mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
344
Commits
No files matched your search
@@ -29,7 +29,8 @@ env:
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
# The Mac mini's runners only: the audit compares with the previous one, so it stays on one platform.
|
||||
runs-on: [self-hosted, horizon-game, macOS]
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- name: 03:00 in Paris
|
||||
|
||||
@@ -23,7 +23,11 @@ env:
|
||||
|
||||
jobs:
|
||||
parity-main:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
|
||||
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
|
||||
# behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
|
||||
# because the maintainer came back could time out, and a failure here bisects and comments on merges.
|
||||
runs-on: [self-hosted, macOS, horizon-parity-main]
|
||||
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -36,11 +36,30 @@ jobs:
|
||||
- name: Tools from the runner
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: |
|
||||
# main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on
|
||||
# the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own.
|
||||
set -euo pipefail
|
||||
if git rev-parse --verify -q origin/main >/dev/null \
|
||||
&& git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then
|
||||
git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh"
|
||||
bash "$RUNNER_TEMP/vanilla-data.sh"
|
||||
else
|
||||
bash tools/ci/vanilla-data.sh
|
||||
fi
|
||||
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
|
||||
run: |
|
||||
# The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither
|
||||
# the tool nor the number, so there is nothing to check.
|
||||
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
|
||||
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
|
||||
exit 0
|
||||
fi
|
||||
python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
|
||||
- name: The version constants are the pinned jar's
|
||||
run: python3 tools/gen_version_consts.py --check
|
||||
- name: The harnesses this change can affect
|
||||
|
||||
@@ -79,9 +79,50 @@ jobs:
|
||||
# A dry run builds whatever the decision, to test the builds.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
|
||||
|
||||
build:
|
||||
exporters:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
|
||||
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: bash tools/ci/runner-env.sh
|
||||
- run: bash tools/ci/vanilla-data.sh
|
||||
- name: Compile and validate original exporters against the pinned jars
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
|
||||
# prepares afresh.
|
||||
cargo build --release --locked -p horizon-prepare
|
||||
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
|
||||
# write those (vanilla-data.sh links .local/exporters to them).
|
||||
rm -rf .local/exporters
|
||||
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
|
||||
mkdir -p exporters
|
||||
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
|
||||
# Only our classes and their manifest leave this runner.
|
||||
stray=$(find exporters -type f ! -path exporters/manifest.json \
|
||||
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
|
||||
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
|
||||
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
build:
|
||||
needs: [decide, exporters]
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
name: build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -220,6 +261,10 @@ jobs:
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -31,3 +31,6 @@
|
||||
/.horizon-cache/
|
||||
/crash-reports/
|
||||
/debug/
|
||||
|
||||
# Release-only compiled original exporter bundle.
|
||||
/exporters/
|
||||
Generated
+253
-310
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/zoom", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -31,7 +31,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
||||
@@ -208,6 +208,9 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
// The GUI of a 1280 x 720 window at scale 3.
|
||||
gui_size: (427.0, 240.0),
|
||||
..Snapshot::default()
|
||||
});
|
||||
mods.tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
@@ -228,8 +231,15 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
Output::Shake { strength, seconds } => {
|
||||
format!("shook the camera: {strength:.2} for {seconds:.1} s")
|
||||
}
|
||||
});
|
||||
}
|
||||
let shapes = mods.hud_shapes().count();
|
||||
if shapes > 0 {
|
||||
report.output.push(format!("drew {shapes} HUD shape(s)"));
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
None => Ok(()),
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
|
||||
//! `tests/*.toml` scripts in the sandbox, with no game.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Capability, Content, Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
testing::{Script, TestGame},
|
||||
};
|
||||
mod check;
|
||||
mod registry;
|
||||
@@ -36,9 +39,10 @@ fn main() -> ExitCode {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("test") => test(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -97,6 +101,10 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
(MANIFEST, include_str!("../template/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/src/lib.rs")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
(
|
||||
"tests/hello.toml",
|
||||
include_str!("../template/tests/hello.toml"),
|
||||
),
|
||||
];
|
||||
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
|
||||
let content = [
|
||||
@@ -270,6 +278,63 @@ fn check(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
|
||||
/// against the built mod, or the `.hmod` given with `--hmod`.
|
||||
fn test(args: &[String]) -> Result<(), String> {
|
||||
let hmod = match flag(args, "--hmod") {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => build(!args.iter().any(|a| a == "--debug"))?,
|
||||
};
|
||||
let filter = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, a)| !a.starts_with("--") && (*i == 0 || args[i - 1] != "--hmod"))
|
||||
.map(|(_, a)| a.as_str());
|
||||
let mut scripts: Vec<PathBuf> = std::fs::read_dir("tests")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"tests/: {e} (a mod's tests are tests/<name>.toml: docs/mods.md, \"Testing a mod\")"
|
||||
)
|
||||
})?
|
||||
.filter_map(|e| Some(e.ok()?.path()))
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
|
||||
.filter(|p| filter.is_none_or(|f| p.to_string_lossy().contains(f)))
|
||||
.collect();
|
||||
scripts.sort();
|
||||
if scripts.is_empty() {
|
||||
return Err("no tests/*.toml to run".into());
|
||||
}
|
||||
println!(
|
||||
"running {} scripts against {}",
|
||||
scripts.len(),
|
||||
hmod.display()
|
||||
);
|
||||
let mut failed = 0;
|
||||
for path in &scripts {
|
||||
let result = std::fs::read_to_string(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| Script::parse(&text))
|
||||
.and_then(|script| {
|
||||
let mut game = TestGame::load(&hmod)?;
|
||||
script.run(&mut game)?;
|
||||
Ok(script.steps.len())
|
||||
});
|
||||
match result {
|
||||
Ok(steps) => println!("test {} ... ok ({steps} steps)", path.display()),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
println!("test {} ... FAILED\n {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
let passed = scripts.len() - failed;
|
||||
if failed > 0 {
|
||||
return Err(format!("{failed} failed, {passed} passed"));
|
||||
}
|
||||
println!("test result: ok. {passed} passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let game = flag(args, "--game")
|
||||
@@ -312,6 +377,8 @@ mod tests {
|
||||
);
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
|
||||
let script = std::fs::read_to_string(path.join("tests/hello.toml")).unwrap();
|
||||
assert_eq!(Script::parse(&script).unwrap().steps.len(), 3);
|
||||
assert!(
|
||||
new(&[path.display().to_string()]).is_err(),
|
||||
"never overwrites"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# `cargo horizon-mod test` runs every tests/*.toml against the built mod, with no game:
|
||||
# steps happen in order, and `expect` checks what the mod showed during the step.
|
||||
# Every key: docs/mods.md, "Testing a mod".
|
||||
|
||||
[[step]]
|
||||
expect.commands = ["hello"]
|
||||
|
||||
[[step]]
|
||||
command = "hello world"
|
||||
expect.chat = ["Hello world! (1 so far)"]
|
||||
|
||||
[[step]]
|
||||
command = "hello again"
|
||||
expect.chat = ["Hello again! (2 so far)"]
|
||||
@@ -18,10 +18,12 @@ horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
|
||||
@@ -367,6 +367,7 @@ pub fn main() -> Result<()> {
|
||||
lid: &|_| open,
|
||||
sign: &|_| sign.as_ref(),
|
||||
banner: &|_| layers.clone(),
|
||||
skull: &|_| None,
|
||||
glyph: &|c| atlas.glyph_cell(c),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! usage: chat_screen_fixture RESOURCES REGISTRIES CASES OUTPUT
|
||||
use crate::pack_screen_fixture;
|
||||
use horizon_client::{
|
||||
chat::{ChatAction, ChatScreen, Commands},
|
||||
chat::{ChatAbilities, ChatAction, ChatScreen, ChatVisibility, Commands},
|
||||
command::vanilla,
|
||||
hud::HudAtlas,
|
||||
lang,
|
||||
@@ -53,6 +53,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
history.len(),
|
||||
w,
|
||||
h,
|
||||
ChatAbilities::new(ChatVisibility::Full),
|
||||
);
|
||||
screen.auto_suggestions = case["autoSuggest"] == true;
|
||||
let mut closed = false;
|
||||
|
||||
@@ -319,6 +319,7 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
atlas,
|
||||
comp(&m["text"]),
|
||||
indicator,
|
||||
horizon_client::chat::ChatSource::Client,
|
||||
m["at"].as_u64().unwrap_or(0),
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -598,6 +598,7 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
|
||||
let _ = writeln!(out, " removed {reason}");
|
||||
return;
|
||||
}
|
||||
let _ = write!(out, "\tleashed {}", u8::from(m.extra.leash.is_some()));
|
||||
let p = m.position;
|
||||
let v = m.velocity;
|
||||
let _ = write!(
|
||||
@@ -977,6 +978,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
// Set once the brain exists (below).
|
||||
x if x.starts_with("cooldown=") => {}
|
||||
"dead" => m.health = 0.0,
|
||||
"leashed" => m.extra.leash = Some(horizon_client::mobs::leash::Holder::Player),
|
||||
x if x.starts_with("leashto=") => {
|
||||
let n: u32 = x[8..].parse().unwrap();
|
||||
@@ -1097,6 +1099,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
};
|
||||
}
|
||||
let mut dropped_leads = 0;
|
||||
for t in 1..=s.ticks {
|
||||
world.game_time += 1;
|
||||
if s.daycycle {
|
||||
@@ -1208,6 +1211,16 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
if !alive {
|
||||
m.removed = true;
|
||||
}
|
||||
// Apply the same detach/drop event as Game::break_leash.
|
||||
for event in &events[from..] {
|
||||
if let MobEvent::LeashBreak { mob, drop, .. } = event
|
||||
&& let Some(m) = mobs.iter_mut().find(|m| m.id == *mob)
|
||||
&& horizon_client::mobs::leash::remove(m).is_some()
|
||||
&& *drop
|
||||
{
|
||||
dropped_leads += 1;
|
||||
}
|
||||
}
|
||||
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
|
||||
villager_events(
|
||||
&mut mobs,
|
||||
@@ -1427,6 +1440,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
world.game_time,
|
||||
);
|
||||
}
|
||||
if s.name.starts_with("leash_death_") {
|
||||
let _ = writeln!(out, "{t} leads {dropped_leads}");
|
||||
}
|
||||
for e in &events {
|
||||
if let MobEvent::Sound(name, _, volume, pitch) = e {
|
||||
let _ = writeln!(
|
||||
|
||||
@@ -730,6 +730,10 @@ pub struct Scene<'a> {
|
||||
pub sign: &'a dyn Fn([i32; 3]) -> Option<&'a crate::signs::SignData>,
|
||||
/// Banner layers at a position: pattern texture and RGB tint, bottom first.
|
||||
pub banner: &'a dyn Fn([i32; 3]) -> Vec<(String, u32)>,
|
||||
/// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
|
||||
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
|
||||
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
}
|
||||
@@ -1279,6 +1283,13 @@ impl BlockEntityRenderer {
|
||||
SkullType::Piglin => ("piglin_head", "entity/piglin/piglin"),
|
||||
SkullType::Player => ("player_head", "entity/player/slim/steve"),
|
||||
};
|
||||
// SkullBlockRenderer.getRenderType: the profile's skin when it
|
||||
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
|
||||
let texture = if *kind == SkullType::Player {
|
||||
(scene.skull)(pos).unwrap_or(texture)
|
||||
} else {
|
||||
texture
|
||||
};
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
@@ -1886,6 +1897,7 @@ mod tests {
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
|
||||
@@ -87,6 +87,19 @@ fn filled(player: &mut dyn Player, new: ItemStack, limit_creative: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LayeredCauldronBlock.handleEntityOnFireInside`: a burning entity in a
|
||||
/// water cauldron lowers it a level; a powder snow cauldron becomes a water
|
||||
/// cauldron of its level first, then lowers.
|
||||
pub fn extinguished(cauldron: &BlockState) -> BlockState {
|
||||
if cauldron.name == "minecraft:powder_snow_cauldron" {
|
||||
let mut water = cauldron.clone();
|
||||
water.name = "minecraft:water_cauldron".into();
|
||||
lowered(&water)
|
||||
} else {
|
||||
lowered(cauldron)
|
||||
}
|
||||
}
|
||||
|
||||
/// `LayeredCauldronBlock.lowerFillLevel`.
|
||||
fn lowered(cauldron: &BlockState) -> BlockState {
|
||||
let level: u8 = cauldron
|
||||
@@ -296,3 +309,24 @@ pub fn interact(
|
||||
_ => pass(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_burning_entity_lowers_water_and_melts_powder_snow() {
|
||||
let water = |l| state("minecraft:water_cauldron", Some(l));
|
||||
assert_eq!(extinguished(&water(3)), water(2));
|
||||
assert_eq!(extinguished(&water(1)), state("minecraft:cauldron", None));
|
||||
// Powder snow turns to water of its level, then lowers.
|
||||
assert_eq!(
|
||||
extinguished(&state("minecraft:powder_snow_cauldron", Some(3))),
|
||||
water(2)
|
||||
);
|
||||
assert_eq!(
|
||||
extinguished(&state("minecraft:powder_snow_cauldron", Some(1))),
|
||||
state("minecraft:cauldron", None)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,92 @@ mod tests;
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
|
||||
/// `ChatVisiblity`: the Chat option's three values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ChatVisibility {
|
||||
Full,
|
||||
System,
|
||||
Hidden,
|
||||
}
|
||||
impl ChatVisibility {
|
||||
/// Options.chatVisibility's saved value (0 full, 1 system, 2 hidden).
|
||||
pub fn from_option(value: u8) -> Self {
|
||||
match value {
|
||||
0 => Self::Full,
|
||||
1 => Self::System,
|
||||
_ => Self::Hidden,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatAbilities`: what the Chat option allows, as `Minecraft.computeChatAbilities`
|
||||
/// derives it from the visibility alone (`ChatRestriction`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ChatAbilities {
|
||||
pub send_messages: bool,
|
||||
pub send_commands: bool,
|
||||
pub receive_player_messages: bool,
|
||||
pub receive_system_messages: bool,
|
||||
}
|
||||
impl ChatAbilities {
|
||||
/// `ChatRestriction.CHAT_AND_COMMANDS_DISABLED_BY_OPTIONS` (Hidden) takes
|
||||
/// chat, commands, player messages and system messages;
|
||||
/// `CHAT_DISABLED_BY_OPTIONS` (System) takes the chat and player messages.
|
||||
pub fn new(visibility: ChatVisibility) -> Self {
|
||||
match visibility {
|
||||
ChatVisibility::Full => Self {
|
||||
send_messages: true,
|
||||
send_commands: true,
|
||||
receive_player_messages: true,
|
||||
receive_system_messages: true,
|
||||
},
|
||||
ChatVisibility::System => Self {
|
||||
send_messages: false,
|
||||
send_commands: true,
|
||||
receive_player_messages: false,
|
||||
receive_system_messages: true,
|
||||
},
|
||||
ChatVisibility::Hidden => Self {
|
||||
send_messages: false,
|
||||
send_commands: false,
|
||||
receive_player_messages: false,
|
||||
receive_system_messages: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
/// `visibleMessagesFilter`: the client's own lines always show.
|
||||
pub fn shows(&self, source: ChatSource) -> bool {
|
||||
match source {
|
||||
ChatSource::Player => self.receive_player_messages,
|
||||
ChatSource::Server => self.receive_system_messages,
|
||||
ChatSource::Client => true,
|
||||
}
|
||||
}
|
||||
/// Whether the chat screen may send `input` (`hasAllowedInput`).
|
||||
pub fn allows(&self, input: &str) -> bool {
|
||||
if input.starts_with('/') {
|
||||
self.send_commands
|
||||
} else {
|
||||
self.send_messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChatAbilities {
|
||||
fn default() -> Self {
|
||||
Self::new(ChatVisibility::Full)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a chat line came from (`GuiMessageSource`): the visibility filters
|
||||
/// player messages and the server's, while the client's own always show.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ChatSource {
|
||||
Player,
|
||||
Server,
|
||||
Client,
|
||||
}
|
||||
|
||||
/// The chat options (doubles, as vanilla keeps them).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ChatOptions {
|
||||
@@ -71,6 +157,7 @@ struct Message {
|
||||
added: u64,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
source: ChatSource,
|
||||
}
|
||||
/// A wrapped line (`GuiMessage.Line`).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -105,6 +192,8 @@ pub struct Chat {
|
||||
pub log: bool,
|
||||
/// Messages added since the start, for the control socket's events.
|
||||
pub received: u64,
|
||||
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
|
||||
abilities: ChatAbilities,
|
||||
}
|
||||
impl Chat {
|
||||
pub fn lines_per_page(&self, focused: bool) -> usize {
|
||||
@@ -114,16 +203,32 @@ impl Chat {
|
||||
fn max_width(&self) -> i32 {
|
||||
(f64::from(self.options.width_px()) / self.options.scale).floor() as i32
|
||||
}
|
||||
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines.
|
||||
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines. A
|
||||
/// message the Chat option hides is dropped, unlogged.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
source: ChatSource,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
// ChatComponent.logChatMessage: every message is in the log.
|
||||
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
|
||||
}
|
||||
fn add_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
source: ChatSource,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
if !self.abilities.shows(source) {
|
||||
return;
|
||||
}
|
||||
// ChatComponent.logChatMessage: every message shown is in the log.
|
||||
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
|
||||
if self.log {
|
||||
println!("[CHAT] {}", content.plain());
|
||||
@@ -132,16 +237,17 @@ impl Chat {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator,
|
||||
source,
|
||||
};
|
||||
self.add_lines(atlas, &message, focused);
|
||||
self.add_lines(font, &message, focused);
|
||||
self.messages.insert(0, message);
|
||||
self.messages.truncate(100);
|
||||
self.received += 1;
|
||||
}
|
||||
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
|
||||
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
|
||||
let width = self.max_width();
|
||||
let content = self.displayed(&message.content);
|
||||
for line in atlas.font.wrap_components(&content, width) {
|
||||
for line in font.wrap_components(&content, width) {
|
||||
if focused && self.scroll > 0 {
|
||||
self.new_since_scroll = true;
|
||||
self.scroll_by(1, focused);
|
||||
@@ -167,16 +273,38 @@ impl Chat {
|
||||
content.strip_legacy()
|
||||
}
|
||||
}
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat).
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
|
||||
/// keeping only the messages the Chat option shows.
|
||||
pub fn rescale(&mut self, atlas: &HudAtlas) {
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
fn rewrap(&mut self, font: &crate::font::Font) {
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.lines.clear();
|
||||
let messages: Vec<Message> = self.messages.iter().rev().cloned().collect();
|
||||
let messages: Vec<Message> = self
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|m| self.abilities.shows(m.source))
|
||||
.cloned()
|
||||
.collect();
|
||||
for m in &messages {
|
||||
self.add_lines(atlas, m, false);
|
||||
self.add_lines(font, m, false);
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.setVisibleMessageFilter`: the Chat option changed;
|
||||
/// the messages it now hides leave the chat, those it shows again return.
|
||||
pub fn set_abilities(&mut self, font: &crate::font::Font, abilities: ChatAbilities) {
|
||||
if self.abilities != abilities {
|
||||
self.abilities = abilities;
|
||||
self.rewrap(font);
|
||||
}
|
||||
}
|
||||
/// Whether a message from `source` would show (`visibleMessagesFilter`).
|
||||
pub fn shows(&self, source: ChatSource) -> bool {
|
||||
self.abilities.shows(source)
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.lines.clear();
|
||||
@@ -437,6 +565,8 @@ pub struct ChatScreen {
|
||||
allow_suggestions: bool,
|
||||
keep_suggestions: bool,
|
||||
pub auto_suggestions: bool,
|
||||
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
|
||||
pub abilities: ChatAbilities,
|
||||
}
|
||||
/// Local completions waiting for the server's reply to request `id`.
|
||||
struct Awaiting {
|
||||
@@ -482,6 +612,7 @@ impl ChatScreen {
|
||||
history_len: usize,
|
||||
width: i32,
|
||||
height: i32,
|
||||
abilities: ChatAbilities,
|
||||
) -> Self {
|
||||
let mut input = TextField::new(4, height - 12, width - 4, 12);
|
||||
input.max_length = 256;
|
||||
@@ -505,6 +636,7 @@ impl ChatScreen {
|
||||
allow_suggestions: false,
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
@@ -522,7 +654,8 @@ impl ChatScreen {
|
||||
if (width, height) != (self.width, self.height) {
|
||||
let value = self.input.value.clone();
|
||||
let auto = self.auto_suggestions;
|
||||
*self = Self::new(m, c, &value, self.history_pos, width, height);
|
||||
let abilities = self.abilities;
|
||||
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
|
||||
self.auto_suggestions = auto;
|
||||
}
|
||||
}
|
||||
@@ -604,6 +737,14 @@ impl ChatScreen {
|
||||
self.pending = Some((range.0, list));
|
||||
self.usage_width = 0;
|
||||
self.usage_position = 0;
|
||||
// CommandSuggestions.updateCommandInfo: the Chat option may take
|
||||
// the messages.
|
||||
if !self.abilities.send_messages {
|
||||
let line =
|
||||
Text::translate("chat_screen.messages_not_allowed", &[]).with_color(text::RED);
|
||||
self.usage_width = m.width(&line);
|
||||
self.usage.push(line);
|
||||
}
|
||||
} else {
|
||||
self.awaiting = None;
|
||||
self.pending = None;
|
||||
@@ -708,6 +849,15 @@ impl ChatScreen {
|
||||
}
|
||||
self.usage.extend(entries);
|
||||
}
|
||||
// CommandSuggestions.updateUsageInfo: the Chat option may take the
|
||||
// commands, under the usage. (Vanilla also counts a command with a
|
||||
// message argument, `/say hi`, as a message; Horizon's parse keeps no
|
||||
// argument kinds, so only a bare message does.)
|
||||
if !self.abilities.send_commands {
|
||||
self.usage.push(
|
||||
Text::translate("chat_screen.commands_not_allowed", &[]).with_color(text::RED),
|
||||
);
|
||||
}
|
||||
self.usage_width = self.usage.iter().map(|u| m.width(u)).max().unwrap_or(0);
|
||||
self.usage_position = if self.usage.is_empty() {
|
||||
0
|
||||
@@ -842,7 +992,14 @@ impl ChatScreen {
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => return ChatAction::Submit(self.normalized()),
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
|
||||
@@ -2,6 +2,11 @@ use super::*;
|
||||
use crate::{font::Font, hud::Rect};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
/// The Chat option's default: no restriction.
|
||||
fn full() -> ChatAbilities {
|
||||
ChatAbilities::new(ChatVisibility::Full)
|
||||
}
|
||||
|
||||
fn font() -> Font {
|
||||
// The missing-glyph metrics let these tests run without game assets.
|
||||
Font::load(
|
||||
@@ -139,7 +144,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
|
||||
assert_eq!(link.component()["click_event"]["command"], expected);
|
||||
let mut chat = Chat::default();
|
||||
add(&mut chat, &font, Text::literal("Found at ").append(link));
|
||||
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360);
|
||||
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
|
||||
let x = 4.0 + f64::from(font.width("Found at "));
|
||||
assert!(!screen.click(&m, &commands, x, 315.0));
|
||||
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
|
||||
@@ -176,7 +181,15 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
|
||||
registries: ®istries,
|
||||
};
|
||||
let history = vec!["first message".to_owned(), "latest message".to_owned()];
|
||||
let mut screen = ChatScreen::new(&m, &commands, "unfinished draft", history.len(), 640, 360);
|
||||
let mut screen = ChatScreen::new(
|
||||
&m,
|
||||
&commands,
|
||||
"unfinished draft",
|
||||
history.len(),
|
||||
640,
|
||||
360,
|
||||
full(),
|
||||
);
|
||||
for (key, expected) in [
|
||||
(ArrowUp, "latest message"),
|
||||
(ArrowUp, "first message"),
|
||||
@@ -213,7 +226,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
dispatcher,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, line);
|
||||
screen
|
||||
};
|
||||
@@ -259,7 +272,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
let tab = |screen: &mut ChatScreen| {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
let (request, sent) = screen.take_request().expect("the server is asked");
|
||||
assert_eq!(sent, "/bossbar get pt");
|
||||
@@ -281,7 +294,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
dispatcher: local,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
assert_eq!(screen.take_request(), None);
|
||||
}
|
||||
@@ -327,3 +340,99 @@ fn a_server_component_keeps_its_click_and_hover_events() {
|
||||
let past = 4.0 + f64::from(font.width("CLICK")) + 20.0;
|
||||
assert!(chat.hover_at(&font, 360, past, 315.0).is_none());
|
||||
}
|
||||
|
||||
/// `ChatVisiblity`: Commands Only keeps the chat out, Hidden takes the
|
||||
/// commands too (`ChatAbilities`), and Enter sends nothing the option hides.
|
||||
#[test]
|
||||
fn the_chat_option_decides_what_may_be_sent() {
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let enter = |screen: &mut ChatScreen| {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Enter, false, false)
|
||||
};
|
||||
for (visibility, chat, command) in [
|
||||
(ChatVisibility::Full, true, true),
|
||||
(ChatVisibility::System, false, true),
|
||||
(ChatVisibility::Hidden, false, false),
|
||||
] {
|
||||
let abilities = ChatAbilities::new(visibility);
|
||||
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
|
||||
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
|
||||
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
|
||||
screen.type_text(&m, &c, line);
|
||||
match enter(&mut screen) {
|
||||
ChatAction::Submit(sent) => {
|
||||
assert!(allowed, "{visibility:?} sent {sent}");
|
||||
assert_eq!(sent, line);
|
||||
}
|
||||
ChatAction::None => assert!(!allowed, "{visibility:?} kept {line}"),
|
||||
ChatAction::Close => panic!("Enter does not close the chat"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The restriction the option sets is a usage line: red, under the input, for
|
||||
/// a command the Chat option takes and for a message it takes.
|
||||
#[test]
|
||||
fn restricted_input_says_why_it_will_not_send() {
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let usage = |visibility, line: &str| {
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
|
||||
screen.type_text(&m, &c, line);
|
||||
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(usage(ChatVisibility::Full, "hi"), Vec::<String>::new());
|
||||
// The font has no language files here, so the line is its key.
|
||||
assert_eq!(
|
||||
usage(ChatVisibility::System, "hi"),
|
||||
["chat_screen.messages_not_allowed"]
|
||||
);
|
||||
// A command the option allows says nothing about the restriction.
|
||||
assert!(
|
||||
!usage(ChatVisibility::System, "/time set day")
|
||||
.iter()
|
||||
.any(|line| line == "chat_screen.commands_not_allowed")
|
||||
);
|
||||
assert!(
|
||||
usage(ChatVisibility::Hidden, "/time set day")
|
||||
.iter()
|
||||
.any(|line| line == "chat_screen.commands_not_allowed")
|
||||
);
|
||||
}
|
||||
|
||||
/// `ChatComponent.setVisibleMessageFilter`: a message the Chat option hides
|
||||
/// on arrival is dropped; one already in the chat leaves it while the option
|
||||
/// hides its kind and comes back when it shows it again.
|
||||
#[test]
|
||||
fn the_chat_option_filters_the_lines_both_ways() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
let says = |chat: &mut Chat, text: &str, source| {
|
||||
chat.add_message(&font, Text::literal(text), None, source, 0, false);
|
||||
};
|
||||
let shown =
|
||||
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
|
||||
says(&mut chat, "player", ChatSource::Player);
|
||||
says(&mut chat, "server", ChatSource::Server);
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
|
||||
assert_eq!(shown(&chat), ["server"]);
|
||||
says(&mut chat, "dropped", ChatSource::Player);
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Hidden));
|
||||
says(&mut chat, "client", ChatSource::Client);
|
||||
assert_eq!(shown(&chat), ["client"]);
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Full));
|
||||
assert_eq!(shown(&chat), ["client", "server", "player"]);
|
||||
}
|
||||
@@ -92,6 +92,13 @@ impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
|
||||
) -> std::result::Result<bool, Self::Error> {
|
||||
self.blocks.is_suffocating(chunk, block, p)
|
||||
}
|
||||
fn may_reach_out(
|
||||
&mut self,
|
||||
chunk: Self::Chunk,
|
||||
section_y: i32,
|
||||
) -> std::result::Result<bool, Self::Error> {
|
||||
self.blocks.may_reach_out(chunk, section_y)
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
block: CollisionBlock,
|
||||
@@ -262,6 +269,17 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
fn collision_block(&mut self, chunk: Self::Chunk, p: BlockPos) -> io::Result<CollisionBlock> {
|
||||
self.binding.collision_block(chunk.block(p.x, p.y, p.z))
|
||||
}
|
||||
/// From the section's palette, without reading its blocks; a palette
|
||||
/// of the whole registry reads every cell.
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
Ok(blocks.is_none_or(|blocks| {
|
||||
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
|
||||
}))
|
||||
}
|
||||
fn is_suffocating(
|
||||
&mut self,
|
||||
_: Self::Chunk,
|
||||
|
||||
@@ -592,3 +592,21 @@ fn mod_commands_join_the_tree_for_every_player() {
|
||||
// The game's own tree is left as it was.
|
||||
assert!(vanilla::game_dispatcher().find("where").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizon_commands_are_off_unless_the_server_turns_them_on() {
|
||||
// By default the game runs vanilla's tree: /help lists no /pregen.
|
||||
assert!(vanilla::game_dispatcher().find("pregen").is_none());
|
||||
for dev in [false, true] {
|
||||
let vanilla_tree = vanilla::game_tree(dev, false);
|
||||
assert!(vanilla_tree.find("pregen").is_none());
|
||||
let d = vanilla::game_tree(dev, true);
|
||||
let parse = d.parse("pregen start 100 circle", ®());
|
||||
assert_eq!(parse.executable().unwrap().1, "pregen.start");
|
||||
// Vanilla's commands are the same with or without it.
|
||||
assert_eq!(
|
||||
d.nodes[0].children.len(),
|
||||
vanilla_tree.nodes[0].children.len() + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -538,12 +538,69 @@ fn apply_vanilla_order(d: &mut Dispatcher) {
|
||||
}
|
||||
/// The tree the game runs: a release integrated server's, or with
|
||||
/// `HORIZON_DEV_COMMANDS` set the development one
|
||||
/// (SharedConstants.DEBUG_DEV_COMMANDS).
|
||||
/// (SharedConstants.DEBUG_DEV_COMMANDS), plus Horizon's own commands when
|
||||
/// the server turned them on (`set_horizon_commands`).
|
||||
pub fn game_dispatcher() -> &'static Dispatcher {
|
||||
if std::env::var_os("HORIZON_DEV_COMMANDS").is_some() {
|
||||
dispatcher()
|
||||
} else {
|
||||
release_dispatcher()
|
||||
game_tree(
|
||||
std::env::var_os("HORIZON_DEV_COMMANDS").is_some(),
|
||||
HORIZON_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
/// Whether this process's server has Horizon's own commands (`/pregen`). Off
|
||||
/// by default, so `/help` and the command tree are vanilla's; a dedicated
|
||||
/// server turns them on with `horizon-pregen=true` in server.properties, the
|
||||
/// game with `--pregen` (docs/pregeneration.md).
|
||||
static HORIZON_COMMANDS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Turns Horizon's own commands on or off for the trees `game_dispatcher`
|
||||
/// returns from now on.
|
||||
pub fn set_horizon_commands(on: bool) {
|
||||
HORIZON_COMMANDS.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// The server.properties key that turns `/pregen` on (Horizon's own; vanilla
|
||||
/// ignores keys it doesn't know).
|
||||
pub const PREGEN_PROPERTY: &str = "horizon-pregen";
|
||||
pub(crate) fn game_tree(dev: bool, horizon: bool) -> &'static Dispatcher {
|
||||
static HORIZON: [std::sync::OnceLock<Dispatcher>; 2] =
|
||||
[const { std::sync::OnceLock::new() }; 2];
|
||||
match (dev, horizon) {
|
||||
(true, false) => dispatcher(),
|
||||
(false, false) => release_dispatcher(),
|
||||
(dev, true) => HORIZON[usize::from(dev)].get_or_init(|| {
|
||||
let mut d = build_with(dev);
|
||||
horizon_commands(&mut d, 0);
|
||||
d
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// Commands vanilla doesn't have, after its own: `/pregen`
|
||||
/// (docs/pregeneration.md).
|
||||
fn horizon_commands(d: &mut Dispatcher, root: usize) {
|
||||
let pregen = d.literal(root, "pregen");
|
||||
d.requires(pregen, GAMEMASTERS);
|
||||
let start = d.literal(pregen, "start");
|
||||
let radius = d.argument(
|
||||
start,
|
||||
"radius",
|
||||
ArgType::Integer {
|
||||
min: 0,
|
||||
max: 30_000_000,
|
||||
},
|
||||
);
|
||||
d.executes(radius, "pregen.start");
|
||||
for shape in ["square", "circle"] {
|
||||
let shape = d.literal(radius, shape);
|
||||
d.executes(shape, "pregen.start");
|
||||
let center = d.argument(shape, "center", ArgType::ColumnPos);
|
||||
d.executes(center, "pregen.start");
|
||||
}
|
||||
for (word, id) in [
|
||||
("pause", "pregen.pause"),
|
||||
("continue", "pregen.continue"),
|
||||
("cancel", "pregen.cancel"),
|
||||
("status", "pregen.status"),
|
||||
] {
|
||||
let w = d.literal(pregen, word);
|
||||
d.executes(w, id);
|
||||
}
|
||||
}
|
||||
pub fn dispatcher() -> &'static Dispatcher {
|
||||
|
||||
@@ -83,6 +83,60 @@ pub enum BlockEntity {
|
||||
Beehive(Box<crate::beehive::Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<crate::command_block::CommandBlock>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
/// sound and `custom_name`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Skull {
|
||||
pub profile: Option<horizon_world::nbt::Tag>,
|
||||
pub note_block_sound: Option<String>,
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
/// What `profile` draws, read from it once rather than every frame.
|
||||
pub skin: Option<SkullSkin>,
|
||||
}
|
||||
impl Skull {
|
||||
pub fn new(
|
||||
profile: Option<horizon_world::nbt::Tag>,
|
||||
note_block_sound: Option<String>,
|
||||
custom_name: Option<horizon_world::nbt::Tag>,
|
||||
) -> Self {
|
||||
let skin = profile.as_ref().map(SkullSkin::of);
|
||||
Self {
|
||||
profile,
|
||||
note_block_sound,
|
||||
custom_name,
|
||||
skin,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
|
||||
/// with the `skins/<sha1>` sprite it downloads to, and the
|
||||
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
|
||||
/// profile with no id).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkullSkin {
|
||||
pub download: Option<(String, String)>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
impl SkullSkin {
|
||||
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
|
||||
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
|
||||
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
|
||||
let fallback = crate::skins::profile_uuid_nbt(profile)
|
||||
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
|
||||
Self { download, fallback }
|
||||
}
|
||||
}
|
||||
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
|
||||
/// `*_skull` and `*_head` but the piston's head.
|
||||
fn is_skull(name: &str) -> bool {
|
||||
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
|
||||
}
|
||||
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
|
||||
/// and the ticks left in the crafting animation.
|
||||
@@ -238,6 +292,9 @@ impl BlockEntity {
|
||||
if name.ends_with("_banner") {
|
||||
return Some(Self::Banner(Banner::default()));
|
||||
}
|
||||
if is_skull(name) {
|
||||
return Some(Self::Skull(Skull::default()));
|
||||
}
|
||||
if name == "minecraft:beacon" {
|
||||
return Some(Self::Beacon(Box::new(Beacon {
|
||||
last_check_y: i32::MIN / 2,
|
||||
@@ -283,6 +340,7 @@ impl BlockEntity {
|
||||
Self::Vault(_) => "minecraft:vault",
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -298,7 +356,8 @@ impl BlockEntity {
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => Vec::new(),
|
||||
| Self::CommandBlock(_)
|
||||
| Self::Skull(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
@@ -315,7 +374,8 @@ impl BlockEntity {
|
||||
| Self::Beacon(_)
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_) => &[],
|
||||
| Self::Banner(_)
|
||||
| Self::Skull(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
@@ -335,7 +395,8 @@ impl BlockEntity {
|
||||
| Self::Beacon(_)
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_) => &mut [],
|
||||
| Self::Banner(_)
|
||||
| Self::Skull(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! `Player.getCooldowns()` (`ItemCooldowns`) as the game keeps it: the
|
||||
//! `use_cooldown` component's entries by cooldown group, counted down once a
|
||||
//! tick, and the percent `GuiGraphicsExtractor.itemCooldown` shades a slot
|
||||
//! by. The world crate's `item_cooldowns` models vanilla's hash map and its
|
||||
//! observers for the parity harness; this is the same data with the lookup a
|
||||
//! slot needs, and no observer (vanilla's own player uses the plain class).
|
||||
use crate::items::ItemStack;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A stack's `minecraft:use_cooldown` component.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UseCooldown {
|
||||
pub seconds: f32,
|
||||
/// `cooldown_group`: the group the entry is filed under, else the item.
|
||||
pub group: Option<Arc<str>>,
|
||||
}
|
||||
impl UseCooldown {
|
||||
/// `UseCooldown.ticks`: the seconds in ticks, truncated.
|
||||
pub fn ticks(&self) -> i32 {
|
||||
(self.seconds * 20.0) as i32
|
||||
}
|
||||
/// The component as `items.json` holds it.
|
||||
pub fn from_json(v: &serde_json::Value) -> Option<Self> {
|
||||
let seconds = v.get("seconds")?.as_f64()? as f32;
|
||||
Some(Self {
|
||||
seconds,
|
||||
group: v
|
||||
.get("cooldown_group")
|
||||
.and_then(|g| g.as_str())
|
||||
.map(Arc::from),
|
||||
})
|
||||
}
|
||||
/// The component as a stack's saved `components` holds it.
|
||||
pub fn from_tag(t: &Tag) -> Option<Self> {
|
||||
let Tag::Compound(c) = t else {
|
||||
return None;
|
||||
};
|
||||
let text = |k: &str| match c.get(&horizon_world::nbt::JavaString::from(k)) {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
let seconds = match c.get(&horizon_world::nbt::JavaString::from("seconds")) {
|
||||
Some(Tag::Float(bits)) => f32::from_bits(*bits),
|
||||
Some(Tag::Double(bits)) => f64::from_bits(*bits) as f32,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Self {
|
||||
seconds,
|
||||
group: text("cooldown_group").map(Arc::from),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The component a stack carries: its own patch first (`ItemStack.get`), then
|
||||
/// the item's default from `items.json` (`ItemInfo::use_cooldown`).
|
||||
pub fn component(stack: &ItemStack, default: Option<&UseCooldown>) -> Option<UseCooldown> {
|
||||
stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:use_cooldown"))
|
||||
.and_then(UseCooldown::from_tag)
|
||||
.or_else(|| default.cloned())
|
||||
}
|
||||
|
||||
/// `ItemCooldowns.getCooldownGroup`: the component's group, else the item id.
|
||||
pub fn group(stack: &ItemStack, default: Option<&UseCooldown>) -> Arc<str> {
|
||||
component(stack, default)
|
||||
.and_then(|c| c.group)
|
||||
.unwrap_or_else(|| Arc::clone(&stack.item))
|
||||
}
|
||||
|
||||
/// `ItemCooldowns`: start and end ticks by group, and the tick counter.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Cooldowns {
|
||||
tick_count: i32,
|
||||
entries: HashMap<Arc<str>, (i32, i32)>,
|
||||
}
|
||||
impl Cooldowns {
|
||||
/// `getCooldownPercent`: what is left of the entry, from 1 to 0.
|
||||
pub fn percent(&self, group: &str, partial_tick: f32) -> f32 {
|
||||
let Some((start, end)) = self.entries.get(group) else {
|
||||
return 0.0;
|
||||
};
|
||||
let duration = end.wrapping_sub(*start) as f32;
|
||||
let remaining = *end as f32 - (self.tick_count as f32 + partial_tick);
|
||||
(remaining / duration).clamp(0.0, 1.0)
|
||||
}
|
||||
/// `isOnCooldown`: whether the group still has time to run.
|
||||
pub fn is_on_cooldown(&self, group: &str) -> bool {
|
||||
self.percent(group, 0.0) > 0.0
|
||||
}
|
||||
/// `addCooldown`.
|
||||
pub fn add(&mut self, group: Arc<str>, ticks: i32) {
|
||||
self.entries.insert(
|
||||
group,
|
||||
(self.tick_count, self.tick_count.wrapping_add(ticks)),
|
||||
);
|
||||
}
|
||||
/// `ItemCooldowns.tick`: the counter, then every entry that has run out,
|
||||
/// which it hands back (`onCooldownEnded`).
|
||||
pub fn tick(&mut self) -> Vec<Arc<str>> {
|
||||
self.tick_count = self.tick_count.wrapping_add(1);
|
||||
let done: Vec<Arc<str>> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, (_, end))| *end <= self.tick_count)
|
||||
.map(|(group, _)| Arc::clone(group))
|
||||
.collect();
|
||||
self.entries.retain(|_, (_, end)| *end > self.tick_count);
|
||||
done
|
||||
}
|
||||
/// `removeCooldown`.
|
||||
pub fn remove(&mut self, group: &str) {
|
||||
self.entries.remove(group);
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn stack(item: &str) -> ItemStack {
|
||||
ItemStack::new(item, 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_percent_falls_from_one_to_zero_over_the_entry() {
|
||||
let mut c = Cooldowns::default();
|
||||
c.add(Arc::from("minecraft:ender_pearl"), 20);
|
||||
assert!(c.is_on_cooldown("minecraft:ender_pearl"));
|
||||
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 1.0);
|
||||
for _ in 0..10 {
|
||||
assert!(c.tick().is_empty());
|
||||
}
|
||||
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 0.5);
|
||||
// The partial tick moves it within the tick.
|
||||
assert_eq!(c.percent("minecraft:ender_pearl", 0.5), 0.475);
|
||||
for _ in 0..9 {
|
||||
c.tick();
|
||||
}
|
||||
assert!(c.is_on_cooldown("minecraft:ender_pearl"));
|
||||
// ItemCooldowns.tick drops the entry at its end tick, and says so.
|
||||
assert_eq!(c.tick(), [Arc::<str>::from("minecraft:ender_pearl")]);
|
||||
assert!(!c.is_on_cooldown("minecraft:ender_pearl"));
|
||||
assert!(c.is_empty());
|
||||
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_group_comes_from_the_stack_then_the_item() {
|
||||
let pearl = stack("minecraft:ender_pearl");
|
||||
let default = UseCooldown::from_json(&json!({"seconds": 1.0})).expect("the component");
|
||||
assert_eq!(&*group(&pearl, Some(&default)), "minecraft:ender_pearl");
|
||||
// A stack's own component overrides the item's, group included.
|
||||
let mut grouped = stack("minecraft:ender_pearl");
|
||||
grouped.data_mut().set_other(
|
||||
"minecraft:use_cooldown",
|
||||
Some(Tag::compound([
|
||||
("seconds", Tag::Float(2.0f32.to_bits())),
|
||||
("cooldown_group", Tag::String("minecraft:pearls".into())),
|
||||
])),
|
||||
);
|
||||
let c = component(&grouped, Some(&default)).expect("the stack's component");
|
||||
assert_eq!(c.ticks(), 40);
|
||||
assert_eq!(&*group(&grouped, Some(&default)), "minecraft:pearls");
|
||||
// An item without the component has no cooldown.
|
||||
assert!(component(&stack("minecraft:dirt"), None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,33 @@ impl Displayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's own lines: the shader pack's GPU time, then its costliest passes (`passes`: each
|
||||
/// pass's median milliseconds, in the order they run).
|
||||
pub fn shader_pass_lines(passes: &[(String, f32)]) -> Vec<String> {
|
||||
const SHOWN: usize = 8;
|
||||
if passes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let total: f32 = passes.iter().map(|p| p.1).sum();
|
||||
let mut lines = vec![format!(
|
||||
"Shader pack GPU: {total:.2} ms in {} passes",
|
||||
passes.len()
|
||||
)];
|
||||
let mut costliest: Vec<&(String, f32)> = passes.iter().collect();
|
||||
costliest.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
lines.extend(
|
||||
costliest
|
||||
.iter()
|
||||
.take(SHOWN)
|
||||
.map(|(name, ms)| format!("{name}: {ms:.2} ms")),
|
||||
);
|
||||
if costliest.len() > SHOWN {
|
||||
let rest: f32 = costliest[SHOWN..].iter().map(|p| p.1).sum();
|
||||
lines.push(format!("{} more: {rest:.2} ms", costliest.len() - SHOWN));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// `Mth.wrapDegrees`.
|
||||
fn wrap_degrees(angle: f32) -> f32 {
|
||||
let wrapped = angle % 360.0;
|
||||
@@ -337,4 +364,15 @@ mod tests {
|
||||
(1.0, [640, 360])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_pass_lines_list_the_costliest_passes_first() {
|
||||
assert!(shader_pass_lines(&[]).is_empty());
|
||||
let passes: Vec<(String, f32)> = (0..10).map(|i| (format!("p{i}"), i as f32)).collect();
|
||||
let lines = shader_pass_lines(&passes);
|
||||
assert_eq!(lines[0], "Shader pack GPU: 45.00 ms in 10 passes");
|
||||
assert_eq!(lines[1], "p9: 9.00 ms");
|
||||
assert_eq!(lines[8], "p2: 2.00 ms");
|
||||
assert_eq!(lines[9], "2 more: 1.00 ms");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
//! Server dialogs (`net.minecraft.server.dialog`): what `Dialog.DIRECT_CODEC`
|
||||
//! decodes from a `show_dialog` packet, the values its inputs hold, and the
|
||||
//! click event an action makes of them (`Action.createAction`).
|
||||
use crate::text::Text;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// `CommonDialogData` and the type's own fields.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Dialog {
|
||||
pub kind: Kind,
|
||||
pub title: Text,
|
||||
pub external_title: Option<Text>,
|
||||
pub can_close_with_escape: bool,
|
||||
pub pause: bool,
|
||||
pub after_action: AfterAction,
|
||||
pub body: Vec<Body>,
|
||||
pub inputs: Vec<Input>,
|
||||
}
|
||||
|
||||
/// The dialog types (`DialogTypes`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Kind {
|
||||
/// `NoticeDialog`: one button, "OK" when the server names none.
|
||||
Notice(Button),
|
||||
Confirmation {
|
||||
yes: Button,
|
||||
no: Button,
|
||||
},
|
||||
MultiAction {
|
||||
actions: Vec<Button>,
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
},
|
||||
ServerLinks {
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
},
|
||||
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
|
||||
DialogList {
|
||||
dialogs: Tag,
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// `DialogAction`: the screen after an action runs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AfterAction {
|
||||
Close,
|
||||
None,
|
||||
WaitForResponse,
|
||||
}
|
||||
|
||||
/// `ActionButton`: `CommonButtonData` and what it does.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Button {
|
||||
pub label: Text,
|
||||
pub tooltip: Option<Text>,
|
||||
pub width: i32,
|
||||
pub action: Option<Action>,
|
||||
}
|
||||
|
||||
/// `Action`: a fixed click event, or one made from the inputs' values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
/// `StaticAction`: a click event as it is.
|
||||
Static(Click),
|
||||
/// `CommandTemplate`: a command with `$(key)` replaced by input values.
|
||||
RunCommand(String),
|
||||
/// `CustomAll`: a custom click event whose payload is `additions` plus
|
||||
/// every input's value under its key.
|
||||
Custom { id: String, additions: Option<Tag> },
|
||||
}
|
||||
|
||||
/// `ClickEvent`, the kinds a server may send (`isAllowedFromServer`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Click {
|
||||
OpenUrl(String),
|
||||
RunCommand(String),
|
||||
SuggestCommand(String),
|
||||
/// The dialog: an id of the `minecraft:dialog` registry or the dialog itself.
|
||||
ShowDialog(Tag),
|
||||
ChangePage(i32),
|
||||
CopyToClipboard(String),
|
||||
Custom {
|
||||
id: String,
|
||||
payload: Option<Tag>,
|
||||
},
|
||||
}
|
||||
|
||||
/// `DialogBody`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Body {
|
||||
/// `PlainMessage`: centred text wrapped to `width`.
|
||||
Message { contents: Text, width: i32 },
|
||||
/// `ItemBody`: the item as `ItemStackTemplate` sent it.
|
||||
Item {
|
||||
item: Tag,
|
||||
description: Option<(Text, i32)>,
|
||||
show_decorations: bool,
|
||||
show_tooltip: bool,
|
||||
width: i32,
|
||||
height: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// `Input`: the key its value goes by, and its control.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Input {
|
||||
pub key: String,
|
||||
pub control: Control,
|
||||
}
|
||||
|
||||
/// `InputControl`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Control {
|
||||
Text {
|
||||
width: i32,
|
||||
label: Text,
|
||||
label_visible: bool,
|
||||
initial: String,
|
||||
max_length: i32,
|
||||
/// `MultilineOptions`: the most lines and the height, when set.
|
||||
multiline: Option<(Option<i32>, Option<i32>)>,
|
||||
},
|
||||
Boolean {
|
||||
label: Text,
|
||||
initial: bool,
|
||||
on_true: String,
|
||||
on_false: String,
|
||||
},
|
||||
SingleOption {
|
||||
width: i32,
|
||||
/// Each option's id, its label and whether it is the initial one.
|
||||
options: Vec<(String, Option<Text>, bool)>,
|
||||
label: Text,
|
||||
label_visible: bool,
|
||||
},
|
||||
NumberRange {
|
||||
width: i32,
|
||||
label: Text,
|
||||
label_format: String,
|
||||
start: f32,
|
||||
end: f32,
|
||||
initial: Option<f32>,
|
||||
step: Option<f32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What an input holds while the dialog is open.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum InputValue {
|
||||
Text(String),
|
||||
Bool(bool),
|
||||
/// The chosen option's index.
|
||||
Option(usize),
|
||||
/// The slider's position, 0 to 1.
|
||||
Slider(f32),
|
||||
}
|
||||
|
||||
fn text(t: &Tag) -> Text {
|
||||
crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))
|
||||
}
|
||||
fn string(t: Option<&Tag>) -> Option<String> {
|
||||
match t? {
|
||||
Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn number(t: Option<&Tag>) -> Option<f64> {
|
||||
Some(match t? {
|
||||
Tag::Byte(v) => f64::from(*v),
|
||||
Tag::Short(v) => f64::from(*v),
|
||||
Tag::Int(v) => f64::from(*v),
|
||||
Tag::Long(v) => *v as f64,
|
||||
Tag::Float(v) => f64::from(f32::from_bits(*v)),
|
||||
Tag::Double(v) => f64::from_bits(*v),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn int(t: &Tag, key: &str, default: i32) -> i32 {
|
||||
number(t.get(key)).map_or(default, |v| v as i32)
|
||||
}
|
||||
fn boolean(t: &Tag, key: &str, default: bool) -> bool {
|
||||
number(t.get(key)).map_or(default, |v| v != 0.0)
|
||||
}
|
||||
fn list(t: Option<&Tag>) -> &[Tag] {
|
||||
match t {
|
||||
Some(Tag::List(items)) => items,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
/// A registry name without its default namespace.
|
||||
fn path(id: &str) -> &str {
|
||||
id.strip_prefix("minecraft:").unwrap_or(id)
|
||||
}
|
||||
|
||||
impl Button {
|
||||
/// `ActionButton.CODEC`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
Some(Self {
|
||||
label: text(t.get("label")?),
|
||||
tooltip: t.get("tooltip").map(text),
|
||||
width: int(t, "width", 150),
|
||||
action: t.get("action").and_then(Action::parse),
|
||||
})
|
||||
}
|
||||
/// `NoticeDialog.DEFAULT_ACTION`.
|
||||
fn ok() -> Self {
|
||||
Self {
|
||||
label: Text::translate("gui.ok", &[]),
|
||||
tooltip: None,
|
||||
width: 150,
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Click {
|
||||
/// `ClickEvent.CODEC`, from its `action` (a click event in a component)
|
||||
/// or `type` (an action) field.
|
||||
pub fn parse(t: &Tag, kind: &str) -> Option<Self> {
|
||||
let s = |key| string(t.get(key));
|
||||
Some(match path(kind) {
|
||||
"open_url" => Self::OpenUrl(s("url")?),
|
||||
"run_command" => Self::RunCommand(s("command")?),
|
||||
"suggest_command" => Self::SuggestCommand(s("command")?),
|
||||
"show_dialog" => Self::ShowDialog(t.get("dialog")?.clone()),
|
||||
"change_page" => Self::ChangePage(int(t, "page", 1)),
|
||||
"copy_to_clipboard" => Self::CopyToClipboard(s("value")?),
|
||||
"custom" => Self::Custom {
|
||||
id: s("id")?,
|
||||
payload: t.get("payload").cloned(),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// `Action.CODEC`, dispatched on `type`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let kind = string(t.get("type"))?;
|
||||
Some(match path(&kind) {
|
||||
"dynamic/run_command" => Self::RunCommand(string(t.get("template"))?),
|
||||
"dynamic/custom" => Self::Custom {
|
||||
id: string(t.get("id"))?,
|
||||
additions: t.get("additions").cloned(),
|
||||
},
|
||||
other => Self::Static(Click::parse(t, other)?),
|
||||
})
|
||||
}
|
||||
/// `createAction`: the click event, given each input's key, control and value.
|
||||
pub fn click(&self, inputs: &[Input], values: &[InputValue]) -> Option<Click> {
|
||||
let values = || inputs.iter().zip(values);
|
||||
Some(match self {
|
||||
Self::Static(click) => click.clone(),
|
||||
Self::RunCommand(template) => Click::RunCommand(substitute(template, &|key| {
|
||||
values()
|
||||
.find(|(i, _)| i.key == key)
|
||||
.map(|(i, v)| i.control.substitution(v))
|
||||
})),
|
||||
Self::Custom { id, additions } => {
|
||||
let mut tag = match additions {
|
||||
Some(t @ Tag::Compound(_)) => t.clone(),
|
||||
_ => Tag::Compound(Default::default()),
|
||||
};
|
||||
for (input, value) in values() {
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.insert(input.key.as_str().into(), input.control.tag(value));
|
||||
}
|
||||
}
|
||||
Click::Custom {
|
||||
id: id.clone(),
|
||||
payload: Some(tag),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `ParsedTemplate.instantiate`: each `$(key)` replaced by its value, or by
|
||||
/// nothing for a key no input has.
|
||||
fn substitute(template: &str, value: &dyn Fn(&str) -> Option<String>) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
while let Some(at) = rest.find("$(") {
|
||||
let Some(end) = rest[at..].find(')') else {
|
||||
break;
|
||||
};
|
||||
out.push_str(&rest[..at]);
|
||||
out.push_str(&value(&rest[at + 2..at + end]).unwrap_or_default());
|
||||
rest = &rest[at + end + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// `StringTag.escapeWithoutQuotes`.
|
||||
fn escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' | '\'' | '\\' => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
'\u{8}' => out.push_str("\\b"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\u{c}' => out.push_str("\\f"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
c if c < ' ' => out.push_str(&format!("\\x{:02x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Control {
|
||||
/// `InputControl.MAP_CODEC`, dispatched on `type`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let label = || t.get("label").map(text);
|
||||
Some(match path(&string(t.get("type"))?) {
|
||||
"text" => Self::Text {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
initial: string(t.get("initial")).unwrap_or_default(),
|
||||
max_length: int(t, "max_length", 32),
|
||||
multiline: t.get("multiline").map(|m| {
|
||||
let get = |key| number(m.get(key)).map(|v| v as i32);
|
||||
(get("max_lines"), get("height"))
|
||||
}),
|
||||
},
|
||||
"boolean" => Self::Boolean {
|
||||
label: label()?,
|
||||
initial: boolean(t, "initial", false),
|
||||
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
|
||||
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
|
||||
},
|
||||
"single_option" => Self::SingleOption {
|
||||
width: int(t, "width", 200),
|
||||
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
|
||||
options: list(t.get("options"))
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
|
||||
o => Some((
|
||||
string(o.get("id"))?,
|
||||
o.get("display").map(text),
|
||||
boolean(o, "initial", false),
|
||||
)),
|
||||
})
|
||||
.collect(),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
},
|
||||
"number_range" => {
|
||||
let float = |key| number(t.get(key)).map(|v| v as f32);
|
||||
Self::NumberRange {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_format: string(t.get("label_format"))
|
||||
.unwrap_or_else(|| "options.generic_value".into()),
|
||||
start: float("start")?,
|
||||
end: float("end")?,
|
||||
initial: float("initial"),
|
||||
step: float("step"),
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// The value it starts with.
|
||||
pub fn initial(&self) -> InputValue {
|
||||
match self {
|
||||
Self::Text { initial, .. } => InputValue::Text(initial.clone()),
|
||||
Self::Boolean { initial, .. } => InputValue::Bool(*initial),
|
||||
Self::SingleOption { options, .. } => {
|
||||
InputValue::Option(options.iter().position(|o| o.2).unwrap_or(0))
|
||||
}
|
||||
Self::NumberRange {
|
||||
start,
|
||||
end,
|
||||
initial,
|
||||
..
|
||||
} => InputValue::Slider(to_slider(
|
||||
initial.unwrap_or((start + end) / 2.0),
|
||||
*start,
|
||||
*end,
|
||||
)),
|
||||
}
|
||||
}
|
||||
/// `ValueGetter.asTemplateSubstitution`.
|
||||
pub fn substitution(&self, value: &InputValue) -> String {
|
||||
match (self, value) {
|
||||
(Self::Text { .. }, InputValue::Text(s)) => escape(s),
|
||||
(
|
||||
Self::Boolean {
|
||||
on_true, on_false, ..
|
||||
},
|
||||
InputValue::Bool(b),
|
||||
) => if *b { on_true } else { on_false }.clone(),
|
||||
(Self::SingleOption { options, .. }, InputValue::Option(i)) => {
|
||||
options.get(*i).map(|o| o.0.clone()).unwrap_or_default()
|
||||
}
|
||||
(Self::NumberRange { .. }, InputValue::Slider(_)) => {
|
||||
number_string(self.scaled(value).unwrap_or(0.0))
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
/// `ValueGetter.asTag`.
|
||||
pub fn tag(&self, value: &InputValue) -> Tag {
|
||||
match (self, value) {
|
||||
(Self::Boolean { .. }, InputValue::Bool(b)) => Tag::Byte(i8::from(*b)),
|
||||
(Self::NumberRange { .. }, _) => {
|
||||
Tag::Float(self.scaled(value).unwrap_or(0.0).to_bits())
|
||||
}
|
||||
(Self::Text { .. }, InputValue::Text(s)) => Tag::String(s.as_str().into()),
|
||||
_ => Tag::String(self.substitution(value).as_str().into()),
|
||||
}
|
||||
}
|
||||
/// `RangeInfo.computeScaledValue`: a number range's value at its slider position.
|
||||
pub fn scaled(&self, value: &InputValue) -> Option<f32> {
|
||||
let (
|
||||
Self::NumberRange {
|
||||
start,
|
||||
end,
|
||||
initial,
|
||||
step,
|
||||
..
|
||||
},
|
||||
InputValue::Slider(slider),
|
||||
) = (self, value)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let (start, end) = (*start, *end);
|
||||
let in_range = start + slider * (end - start);
|
||||
let Some(step) = step else {
|
||||
return Some(in_range);
|
||||
};
|
||||
let first = initial.unwrap_or((start + end) / 2.0);
|
||||
// Math.round: half up.
|
||||
let steps = ((in_range - first) / step + 0.5).floor() as i32;
|
||||
let result = first + steps as f32 * step;
|
||||
let position = to_slider(result, start, end);
|
||||
if (0.0..=1.0).contains(&position) {
|
||||
return Some(result);
|
||||
}
|
||||
Some(first + (steps - steps.signum()) as f32 * step)
|
||||
}
|
||||
}
|
||||
|
||||
/// `RangeInfo.scaledValueToSlider`.
|
||||
fn to_slider(value: f32, start: f32, end: f32) -> f32 {
|
||||
if start == end {
|
||||
0.5
|
||||
} else {
|
||||
(value - start) / (end - start)
|
||||
}
|
||||
}
|
||||
|
||||
/// `SliderImpl.valueToString`: whole numbers without a fraction, the rest as
|
||||
/// Java's `Float.toString`.
|
||||
pub fn number_string(v: f32) -> String {
|
||||
if v == (v as i32) as f32 {
|
||||
(v as i32).to_string()
|
||||
} else {
|
||||
crate::snbt::java_float(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl Body {
|
||||
/// `DialogBody.DIALOG_BODY_CODEC`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let message = |t: &Tag| -> Option<(Text, i32)> {
|
||||
// PlainMessage.CODEC: the full form or just its contents.
|
||||
match t {
|
||||
Tag::Compound(_) if t.get("contents").is_some() => {
|
||||
Some((text(t.get("contents")?), int(t, "width", 200)))
|
||||
}
|
||||
t => Some((text(t), 200)),
|
||||
}
|
||||
};
|
||||
Some(match path(&string(t.get("type"))?) {
|
||||
"plain_message" => {
|
||||
let (contents, width) = message(t)?;
|
||||
Self::Message { contents, width }
|
||||
}
|
||||
"item" => Self::Item {
|
||||
item: t.get("item")?.clone(),
|
||||
description: t.get("description").and_then(message),
|
||||
show_decorations: boolean(t, "show_decorations", true),
|
||||
show_tooltip: boolean(t, "show_tooltip", true),
|
||||
width: int(t, "width", 16),
|
||||
height: int(t, "height", 16),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
/// `Dialog.DIRECT_CODEC`; `None` for a type or field it can't read
|
||||
/// (vanilla fails the whole packet then).
|
||||
pub fn parse(t: &Tag) -> Option<Self> {
|
||||
let button = |key: &str| t.get(key).and_then(Button::parse);
|
||||
let kind = match path(&string(t.get("type"))?) {
|
||||
"notice" => Kind::Notice(match t.get("action") {
|
||||
Some(b) => Button::parse(b)?,
|
||||
None => Button::ok(),
|
||||
}),
|
||||
"confirmation" => Kind::Confirmation {
|
||||
yes: button("yes")?,
|
||||
no: button("no")?,
|
||||
},
|
||||
"multi_action" => Kind::MultiAction {
|
||||
actions: list(t.get("actions"))
|
||||
.iter()
|
||||
.map(Button::parse)
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.filter(|a| !a.is_empty())?,
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
},
|
||||
"server_links" => Kind::ServerLinks {
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
button_width: int(t, "button_width", 150),
|
||||
},
|
||||
"dialog_list" => Kind::DialogList {
|
||||
dialogs: t.get("dialogs")?.clone(),
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
button_width: int(t, "button_width", 150),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
|
||||
let body = match t.get("body") {
|
||||
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
|
||||
Some(one) => Body::parse(one).into_iter().collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let inputs = list(t.get("inputs"))
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
Some(Input {
|
||||
key: string(i.get("key"))?,
|
||||
control: Control::parse(i)?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(Self {
|
||||
kind,
|
||||
title: text(t.get("title")?),
|
||||
external_title: t.get("external_title").map(text),
|
||||
can_close_with_escape: boolean(t, "can_close_with_escape", true),
|
||||
pause: boolean(t, "pause", true),
|
||||
after_action: match string(t.get("after_action")).as_deref() {
|
||||
Some("none") => AfterAction::None,
|
||||
Some("wait_for_response") => AfterAction::WaitForResponse,
|
||||
_ => AfterAction::Close,
|
||||
},
|
||||
body,
|
||||
inputs,
|
||||
})
|
||||
}
|
||||
/// `onCancel`: what Escape (or closing it) does.
|
||||
pub fn on_cancel(&self) -> Option<&Action> {
|
||||
match &self.kind {
|
||||
Kind::Notice(b) => b.action.as_ref(),
|
||||
Kind::Confirmation { no, .. } => no.action.as_ref(),
|
||||
Kind::MultiAction { exit, .. }
|
||||
| Kind::ServerLinks { exit, .. }
|
||||
| Kind::DialogList { exit, .. } => exit.as_ref().and_then(|b| b.action.as_ref()),
|
||||
}
|
||||
}
|
||||
/// The buttons it draws, in order: the list, then the footer's.
|
||||
pub fn buttons(&self) -> Vec<&Button> {
|
||||
match &self.kind {
|
||||
Kind::Notice(b) => vec![b],
|
||||
Kind::Confirmation { yes, no } => vec![yes, no],
|
||||
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
|
||||
}
|
||||
}
|
||||
/// What `status` reports of it.
|
||||
pub fn json(&self, values: &[InputValue]) -> Value {
|
||||
let kind = match &self.kind {
|
||||
Kind::Notice(_) => "notice",
|
||||
Kind::Confirmation { .. } => "confirmation",
|
||||
Kind::MultiAction { .. } => "multi_action",
|
||||
Kind::ServerLinks { .. } => "server_links",
|
||||
Kind::DialogList { .. } => "dialog_list",
|
||||
};
|
||||
let inputs: Vec<Value> = self
|
||||
.inputs
|
||||
.iter()
|
||||
.zip(values)
|
||||
.map(|(i, v)| {
|
||||
let kind = match i.control {
|
||||
Control::Text { .. } => "text",
|
||||
Control::Boolean { .. } => "boolean",
|
||||
Control::SingleOption { .. } => "single_option",
|
||||
Control::NumberRange { .. } => "number_range",
|
||||
};
|
||||
json!({"key": i.key, "type": kind, "value": i.control.substitution(v)})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"type": kind,
|
||||
"title": self.title.plain(),
|
||||
"body": self.body.iter().map(|b| match b {
|
||||
Body::Message { contents, .. } => contents.plain(),
|
||||
Body::Item { item, .. } => string(item.get("id")).unwrap_or_default(),
|
||||
}).collect::<Vec<_>>(),
|
||||
"inputs": inputs,
|
||||
"buttons": self.buttons().iter().map(|b| b.label.plain()).collect::<Vec<_>>(),
|
||||
"can_close_with_escape": self.can_close_with_escape,
|
||||
"after_action": match self.after_action {
|
||||
AfterAction::Close => "close",
|
||||
AfterAction::None => "none",
|
||||
AfterAction::WaitForResponse => "wait_for_response",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn s(v: &str) -> Tag {
|
||||
Tag::String(v.into())
|
||||
}
|
||||
|
||||
/// The register dialog AuthMe 6 sends in configuration (the shape its
|
||||
/// `show_dialog` packet has on the test bed).
|
||||
fn register() -> Tag {
|
||||
Tag::compound([
|
||||
("type", s("minecraft:multi_action")),
|
||||
("title", s("Register")),
|
||||
("can_close_with_escape", Tag::Byte(0)),
|
||||
("after_action", s("wait_for_response")),
|
||||
(
|
||||
"body",
|
||||
Tag::compound([
|
||||
("type", s("minecraft:plain_message")),
|
||||
("contents", s("Choose a password")),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"inputs",
|
||||
Tag::List(vec![Tag::compound([
|
||||
("type", s("minecraft:text")),
|
||||
("key", s("password")),
|
||||
("label", s("Password")),
|
||||
])]),
|
||||
),
|
||||
(
|
||||
"actions",
|
||||
Tag::List(vec![
|
||||
Tag::compound([
|
||||
("label", s("Register")),
|
||||
(
|
||||
"action",
|
||||
Tag::compound([
|
||||
("type", s("minecraft:dynamic/custom")),
|
||||
("id", s("authme:prejoin-register/submit")),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
Tag::compound([
|
||||
("label", s("Cancel")),
|
||||
(
|
||||
"action",
|
||||
Tag::compound([
|
||||
("type", s("minecraft:dynamic/run_command")),
|
||||
("template", s("say $(password) $(missing)!")),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_multi_action_dialog_with_defaults() {
|
||||
let d = Dialog::parse(®ister()).unwrap();
|
||||
assert_eq!(d.title.plain(), "Register");
|
||||
assert!(!d.can_close_with_escape);
|
||||
assert!(d.pause);
|
||||
assert_eq!(d.after_action, AfterAction::WaitForResponse);
|
||||
assert!(matches!(&d.body[..], [Body::Message { width: 200, .. }]));
|
||||
let Kind::MultiAction {
|
||||
actions,
|
||||
exit,
|
||||
columns,
|
||||
} = &d.kind
|
||||
else {
|
||||
panic!("{:?}", d.kind)
|
||||
};
|
||||
assert_eq!((actions.len(), exit.is_none(), *columns), (2, true, 2));
|
||||
assert_eq!(actions[0].width, 150);
|
||||
assert!(d.on_cancel().is_none());
|
||||
let [
|
||||
Input {
|
||||
key,
|
||||
control:
|
||||
Control::Text {
|
||||
max_length: 32,
|
||||
width: 200,
|
||||
..
|
||||
},
|
||||
},
|
||||
] = &d.inputs[..]
|
||||
else {
|
||||
panic!("{:?}", d.inputs)
|
||||
};
|
||||
assert_eq!(key, "password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_carry_the_input_values() {
|
||||
let d = Dialog::parse(®ister()).unwrap();
|
||||
let values = [InputValue::Text("p\"w".into())];
|
||||
let Kind::MultiAction { actions, .. } = &d.kind else {
|
||||
unreachable!()
|
||||
};
|
||||
let custom = actions[0]
|
||||
.action
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.click(&d.inputs, &values);
|
||||
assert_eq!(
|
||||
custom,
|
||||
Some(Click::Custom {
|
||||
id: "authme:prejoin-register/submit".into(),
|
||||
payload: Some(Tag::compound([("password", s("p\"w"))])),
|
||||
})
|
||||
);
|
||||
// The template escapes the text; a key no input has becomes nothing.
|
||||
let command = actions[1]
|
||||
.action
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.click(&d.inputs, &values);
|
||||
assert_eq!(command, Some(Click::RunCommand("say p\\\"w !".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notice_without_an_action_has_ok() {
|
||||
let d = Dialog::parse(&Tag::compound([("type", s("notice")), ("title", s("Hi"))])).unwrap();
|
||||
let Kind::Notice(b) = &d.kind else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(b.label, Text::translate("gui.ok", &[]));
|
||||
// Without a title, or with an unknown type, there is no dialog.
|
||||
assert!(Dialog::parse(&Tag::compound([("type", s("notice"))])).is_none());
|
||||
assert!(Dialog::parse(&Tag::compound([("type", s("x")), ("title", s("Hi"))])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_ranges_snap_to_their_step() {
|
||||
let mut range = Control::NumberRange {
|
||||
width: 200,
|
||||
label: Text::literal("n"),
|
||||
label_format: "options.generic_value".into(),
|
||||
start: 0.0,
|
||||
end: 10.0,
|
||||
initial: Some(4.0),
|
||||
step: Some(3.0),
|
||||
};
|
||||
assert_eq!(range.initial(), InputValue::Slider(0.4));
|
||||
assert_eq!(range.scaled(&InputValue::Slider(0.62)), Some(7.0));
|
||||
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(10.0));
|
||||
let Control::NumberRange { step, .. } = &mut range else {
|
||||
unreachable!()
|
||||
};
|
||||
*step = Some(4.0);
|
||||
// 12 is out of range: one step less.
|
||||
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(8.0));
|
||||
assert_eq!(range.substitution(&InputValue::Slider(0.0)), "0");
|
||||
assert_eq!(number_string(2.5), "2.5");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
//! `DialogScreen` and its kinds (`SimpleDialogScreen`, `MultiButtonDialogScreen`):
|
||||
//! where a server dialog's title, body, inputs and buttons go in a
|
||||
//! `HeaderAndFooterLayout` (a 33 high header, the body in a `ScrollableLayout`,
|
||||
//! the footer), and the `WaitingForResponseScreen` an answer can leave open.
|
||||
use crate::{
|
||||
dialog::{Body, Control, Dialog, Kind},
|
||||
font::Font,
|
||||
text::Text,
|
||||
};
|
||||
|
||||
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
|
||||
const BAR: f32 = 33.0;
|
||||
/// `Checkbox.getBoxSize`.
|
||||
pub const BOX: f32 = 17.0;
|
||||
/// `DialogScreen.WARNING_BUTTON_SPRITES`: normal, disabled, highlighted.
|
||||
pub const WARNING_SPRITES: [&str; 3] = [
|
||||
"gui/sprites/dialog/warning_button",
|
||||
"gui/sprites/dialog/warning_button_disabled",
|
||||
"gui/sprites/dialog/warning_button_highlighted",
|
||||
];
|
||||
|
||||
/// One drawn piece; `y` is from the top of the body (before it scrolls) for
|
||||
/// the body's parts, from the top of the screen for the footer's.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Part {
|
||||
/// A line of text and where it starts.
|
||||
Line(Text, f32, f32),
|
||||
/// A text input's box.
|
||||
Field { input: usize, rect: [f32; 4] },
|
||||
/// A boolean input: the box, its label right of it.
|
||||
Check { input: usize, rect: [f32; 4] },
|
||||
/// A button of `Dialog::buttons`.
|
||||
Button { index: usize, rect: [f32; 4] },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Layout {
|
||||
/// The title and where it is drawn.
|
||||
pub title: (f32, f32),
|
||||
pub warning: [f32; 4],
|
||||
/// The body's visible area (`ScrollableLayout`'s container): x, y, w, h.
|
||||
pub area: [f32; 4],
|
||||
/// The body's full height: more than the area's scrolls.
|
||||
pub content: f32,
|
||||
pub body: Vec<Part>,
|
||||
pub footer: Vec<Part>,
|
||||
}
|
||||
|
||||
/// A laid-out element of a `LinearLayout` before it is placed.
|
||||
struct Element {
|
||||
w: f32,
|
||||
h: f32,
|
||||
parts: Vec<Part>,
|
||||
}
|
||||
impl Element {
|
||||
/// Its parts moved to `(x, y)`.
|
||||
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
|
||||
self.parts.into_iter().map(move |p| match p {
|
||||
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
|
||||
[x + dx, y + dy, w, h]
|
||||
}
|
||||
/// `FrameLayout.alignInDimension` at 0.5: the offset that centres `inner`.
|
||||
fn centre(outer: f32, inner: f32) -> f32 {
|
||||
((outer - inner) * 0.5).trunc()
|
||||
}
|
||||
|
||||
/// `FocusableTextWidget` with the body's settings: `width` wide, the text
|
||||
/// wrapped to `width - 8` and centred, 4 of padding all round.
|
||||
fn message(font: &Font, contents: &Text, width: f32) -> Element {
|
||||
let lines = font.split_lines(contents, width - 8.0);
|
||||
let mid = (width / 2.0).trunc();
|
||||
let parts = lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (line, _))| {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
Part::Line(line, x, 4.0 + 9.0 * i as f32)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Element {
|
||||
w: width,
|
||||
h: 9.0 * parts.len() as f32 + 8.0,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`, if Horizon has it.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
Some(match control {
|
||||
Control::Text {
|
||||
width,
|
||||
label,
|
||||
label_visible,
|
||||
..
|
||||
} => {
|
||||
let w = *width as f32;
|
||||
if !label_visible {
|
||||
return Some(Element {
|
||||
w,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, w, 20.0],
|
||||
}],
|
||||
});
|
||||
}
|
||||
// CommonLayouts.labeledElement: the label, 4 below it the box,
|
||||
// both on the left.
|
||||
Element {
|
||||
w: w.max(font.width_text(label)),
|
||||
h: 9.0 + 4.0 + 20.0,
|
||||
parts: vec![
|
||||
Part::Line(label.clone(), 0.0, 0.0),
|
||||
Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 13.0, w, 20.0],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
Control::Boolean { label, .. } => {
|
||||
let w = BOX + 4.0 + font.width_text(label);
|
||||
Element {
|
||||
w,
|
||||
h: BOX,
|
||||
parts: vec![Part::Check {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, w, BOX],
|
||||
}],
|
||||
}
|
||||
}
|
||||
Control::SingleOption { .. } | Control::NumberRange { .. } => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
|
||||
/// is left in a centred row under them.
|
||||
fn columns(widths: &[f32], columns: usize, first: usize) -> Element {
|
||||
let columns = columns.max(1);
|
||||
let full = widths.len() / columns * columns;
|
||||
let row_width = |ws: &[f32]| ws.iter().sum::<f32>() + 2.0 * (ws.len().max(1) - 1) as f32;
|
||||
let mut col = vec![0.0f32; columns];
|
||||
for (i, w) in widths[..full].iter().enumerate() {
|
||||
col[i % columns] = col[i % columns].max(*w);
|
||||
}
|
||||
let last = &widths[full..];
|
||||
if !last.is_empty() {
|
||||
// GridLayout spreads a cell over every column it spans (Divisor).
|
||||
let spread = row_width(last) - 2.0 * (columns - 1) as f32;
|
||||
let (each, extra) = ((spread / columns as f32).floor(), spread % columns as f32);
|
||||
for (c, w) in col.iter_mut().enumerate() {
|
||||
*w = w.max(each + f32::from(u8::from((c as f32) < extra)));
|
||||
}
|
||||
}
|
||||
let x_of = |c: usize| col[..c].iter().sum::<f32>() + 2.0 * c as f32;
|
||||
let mut parts = Vec::new();
|
||||
for (i, w) in widths[..full].iter().enumerate() {
|
||||
let (r, c) = (i / columns, i % columns);
|
||||
parts.push(Part::Button {
|
||||
index: first + i,
|
||||
rect: [x_of(c) + centre(col[c], *w), 22.0 * r as f32, *w, 20.0],
|
||||
});
|
||||
}
|
||||
let width = x_of(columns - 1) + col[columns - 1];
|
||||
if !last.is_empty() {
|
||||
let y = 22.0 * (full / columns) as f32;
|
||||
let mut x = centre(width, row_width(last));
|
||||
for (i, w) in last.iter().enumerate() {
|
||||
parts.push(Part::Button {
|
||||
index: first + full + i,
|
||||
rect: [x, y, *w, 20.0],
|
||||
});
|
||||
x += w + 2.0;
|
||||
}
|
||||
}
|
||||
let rows = full / columns + usize::from(!last.is_empty());
|
||||
Element {
|
||||
w: width,
|
||||
h: (22.0 * rows as f32 - 2.0).max(0.0),
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// `DialogScreen.init` and `repositionElements` on a `w` by `h` screen.
|
||||
pub fn new(dialog: &Dialog, font: &Font, (w, h): (f32, f32)) -> Self {
|
||||
// The header: the title, 10 right of it the warning button, both
|
||||
// centred together.
|
||||
let tw = font.width_text(&dialog.title);
|
||||
let (hx, hy) = (centre(w, tw + 30.0), centre(BAR, 20.0));
|
||||
let title = (hx, hy + centre(20.0, 9.0));
|
||||
let warning = [hx + tw + 10.0, hy, 20.0, 20.0];
|
||||
|
||||
let mut elements: Vec<Element> = dialog
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
|
||||
Body::Item { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
elements.extend(
|
||||
dialog
|
||||
.inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, input_)| input(font, i, &input_.control)),
|
||||
);
|
||||
let buttons = dialog.buttons();
|
||||
let (listed, footer_buttons, footer_h) = match &dialog.kind {
|
||||
Kind::MultiAction {
|
||||
actions,
|
||||
exit,
|
||||
columns: n,
|
||||
} => {
|
||||
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
|
||||
elements.push(columns(&widths, *n as usize, 0));
|
||||
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
|
||||
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
|
||||
(
|
||||
actions.len(),
|
||||
footer,
|
||||
if exit.is_some() { BAR } else { 5.0 },
|
||||
)
|
||||
}
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
|
||||
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
|
||||
(0, footer, if exit.is_some() { BAR } else { 5.0 })
|
||||
}
|
||||
// SimpleDialogScreen: the buttons side by side, 8 apart.
|
||||
Kind::Notice(_) | Kind::Confirmation { .. } => {
|
||||
(0, (0..buttons.len()).collect::<Vec<_>>(), BAR)
|
||||
}
|
||||
};
|
||||
debug_assert!(listed + footer_buttons.len() <= buttons.len());
|
||||
|
||||
// The body: a column 10 apart, each element centred in it.
|
||||
let body_w = elements.iter().map(|e| e.w).fold(0.0, f32::max);
|
||||
let content = elements.iter().map(|e| e.h).sum::<f32>()
|
||||
+ 10.0 * elements.len().saturating_sub(1) as f32;
|
||||
// ScrollableLayout: 10 each side for the scrollbar (ReserveStrategy.BOTH),
|
||||
// no higher than what the header and footer leave.
|
||||
let area_w = body_w + 20.0;
|
||||
let area_h = content.min(h - BAR - footer_h).max(0.0);
|
||||
let area_y = (BAR + 30.0).min(h - footer_h - area_h);
|
||||
let area_x = centre(w, area_w);
|
||||
let mut body = Vec::new();
|
||||
let mut y = 0.0;
|
||||
for e in elements {
|
||||
let (x, eh) = (area_x + 10.0 + centre(body_w, e.w), e.h);
|
||||
body.extend(e.at(x, y));
|
||||
y += eh + 10.0;
|
||||
}
|
||||
|
||||
let widths: Vec<f32> = footer_buttons
|
||||
.iter()
|
||||
.map(|&i| buttons[i].width as f32)
|
||||
.collect();
|
||||
let row = widths.iter().sum::<f32>() + 8.0 * widths.len().saturating_sub(1) as f32;
|
||||
let (mut x, fy) = (centre(w, row), h - footer_h + centre(footer_h, 20.0));
|
||||
let mut footer = Vec::new();
|
||||
for (&i, bw) in footer_buttons.iter().zip(&widths) {
|
||||
footer.push(Part::Button {
|
||||
index: i,
|
||||
rect: [x, fy, *bw, 20.0],
|
||||
});
|
||||
x += bw + 8.0;
|
||||
}
|
||||
Self {
|
||||
title,
|
||||
warning: Self::in_bounds(warning, w, h),
|
||||
area: [area_x, area_y, area_w, area_h],
|
||||
content,
|
||||
body,
|
||||
footer,
|
||||
}
|
||||
}
|
||||
/// `makeSureWarningButtonIsInBounds`.
|
||||
fn in_bounds(r: [f32; 4], w: f32, h: f32) -> [f32; 4] {
|
||||
if r[0] < 0.0 || r[1] < 0.0 || r[0] > w - 20.0 || r[1] > h - 20.0 {
|
||||
[(w - 40.0).max(0.0), 5.0f32.min(h), 20.0, 20.0]
|
||||
} else {
|
||||
r
|
||||
}
|
||||
}
|
||||
/// Every part where it is drawn with the body scrolled by `scroll`, and
|
||||
/// whether it is in the body (clipped to its area).
|
||||
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
|
||||
let dy = self.area[1] - scroll;
|
||||
let body = self.body.iter().map(move |p| {
|
||||
let p = match p.clone() {
|
||||
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
};
|
||||
(p, true)
|
||||
});
|
||||
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
|
||||
}
|
||||
}
|
||||
|
||||
/// `WaitingForResponseScreen`: its Back button's label after `ticks`, and
|
||||
/// whether it shows and works (it shows after a second, works after five).
|
||||
pub fn waiting_button(ticks: u32) -> (Text, bool, bool) {
|
||||
let seconds = (ticks / 20).min(5);
|
||||
let label = match seconds {
|
||||
0 => Text::literal(""),
|
||||
5 => Text::translate("gui.back", &[]),
|
||||
s => Text::translate(
|
||||
"gui.waitingForResponse.button.inactive",
|
||||
&[Text::literal((5 - s).to_string())],
|
||||
),
|
||||
};
|
||||
(label, seconds >= 1, seconds == 5)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grids_pack_full_rows_and_centre_the_rest() {
|
||||
// AuthMe's two buttons: one row, 2 apart.
|
||||
let two = columns(&[150.0, 150.0], 2, 0);
|
||||
assert_eq!((two.w, two.h), (302.0, 20.0));
|
||||
// Three in two columns: the third alone, centred under them.
|
||||
let three = columns(&[150.0, 150.0, 150.0], 2, 0);
|
||||
assert_eq!((three.w, three.h), (302.0, 42.0));
|
||||
assert_eq!(
|
||||
three.parts[2],
|
||||
Part::Button {
|
||||
index: 2,
|
||||
rect: [76.0, 22.0, 150.0, 20.0]
|
||||
}
|
||||
);
|
||||
// One in two columns spreads over both.
|
||||
let one = columns(&[100.0], 2, 0);
|
||||
assert_eq!(one.w, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_waiting_screens_button_counts_down() {
|
||||
assert!(!waiting_button(0).1);
|
||||
assert_eq!(
|
||||
waiting_button(20).0,
|
||||
Text::translate(
|
||||
"gui.waitingForResponse.button.inactive",
|
||||
&[Text::literal("4")]
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
waiting_button(100),
|
||||
(Text::translate("gui.back", &[]), true, true)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,11 @@ impl Cost {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Enchantment {
|
||||
pub id: Arc<str>,
|
||||
/// The name the pinned English map gave at load: the fallback for a
|
||||
/// language that has no entry for the key below.
|
||||
pub description: String,
|
||||
/// `description`'s translation key (`enchantment.minecraft.<name>`).
|
||||
pub description_key: String,
|
||||
pub max_level: u32,
|
||||
pub weight: u32,
|
||||
pub anvil_cost: u32,
|
||||
@@ -224,6 +228,7 @@ impl Enchantments {
|
||||
.get(key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| id.trim_start_matches("minecraft:").replace('_', " ")),
|
||||
description_key: key.to_owned(),
|
||||
max_level: v["max_level"].as_u64().unwrap_or(1) as u32,
|
||||
weight: v["weight"].as_u64().unwrap_or(1) as u32,
|
||||
anvil_cost: v["anvil_cost"].as_u64().unwrap_or(0) as u32,
|
||||
@@ -329,16 +334,26 @@ impl Enchantments {
|
||||
}
|
||||
/// `Enchantment.getFullname` as plain text and whether it is a curse (red).
|
||||
pub fn full_name(&self, id: &str, level: u32) -> (String, bool) {
|
||||
let language = crate::lang::current();
|
||||
self.full_name_in(language.as_deref(), id, level)
|
||||
}
|
||||
/// `full_name` against `language`; `None` (no language loaded: a
|
||||
/// dedicated server, the probes) keeps the English names loaded above.
|
||||
pub fn full_name_in(
|
||||
&self,
|
||||
language: Option<&crate::lang::Language>,
|
||||
id: &str,
|
||||
level: u32,
|
||||
) -> (String, bool) {
|
||||
let tr = |key: &str| language.and_then(|l| l.get(key).map(str::to_owned));
|
||||
let Some(e) = self.get(id) else {
|
||||
return (format!("{id} {}", level_name(level)), false);
|
||||
};
|
||||
let mut name = e.description.clone();
|
||||
let mut name = tr(&e.description_key).unwrap_or_else(|| e.description.clone());
|
||||
if level != 1 || e.max_level != 1 {
|
||||
name.push(' ');
|
||||
name.push_str(
|
||||
self.lang
|
||||
.get(&format!("enchantment.level.{level}"))
|
||||
.map_or(&level_name(level), |s| s),
|
||||
&tr(&format!("enchantment.level.{level}")).unwrap_or_else(|| level_name(level)),
|
||||
);
|
||||
}
|
||||
(name, self.is_curse(id))
|
||||
@@ -771,7 +786,7 @@ pub(crate) mod tests {
|
||||
let defs = [
|
||||
(
|
||||
"minecraft:sharpness",
|
||||
json!({"anvil_cost":1,"exclusive_set":"#minecraft:exclusive_set/damage","max_cost":{"base":21,"per_level_above_first":11},"max_level":5,"min_cost":{"base":1,"per_level_above_first":11},"primary_items":"#minecraft:enchantable/melee_weapon","supported_items":"#minecraft:enchantable/sharp_weapon","weight":10,"slots":["mainhand"],"effects":{"minecraft:damage":[{"effect":{"type":"minecraft:add","value":{"type":"minecraft:linear","base":1.0,"per_level_above_first":0.5}}}]}}),
|
||||
json!({"anvil_cost":1,"description":{"translate":"enchantment.minecraft.sharpness"},"exclusive_set":"#minecraft:exclusive_set/damage","max_cost":{"base":21,"per_level_above_first":11},"max_level":5,"min_cost":{"base":1,"per_level_above_first":11},"primary_items":"#minecraft:enchantable/melee_weapon","supported_items":"#minecraft:enchantable/sharp_weapon","weight":10,"slots":["mainhand"],"effects":{"minecraft:damage":[{"effect":{"type":"minecraft:add","value":{"type":"minecraft:linear","base":1.0,"per_level_above_first":0.5}}}]}}),
|
||||
),
|
||||
(
|
||||
"minecraft:smite",
|
||||
@@ -795,6 +810,7 @@ pub(crate) mod tests {
|
||||
.map(|(id, v)| Enchantment {
|
||||
id: Arc::from(*id),
|
||||
description: id.trim_start_matches("minecraft:").into(),
|
||||
description_key: v["description"]["translate"].as_str().unwrap_or("").into(),
|
||||
max_level: v["max_level"].as_u64().unwrap() as u32,
|
||||
weight: v["weight"].as_u64().unwrap() as u32,
|
||||
anvil_cost: v["anvil_cost"].as_u64().unwrap() as u32,
|
||||
@@ -1088,4 +1104,33 @@ pub(crate) mod tests {
|
||||
assert!(!e.compatible("minecraft:sharpness", "minecraft:smite"));
|
||||
assert!(e.compatible("minecraft:sharpness", "minecraft:unbreaking"));
|
||||
}
|
||||
|
||||
/// The enchantment's name and its level come from the selected language,
|
||||
/// with the English name loaded above as the fallback.
|
||||
#[test]
|
||||
fn names_follow_the_language() {
|
||||
struct Mem(HashMap<String, Vec<u8>>);
|
||||
impl crate::lang::ResourceSource for Mem {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.0.get(path).cloned()
|
||||
}
|
||||
}
|
||||
let ru = Mem(HashMap::from([(
|
||||
"assets/minecraft/lang/ru_ru.json".to_owned(),
|
||||
r#"{"enchantment.minecraft.sharpness":"Острота","enchantment.level.5":"V"}"#
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
)]));
|
||||
let ru = crate::lang::Language::load(&[&ru], "ru_ru");
|
||||
let e = sample();
|
||||
assert_eq!(
|
||||
e.full_name_in(Some(&ru), "minecraft:sharpness", 5).0,
|
||||
"Острота V"
|
||||
);
|
||||
// mending carries no key in the fixture: the English name stays.
|
||||
assert_eq!(
|
||||
e.full_name_in(Some(&ru), "minecraft:mending", 1).0,
|
||||
"mending"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,20 @@ impl Culler {
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
start: usize,
|
||||
opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
let hidden = self.hidden(vertices, start, opaque);
|
||||
if hidden {
|
||||
vertices.truncate(start);
|
||||
}
|
||||
hidden
|
||||
}
|
||||
|
||||
/// Whether this entity's completed camera-relative mesh (`vertices[start..]`) is hidden.
|
||||
pub fn hidden(
|
||||
&self,
|
||||
vertices: &[crate::mesher::Vertex],
|
||||
start: usize,
|
||||
opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
if start >= vertices.len() || self.remaining.get() == 0 {
|
||||
return false;
|
||||
@@ -42,11 +56,7 @@ impl Culler {
|
||||
}
|
||||
}
|
||||
// Cover floating-point and rasterization edges as well as the vertices.
|
||||
if self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque) {
|
||||
vertices.truncate(start);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque)
|
||||
}
|
||||
|
||||
/// Returns true only when a complete opaque block plane covers the bounds.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their
|
||||
//! Horizon's optional extras (minimap, lighting, inventory tools): their
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
@@ -6,17 +6,8 @@
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
/// The name the options screen uses while the zoom key waits for a key.
|
||||
pub const ZOOM_KEY: &str = "horizon.zoom";
|
||||
/// The same for the sort key.
|
||||
/// The name the options screen uses while the sort key waits for a key.
|
||||
pub const SORT_KEY: &str = "horizon.sort";
|
||||
/// Zoom levels the default cycles through, and the wheel's limits.
|
||||
pub const ZOOM_LEVELS: [u8; 6] = [2, 3, 4, 6, 8, 10];
|
||||
pub const ZOOM_MIN: f32 = 1.25;
|
||||
/// `Camera.tickFov` keeps the field-of-view modifier at 0.1 or more.
|
||||
pub const ZOOM_MAX: f32 = 10.0;
|
||||
/// Each wheel step multiplies the zoom by this.
|
||||
const ZOOM_STEP: f32 = 1.25;
|
||||
|
||||
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
|
||||
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
|
||||
@@ -81,6 +72,9 @@ pub struct Extras {
|
||||
pub zoom_smooth: bool,
|
||||
/// Divide mouse turning by the zoom.
|
||||
pub zoom_sensitivity: bool,
|
||||
/// The FPS counter and keystrokes extras are official mods now
|
||||
/// (`moved_extras`): their old settings stay here until the game has
|
||||
/// installed the mod for a player who had one on.
|
||||
pub fps: bool,
|
||||
pub fps_corner: Corner,
|
||||
/// Also show the minimum and 1% low.
|
||||
@@ -89,6 +83,9 @@ pub struct Extras {
|
||||
pub keystrokes_corner: Corner,
|
||||
/// Clicks per second under the mouse buttons.
|
||||
pub keystrokes_cps: bool,
|
||||
/// The official mods installed for the extras they replace, by mod id
|
||||
/// (`movedToMods`): each is done once.
|
||||
pub moved: Vec<String>,
|
||||
pub minimap: bool,
|
||||
pub minimap_corner: Corner,
|
||||
/// Side in GUI pixels (`MINIMAP_SIZES`).
|
||||
@@ -132,6 +129,7 @@ impl Default for Extras {
|
||||
keystrokes: false,
|
||||
keystrokes_corner: Corner::TopLeft,
|
||||
keystrokes_cps: true,
|
||||
moved: Vec::new(),
|
||||
minimap: false,
|
||||
minimap_corner: Corner::TopRight,
|
||||
minimap_size: 96,
|
||||
@@ -177,7 +175,7 @@ impl Extras {
|
||||
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
|
||||
"zoomLevel" => {
|
||||
if let Ok(v) = value.parse::<u8>() {
|
||||
e.zoom_level = v.clamp(2, ZOOM_MAX as u8);
|
||||
e.zoom_level = v.clamp(2, 10);
|
||||
}
|
||||
}
|
||||
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
|
||||
@@ -190,6 +188,14 @@ impl Extras {
|
||||
e.keystrokes_corner = Corner::parse(value, e.keystrokes_corner)
|
||||
}
|
||||
"keystrokesCps" => e.keystrokes_cps = flag(e.keystrokes_cps),
|
||||
"movedToMods" => {
|
||||
e.moved = value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
"minimap" => e.minimap = flag(e.minimap),
|
||||
"minimapCorner" => e.minimap_corner = Corner::parse(value, e.minimap_corner),
|
||||
"minimapSize" => {
|
||||
@@ -219,7 +225,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -233,6 +239,7 @@ impl Extras {
|
||||
bool_text(self.keystrokes),
|
||||
self.keystrokes_corner.name(),
|
||||
bool_text(self.keystrokes_cps),
|
||||
self.moved.join(","),
|
||||
bool_text(self.minimap),
|
||||
self.minimap_corner.name(),
|
||||
self.minimap_size,
|
||||
@@ -254,11 +261,10 @@ impl Extras {
|
||||
bool_text(self.discord_presence),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
/// another name (a vanilla mapping).
|
||||
pub fn set_key(&mut self, name: &str, value: String) -> bool {
|
||||
match name {
|
||||
ZOOM_KEY => self.zoom_key = value,
|
||||
SORT_KEY => self.sort_key = value,
|
||||
_ => return false,
|
||||
}
|
||||
@@ -275,87 +281,12 @@ impl Extras {
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom key's state: held or not, and the zoom the wheel has set.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Zoom {
|
||||
held: bool,
|
||||
factor: f32,
|
||||
}
|
||||
impl Default for Zoom {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
held: false,
|
||||
factor: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Zoom {
|
||||
/// The key went down: start at the default level.
|
||||
pub fn press(&mut self, level: u8) {
|
||||
self.held = true;
|
||||
self.factor = f32::from(level).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
}
|
||||
pub fn release(&mut self) {
|
||||
self.held = false;
|
||||
}
|
||||
/// A press or release of the zoom key. Only a press while `playing` (no
|
||||
/// screen or text field open) zooms; anywhere else the key belongs to
|
||||
/// the screen, and the zoom lets go.
|
||||
pub fn key(&mut self, pressed: bool, repeat: bool, playing: bool, level: u8) {
|
||||
if !pressed || !playing {
|
||||
self.release();
|
||||
} else if !repeat {
|
||||
self.press(level);
|
||||
}
|
||||
}
|
||||
pub fn held(&self) -> bool {
|
||||
self.held
|
||||
}
|
||||
/// Wheel steps while held (positive zooms in); false when not zooming.
|
||||
pub fn scroll(&mut self, steps: f32) -> bool {
|
||||
if !self.held {
|
||||
return false;
|
||||
}
|
||||
self.factor = (self.factor * ZOOM_STEP.powf(steps)).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
true
|
||||
}
|
||||
/// The zoom in effect: 1 when the key is up or the spyglass is in use
|
||||
/// (scoping keeps vanilla's spyglass view).
|
||||
pub fn factor(&self, scoping: bool) -> f32 {
|
||||
if self.held && !scoping {
|
||||
self.factor
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Vanilla key mappings bound to the zoom key. The key still reaches them
|
||||
/// (a key bound twice triggers both, as vanilla's do), so the Extras page
|
||||
/// says so.
|
||||
pub fn zoom_key_conflicts(e: &Extras, keys: &crate::keybinds::KeyBinds) -> Vec<&'static str> {
|
||||
if e.zoom_key == "key.keyboard.unknown" {
|
||||
return Vec::new();
|
||||
}
|
||||
crate::keybinds::MAPPINGS
|
||||
.iter()
|
||||
.map(|m| m.0)
|
||||
.filter(|name| keys.get(name) == e.zoom_key)
|
||||
.collect()
|
||||
}
|
||||
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
|
||||
/// (`getFieldOfViewModifier`) divided by the zoom. A zoom of 1 leaves it as is.
|
||||
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
|
||||
/// zoom of 1 leaves it as is.
|
||||
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
|
||||
if zoom == 1.0 { vanilla } else { vanilla / zoom }
|
||||
}
|
||||
/// The mouse turning scale while zoomed.
|
||||
pub fn turn_scale(zoom: f32, lower_sensitivity: bool) -> f64 {
|
||||
if lower_sensitivity && zoom > 1.0 {
|
||||
1.0 / f64::from(zoom)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame times over the last few seconds: frames per second, the minimum
|
||||
/// and the 1% low, recomputed twice a second.
|
||||
pub struct FpsCounter {
|
||||
@@ -447,17 +378,6 @@ impl Cps {
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
Zoom,
|
||||
ZoomKey,
|
||||
ZoomLevel,
|
||||
ZoomSmooth,
|
||||
ZoomSensitivity,
|
||||
Fps,
|
||||
FpsCorner,
|
||||
FpsLows,
|
||||
Keystrokes,
|
||||
KeystrokesCorner,
|
||||
KeystrokesCps,
|
||||
Minimap,
|
||||
MinimapCorner,
|
||||
MinimapSize,
|
||||
@@ -482,12 +402,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Zoom"),
|
||||
Ok(Zoom),
|
||||
Ok(ZoomKey),
|
||||
Ok(ZoomLevel),
|
||||
Ok(ZoomSmooth),
|
||||
Ok(ZoomSensitivity),
|
||||
Err("Minimap"),
|
||||
Ok(Minimap),
|
||||
Ok(MinimapCorner),
|
||||
@@ -495,14 +409,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(MinimapShape),
|
||||
Ok(MinimapZoom),
|
||||
Ok(MinimapRotate),
|
||||
Err("FPS counter"),
|
||||
Ok(Fps),
|
||||
Ok(FpsCorner),
|
||||
Ok(FpsLows),
|
||||
Err("Keystrokes"),
|
||||
Ok(Keystrokes),
|
||||
Ok(KeystrokesCorner),
|
||||
Ok(KeystrokesCps),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
@@ -532,13 +438,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::Zoom => "Zoom",
|
||||
Item::ZoomKey => "Zoom key",
|
||||
Item::ZoomLevel => "Starting zoom",
|
||||
Item::ZoomSmooth => "Smooth zoom",
|
||||
Item::ZoomSensitivity => "Slower turning",
|
||||
Item::Fps => "FPS counter",
|
||||
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => "Corner",
|
||||
Item::MinimapCorner => "Corner",
|
||||
Item::Minimap => "Minimap",
|
||||
Item::MinimapSize => "Size",
|
||||
Item::MinimapShape => "Shape",
|
||||
@@ -549,9 +449,6 @@ impl Item {
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
Item::Sort => "Sort buttons",
|
||||
Item::SortKey => "Sort key",
|
||||
Item::FpsLows => "Minimum and 1% low",
|
||||
Item::Keystrokes => "Keystrokes",
|
||||
Item::KeystrokesCps => "Clicks per second",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
@@ -561,14 +458,11 @@ impl Item {
|
||||
pub fn parent(self) -> Option<Item> {
|
||||
use Item::*;
|
||||
Some(match self {
|
||||
ZoomKey | ZoomLevel | ZoomSmooth | ZoomSensitivity => Zoom,
|
||||
MinimapCorner | MinimapSize | MinimapShape | MinimapZoom | MinimapRotate => Minimap,
|
||||
FpsCorner | FpsLows => Fps,
|
||||
KeystrokesCorner | KeystrokesCps => Keystrokes,
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence => {
|
||||
Fullbright | DynamicLights | Minimap | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -580,15 +474,7 @@ impl Item {
|
||||
Item::DynamicLights => {
|
||||
"Torches and other light sources held, dropped or on fire light the blocks around them."
|
||||
}
|
||||
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
|
||||
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
|
||||
Item::ZoomLevel => "The zoom the key starts at.",
|
||||
Item::ZoomSmooth => "Eases in and out like the spyglass.",
|
||||
Item::ZoomSensitivity => "Turns slower the more you zoom in.",
|
||||
Item::Fps => "Frames per second in a corner of the screen.",
|
||||
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => {
|
||||
"Overlays in one corner stack."
|
||||
}
|
||||
Item::MinimapCorner => "Overlays in one corner stack.",
|
||||
Item::Minimap => "A map of the loaded terrain around you, in map colours.",
|
||||
Item::MinimapSize => "How big the map is on screen.",
|
||||
Item::MinimapShape => "Square or round.",
|
||||
@@ -597,7 +483,6 @@ impl Item {
|
||||
Item::AutoReconnect => {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
Item::FpsLows => "The slowest frame and the 1% low of the last 5 seconds.",
|
||||
Item::MouseTweaks => {
|
||||
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
|
||||
}
|
||||
@@ -610,8 +495,6 @@ impl Item {
|
||||
Item::SortKey => {
|
||||
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
|
||||
}
|
||||
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
|
||||
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
@@ -627,7 +510,7 @@ impl Item {
|
||||
pub fn cost(self) -> Option<crate::graphics_style::Cost> {
|
||||
use crate::graphics_style::Cost;
|
||||
match self {
|
||||
Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::Minimap | Item::DynamicLights => Some(Cost::Low),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
_ => None,
|
||||
@@ -638,13 +521,6 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::Zoom => e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity,
|
||||
Item::Fps => e.fps,
|
||||
Item::FpsLows => e.fps_lows,
|
||||
Item::Keystrokes => e.keystrokes,
|
||||
Item::KeystrokesCps => e.keystrokes_cps,
|
||||
Item::Minimap => e.minimap,
|
||||
Item::MinimapRotate => e.minimap_rotate,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
@@ -660,11 +536,7 @@ impl Item {
|
||||
/// The value of the other items, as their button shows it.
|
||||
pub fn value(self, e: &Extras) -> String {
|
||||
match self {
|
||||
Item::ZoomKey => crate::keybinds::display_name(&e.zoom_key).plain(),
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
Item::ZoomLevel => format!("{}x", e.zoom_level),
|
||||
Item::FpsCorner => e.fps_corner.label().into(),
|
||||
Item::KeystrokesCorner => e.keystrokes_corner.label().into(),
|
||||
Item::MinimapCorner => e.minimap_corner.label().into(),
|
||||
Item::MinimapSize => MINIMAP_SIZES
|
||||
.iter()
|
||||
@@ -679,23 +551,11 @@ impl Item {
|
||||
},
|
||||
}
|
||||
}
|
||||
/// A click (`back` for the right button). The zoom key binds instead.
|
||||
/// A click (`back` for the right button). The sort key binds instead.
|
||||
pub fn cycle(self, e: &mut Extras, back: bool) {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::Zoom => e.zoom = !e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
|
||||
Item::Fps => e.fps = !e.fps,
|
||||
Item::FpsLows => e.fps_lows = !e.fps_lows,
|
||||
Item::Keystrokes => e.keystrokes = !e.keystrokes,
|
||||
Item::KeystrokesCps => e.keystrokes_cps = !e.keystrokes_cps,
|
||||
Item::ZoomLevel => e.zoom_level = next(&ZOOM_LEVELS, e.zoom_level, back),
|
||||
Item::FpsCorner => e.fps_corner = next(&Corner::ALL, e.fps_corner, back),
|
||||
Item::KeystrokesCorner => {
|
||||
e.keystrokes_corner = next(&Corner::ALL, e.keystrokes_corner, back)
|
||||
}
|
||||
Item::Minimap => e.minimap = !e.minimap,
|
||||
Item::MinimapRotate => e.minimap_rotate = !e.minimap_rotate,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
@@ -712,13 +572,12 @@ impl Item {
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::ZoomKey | Item::SortKey => {}
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
/// The key name the options screen waits on for a key item.
|
||||
pub fn key_name(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Item::ZoomKey => Some(ZOOM_KEY),
|
||||
Item::SortKey => Some(SORT_KEY),
|
||||
_ => None,
|
||||
}
|
||||
@@ -750,6 +609,7 @@ mod tests {
|
||||
e.fps_corner = Corner::BottomRight;
|
||||
e.keystrokes = true;
|
||||
e.keystrokes_cps = false;
|
||||
e.moved = vec!["fps_overlay".into(), "keystrokes".into()];
|
||||
e.minimap = true;
|
||||
e.minimap_size = 128;
|
||||
e.minimap_round = true;
|
||||
@@ -775,43 +635,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_zoom_key_is_free_in_vanilla() {
|
||||
let key = Extras::default().zoom_key;
|
||||
assert!(crate::keybinds::key_code(&key).is_some());
|
||||
assert!(
|
||||
crate::keybinds::MAPPINGS.iter().all(|m| m.1 != key),
|
||||
"{key} is a vanilla binding"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_is_vanilla_unless_held_and_not_scoping() {
|
||||
let mut z = Zoom::default();
|
||||
assert_eq!(z.factor(false), 1.0);
|
||||
assert_eq!(fov_target(1.1, z.factor(false)), 1.1);
|
||||
assert!(!z.scroll(1.0), "the wheel is the hotbar's when not zooming");
|
||||
z.press(4);
|
||||
assert_eq!(z.factor(false), 4.0);
|
||||
assert_eq!(z.factor(true), 1.0, "the spyglass wins");
|
||||
assert!((fov_target(1.0, 4.0) - 0.25).abs() < 1e-6);
|
||||
assert!(z.scroll(1.0));
|
||||
assert!((z.factor(false) - 5.0).abs() < 1e-5);
|
||||
for _ in 0..40 {
|
||||
z.scroll(1.0);
|
||||
}
|
||||
assert_eq!(z.factor(false), ZOOM_MAX);
|
||||
for _ in 0..80 {
|
||||
z.scroll(-1.0);
|
||||
}
|
||||
assert_eq!(z.factor(false), ZOOM_MIN);
|
||||
z.release();
|
||||
assert_eq!(z.factor(false), 1.0);
|
||||
z.press(2);
|
||||
assert_eq!(z.factor(false), 2.0, "each press starts at the default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_target_eases_like_the_spyglass() {
|
||||
fn a_mods_zoom_eases_like_the_spyglass() {
|
||||
// Camera.tickFov halves the distance to the target each tick.
|
||||
let mut camera = crate::camera::Camera::default();
|
||||
camera.tick(None, 1.0);
|
||||
@@ -835,55 +659,6 @@ mod tests {
|
||||
assert!((floor - 7.0).abs() < 1e-3, "{floor}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_zoom_key_only_zooms_while_playing() {
|
||||
let mut z = Zoom::default();
|
||||
// In chat, a sign, a text field or any screen: no zoom.
|
||||
z.key(true, false, false, 4);
|
||||
assert!(!z.held());
|
||||
z.key(true, false, true, 4);
|
||||
assert!(z.held());
|
||||
// A screen opening while held lets go (its key presses are the screen's).
|
||||
z.key(true, true, false, 4);
|
||||
assert!(!z.held());
|
||||
// Key repeats do not restart a zoom the wheel changed.
|
||||
z.key(true, false, true, 4);
|
||||
z.scroll(1.0);
|
||||
z.key(true, true, true, 4);
|
||||
assert!((z.factor(false) - 5.0).abs() < 1e-5);
|
||||
z.key(false, false, true, 4);
|
||||
assert!(!z.held());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_key_conflicts_name_the_vanilla_mappings() {
|
||||
let mut e = Extras::default();
|
||||
let mut keys = crate::keybinds::KeyBinds::default();
|
||||
assert!(zoom_key_conflicts(&e, &keys).is_empty());
|
||||
e.zoom_key = "key.keyboard.c".into();
|
||||
assert_eq!(
|
||||
zoom_key_conflicts(&e, &keys),
|
||||
[
|
||||
"key.saveToolbarActivator",
|
||||
"key.debug.crash",
|
||||
"key.debug.copyLocation"
|
||||
]
|
||||
);
|
||||
keys.set("key.drop", "key.keyboard.z");
|
||||
e.zoom_key = "key.keyboard.z".into();
|
||||
assert_eq!(zoom_key_conflicts(&e, &keys), ["key.drop"]);
|
||||
e.zoom_key = "key.keyboard.unknown".into();
|
||||
keys.set("key.smoothCamera", "key.keyboard.unknown");
|
||||
assert!(zoom_key_conflicts(&e, &keys).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitivity_scales_with_zoom() {
|
||||
assert_eq!(turn_scale(1.0, true), 1.0);
|
||||
assert_eq!(turn_scale(4.0, false), 1.0);
|
||||
assert_eq!(turn_scale(4.0, true), 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_counter_reports_rate_minimum_and_low() {
|
||||
let mut c = FpsCounter::default();
|
||||
@@ -937,16 +712,8 @@ mod tests {
|
||||
#[test]
|
||||
fn items_cycle_through_their_values() {
|
||||
let mut e = Extras::default();
|
||||
Item::ZoomLevel.cycle(&mut e, false);
|
||||
assert_eq!(e.zoom_level, 6);
|
||||
Item::ZoomLevel.cycle(&mut e, true);
|
||||
Item::ZoomLevel.cycle(&mut e, true);
|
||||
assert_eq!(e.zoom_level, 3);
|
||||
Item::FpsCorner.cycle(&mut e, true);
|
||||
assert_eq!(e.fps_corner, Corner::BottomRight);
|
||||
Item::Zoom.cycle(&mut e, false);
|
||||
assert_eq!(Item::Zoom.switch(&e), Some(true));
|
||||
assert_eq!(Item::ZoomKey.value(&e), "Z");
|
||||
Item::MinimapCorner.cycle(&mut e, true);
|
||||
assert_eq!(e.minimap_corner, Corner::TopLeft);
|
||||
Item::MinimapZoom.cycle(&mut e, true);
|
||||
assert_eq!(Item::MinimapZoom.value(&e), "½ block per pixel");
|
||||
Item::MinimapSize.cycle(&mut e, false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The extras' HUD overlays (FPS counter, keystrokes): small boxes in the
|
||||
//! vanilla font, in GUI pixels, stacked per corner (`docs/extras.md`).
|
||||
//! HUD boxes in the corners: the minimap's place and mods' panels, small
|
||||
//! boxes in GUI pixels stacked per corner (`docs/extras.md`).
|
||||
use crate::{
|
||||
extras::{Corner, FpsReading},
|
||||
extras::Corner,
|
||||
hud::{HudAtlas, HudBatch, Rect, rgba},
|
||||
};
|
||||
|
||||
@@ -99,36 +99,6 @@ pub fn draw_minimap(
|
||||
}
|
||||
}
|
||||
|
||||
/// The FPS counter's text.
|
||||
pub fn fps_text(r: FpsReading, lows: bool) -> String {
|
||||
if lows {
|
||||
format!("{} fps min {} 1% {}", r.fps, r.min, r.low)
|
||||
} else {
|
||||
format!("{} fps", r.fps)
|
||||
}
|
||||
}
|
||||
pub fn draw_fps(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
corners: &mut Corners,
|
||||
corner: Corner,
|
||||
reading: FpsReading,
|
||||
lows: bool,
|
||||
) {
|
||||
let text = fps_text(reading, lows);
|
||||
let w = HudBatch::text_width(atlas, &text) + 4.0;
|
||||
let (x, y) = corners.place(corner, w, 12.0);
|
||||
batch.fill(atlas, x, y, w, 12.0, BACKGROUND);
|
||||
batch.text(
|
||||
atlas,
|
||||
&text,
|
||||
x + 2.0,
|
||||
y + 2.0,
|
||||
rgba(255, 255, 255, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// A box of text lines (a mod's HUD panel): `(text, 0xRRGGBB)` each.
|
||||
pub fn draw_lines(
|
||||
batch: &mut HudBatch,
|
||||
@@ -155,95 +125,6 @@ pub fn draw_lines(
|
||||
}
|
||||
}
|
||||
|
||||
/// What the keystroke overlay shows.
|
||||
pub struct Keystrokes {
|
||||
/// Forward, left, back, right: held, and the bound key's name.
|
||||
pub moves: [(bool, String); 4],
|
||||
pub jump: bool,
|
||||
pub attack: bool,
|
||||
pub use_: bool,
|
||||
/// Clicks per second of the left and right buttons, when shown.
|
||||
pub cps: Option<(usize, usize)>,
|
||||
}
|
||||
const KEY: f32 = 22.0;
|
||||
const GAP: f32 = 2.0;
|
||||
/// The overlay's size.
|
||||
pub fn keystrokes_size() -> (f32, f32) {
|
||||
(3.0 * KEY + 2.0 * GAP, 3.0 * KEY + 3.0 * GAP + 10.0)
|
||||
}
|
||||
fn key_box(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4], held: bool, lines: &[&str]) {
|
||||
let [x, y, w, h] = rect;
|
||||
let (fill, ink) = if held {
|
||||
(rgba(255, 255, 255, 176), rgba(20, 20, 20, 255))
|
||||
} else {
|
||||
(BACKGROUND, rgba(255, 255, 255, 255))
|
||||
};
|
||||
batch.fill(atlas, x, y, w, h, fill);
|
||||
let total = lines.len() as f32 * 9.0 - 1.0;
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let tw = HudBatch::text_width(atlas, line);
|
||||
let tx = (x + (w - tw) / 2.0).round();
|
||||
let ty = (y + (h - total) / 2.0).round() + i as f32 * 9.0;
|
||||
batch.text(atlas, line, tx, ty, ink, !held);
|
||||
}
|
||||
}
|
||||
pub fn draw_keystrokes(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
corners: &mut Corners,
|
||||
corner: Corner,
|
||||
k: &Keystrokes,
|
||||
) {
|
||||
let (w, h) = keystrokes_size();
|
||||
let (x, y) = corners.place(corner, w, h);
|
||||
let step = KEY + GAP;
|
||||
let [forward, left, back, right] = &k.moves;
|
||||
key_box(
|
||||
batch,
|
||||
atlas,
|
||||
[x + step, y, KEY, KEY],
|
||||
forward.0,
|
||||
&[&forward.1],
|
||||
);
|
||||
for (i, key) in [left, back, right].into_iter().enumerate() {
|
||||
key_box(
|
||||
batch,
|
||||
atlas,
|
||||
[x + i as f32 * step, y + step, KEY, KEY],
|
||||
key.0,
|
||||
&[&key.1],
|
||||
);
|
||||
}
|
||||
let mw = (w - GAP) / 2.0;
|
||||
let my = y + 2.0 * step;
|
||||
for (i, (held, label, cps)) in [
|
||||
(k.attack, "LMB", k.cps.map(|c| c.0)),
|
||||
(k.use_, "RMB", k.cps.map(|c| c.1)),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let rate = cps.map(|n| format!("{n} CPS"));
|
||||
let lines: Vec<&str> = std::iter::once(label).chain(rate.as_deref()).collect();
|
||||
key_box(
|
||||
batch,
|
||||
atlas,
|
||||
[x + i as f32 * (mw + GAP), my, mw, KEY],
|
||||
held,
|
||||
&lines,
|
||||
);
|
||||
}
|
||||
// The space bar: a line in a short key.
|
||||
let sy = my + step;
|
||||
key_box(batch, atlas, [x, sy, w, 10.0], k.jump, &[]);
|
||||
let ink = if k.jump {
|
||||
rgba(20, 20, 20, 255)
|
||||
} else {
|
||||
rgba(255, 255, 255, 255)
|
||||
};
|
||||
batch.fill(atlas, x + w / 2.0 - 12.0, sy + 4.0, 24.0, 2.0, ink);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -259,15 +140,4 @@ mod tests {
|
||||
assert_eq!(c.place(Corner::BottomRight, 50.0, 10.0), (348.0, 274.0));
|
||||
assert_eq!(c.place(Corner::BottomLeft, 10.0, 10.0), (2.0, 288.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_text_with_and_without_lows() {
|
||||
let r = FpsReading {
|
||||
fps: 144,
|
||||
min: 61,
|
||||
low: 98,
|
||||
};
|
||||
assert_eq!(fps_text(r, false), "144 fps");
|
||||
assert_eq!(fps_text(r, true), "144 fps min 61 1% 98");
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ mod debug_keys;
|
||||
mod decorated_pot;
|
||||
mod dedicated;
|
||||
mod deflect;
|
||||
mod dialogs;
|
||||
mod discord;
|
||||
mod dispense_fixture;
|
||||
mod dispenser;
|
||||
@@ -97,12 +98,16 @@ mod maps;
|
||||
mod minecarts;
|
||||
mod mob_blocks;
|
||||
mod mob_gear;
|
||||
mod mod_input;
|
||||
mod mod_render;
|
||||
mod mods;
|
||||
mod mount_menu;
|
||||
mod mounts;
|
||||
mod moved_extras;
|
||||
mod moving_pistons;
|
||||
mod music;
|
||||
mod names;
|
||||
mod native_shaders;
|
||||
mod natural_spawning;
|
||||
mod nautili;
|
||||
mod net_harness;
|
||||
@@ -349,10 +354,7 @@ impl Options {
|
||||
dedicated: None,
|
||||
benchmark: None,
|
||||
dev_mods: Vec::new(),
|
||||
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
|
||||
// itself, without the event loop: it stays in the frame.
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
|
||||
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0"),
|
||||
execution_role: execution_role::ExecutionRole::Client,
|
||||
};
|
||||
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
|
||||
@@ -361,7 +363,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n\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..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -378,6 +380,8 @@ impl Options {
|
||||
"--control" => o.control = Some(horizon_client::control::default_path()),
|
||||
"--server-thread" => o.server_thread = true,
|
||||
"--in-frame" | "--no-server-thread" => o.server_thread = false,
|
||||
// Horizon's own commands (/pregen), off by default as on vanilla.
|
||||
"--pregen" => horizon_client::command::vanilla::set_horizon_commands(true),
|
||||
_ => {
|
||||
let value = args
|
||||
.next()
|
||||
@@ -554,6 +558,8 @@ struct GameLevel<'w> {
|
||||
/// Where the chunk tickets let blocks tick.
|
||||
tickets: horizon_client::ticket_levels::Levels,
|
||||
random_tick_sections: &'w mut horizon_client::random_tick_sections::RandomTickSections,
|
||||
/// Players with each chest open (a trapped chest's signal).
|
||||
lid_openers: &'w HashMap<(i32, i32, i32), i32>,
|
||||
}
|
||||
struct PlayerCollision {
|
||||
y: f64,
|
||||
@@ -689,6 +695,10 @@ impl LevelAccess for GameLevel<'_> {
|
||||
_state: BlockStateId,
|
||||
_direction: Direction,
|
||||
) -> i32 {
|
||||
// TrappedChestBlock.getSignal: the players with it open, at most 15.
|
||||
if let Some(count) = self.lid_openers.get(&(pos.x, pos.y, pos.z)) {
|
||||
return (*count).clamp(0, 15);
|
||||
}
|
||||
jukebox::jukebox_signals(self.world, self.items, pos).map_or(0, |(signal, _)| signal)
|
||||
}
|
||||
fn biome_name(&self, pos: BlockPos) -> Option<&str> {
|
||||
@@ -1141,6 +1151,7 @@ struct Game<'a> {
|
||||
pending_pack_reload: bool,
|
||||
/// A server's resource packs (game/server_packs.rs).
|
||||
server_packs: server_packs::ServerPackState,
|
||||
dialogs: dialogs::DialogState,
|
||||
world_renderer_ready: bool,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
motion: &'a MotionResources,
|
||||
@@ -1209,6 +1220,8 @@ struct Game<'a> {
|
||||
left: bool,
|
||||
right: bool,
|
||||
cursor: (f32, f32),
|
||||
/// The cursor icon the window shows (`widgets::take_cursor` each frame).
|
||||
cursor_icon: horizon_client::widgets::Cursor,
|
||||
captured: bool,
|
||||
focused: bool,
|
||||
/// The keyboard's modifier state (`KeyEvent.modifiers`).
|
||||
@@ -1240,6 +1253,11 @@ struct Game<'a> {
|
||||
luminance: Option<horizon_client::dynamic_light::Luminance>,
|
||||
/// Name tags drawn over everything (`RenderPipelines.TEXT_SEE_THROUGH`).
|
||||
see_through_vertices: Vec<Vertex>,
|
||||
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
|
||||
/// while `pack_ids` is set (game/shader_packs.rs).
|
||||
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
|
||||
/// The shader pack's entity and block entity ids, while one draws entities.
|
||||
pack_ids: Option<shader_packs::PackIds>,
|
||||
started: Instant,
|
||||
previous: Instant,
|
||||
/// FramerateLimitTracker.latestInputTime.
|
||||
@@ -1326,6 +1344,10 @@ struct Game<'a> {
|
||||
hand: Hand,
|
||||
/// The shield's item cooldown after an axe disabled it (ticks).
|
||||
shield_cooldown: u32,
|
||||
/// `Player.getCooldowns()`: the `use_cooldown` component's entries, what
|
||||
/// `itemDecorations` shades a slot by. A server's players have their own
|
||||
/// (`GuestPlay::cooldowns`), swapped in for their turn.
|
||||
cooldowns: horizon_client::cooldowns::Cooldowns,
|
||||
/// The source of a command run through /execute (the player otherwise).
|
||||
command_source: Option<commands::execute::Source>,
|
||||
titles: titles::Titles,
|
||||
@@ -1489,15 +1511,20 @@ struct Game<'a> {
|
||||
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
|
||||
/// Recent frame times for the performance overlay.
|
||||
frame_graph: horizon_client::perf_overlay::FrameGraph,
|
||||
/// Horizon's extras (zoom, FPS counter, keystrokes): `game/extras.rs`.
|
||||
/// Horizon's extras (minimap, auto-reconnect; mods' counters): `game/extras.rs`.
|
||||
extras: extras::ExtrasState,
|
||||
/// Horizon mods (`game/mods.rs`), none running unless turned on.
|
||||
mods: mods::ModsState,
|
||||
/// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`).
|
||||
native_shaders: native_shaders::NativeShaders,
|
||||
/// Meshed sections waiting for an upload budget (Horizon settings).
|
||||
pending_uploads: Vec<((i32, i32, i32), horizon_client::mesher::SectionMesh)>,
|
||||
/// This frame's view frustum, for culling entities and block entities.
|
||||
view_frustum: Option<horizon_client::gfx::Frustum>,
|
||||
entity_culler: Option<horizon_client::entity_culling::Culler>,
|
||||
/// `Renderer::pack_shadow_casters` for this frame's entities: (keep what the culler hides,
|
||||
/// build the player's model in first person); (false, false) without a shader pack.
|
||||
pack_shadow_casters: (bool, bool),
|
||||
/// A scripted flight from the control socket (`fly`).
|
||||
flight: Option<control::Flight>,
|
||||
/// The running agent action from the control socket (game/agent.rs).
|
||||
@@ -1552,6 +1579,8 @@ struct Game<'a> {
|
||||
/// position) for the guests.
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
/// Global level events for every guest (`ServerLevel.globalLevelEvent`).
|
||||
lan_global_events: Vec<(i32, [i32; 3], i32)>,
|
||||
/// `ContainerOpenersCounter`: how many players (the host and the guests)
|
||||
/// have each chest open.
|
||||
lid_openers: HashMap<(i32, i32, i32), i32>,
|
||||
@@ -1564,6 +1593,9 @@ struct Game<'a> {
|
||||
chat_capture: Option<Vec<horizon_client::text::Text>>,
|
||||
/// Packets for the guest acting now, sent once its action returns.
|
||||
guest_packets: Vec<horizon_protocol::packets::play::Clientbound>,
|
||||
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
|
||||
/// hub's item registry, which the guest's turn does not hold.
|
||||
guest_cooldowns: Vec<(Arc<str>, i32)>,
|
||||
/// `broadcastToAdmins` lines a guest's command queued, sent once it
|
||||
/// returns: the line, whether operators get it and whether the console
|
||||
/// logs it.
|
||||
@@ -2177,7 +2209,7 @@ impl Game<'_> {
|
||||
self.player_drop(thrown);
|
||||
}
|
||||
/// `Player.drop`: the statistics and a thrown item entity in front of the player.
|
||||
fn player_drop(&mut self, thrown: ItemStack) {
|
||||
pub(super) fn player_drop(&mut self, thrown: ItemStack) {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return;
|
||||
};
|
||||
@@ -2448,6 +2480,7 @@ impl Game<'_> {
|
||||
spill: &mut self.spill,
|
||||
tickets: self.tickets.levels(),
|
||||
random_tick_sections: &mut self.random_tick_sections,
|
||||
lid_openers: &self.lid_openers,
|
||||
};
|
||||
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
|
||||
self.flush_spill();
|
||||
@@ -2584,10 +2617,41 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// BigDripleafBlock.entityInside also asks whether the player stands on
|
||||
// the ground and how high its feet are: the leaves its box's feet
|
||||
// layer is inside (the host and the LAN guests).
|
||||
let mut standing = Vec::new();
|
||||
let feet = self
|
||||
.player
|
||||
.iter()
|
||||
.filter(|_| !self.spectator())
|
||||
.map(|p| {
|
||||
(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
p.ground.on_ground,
|
||||
f64::from(p.dimensions.width) / 2.0,
|
||||
)
|
||||
})
|
||||
.chain(
|
||||
self.lan_guest_feet()
|
||||
.into_iter()
|
||||
.map(|(at, on_ground)| (at, on_ground, 0.3)),
|
||||
);
|
||||
for (at, on_ground, w) in feet {
|
||||
let y = (at[1] + 1e-7).floor() as i32;
|
||||
for x in (at[0] - w + 1e-7).floor() as i32..=(at[0] + w - 1e-7).floor() as i32 {
|
||||
for z in (at[2] - w + 1e-7).floor() as i32..=(at[2] + w - 1e-7).floor() as i32 {
|
||||
standing.push((BlockPos::new(x, y, z), on_ground, at[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.with_level(|b, level, ticks| {
|
||||
for pos in inside {
|
||||
b.entity_inside(level, ticks, pos);
|
||||
}
|
||||
for (pos, on_ground, feet_y) in standing {
|
||||
b.entity_on_big_dripleaf(level, ticks, pos, on_ground, feet_y);
|
||||
}
|
||||
});
|
||||
self.process_level_events()?;
|
||||
self.tick_scheduled()?;
|
||||
@@ -2765,7 +2829,13 @@ impl Game<'_> {
|
||||
}
|
||||
_ => 1.0,
|
||||
};
|
||||
self.audio.play(name, c, volume, pitch);
|
||||
if matches!(id, 1029 | 1030) {
|
||||
// Clients play the anvil sound from its level event;
|
||||
// capturing it also sends a duplicate sound packet.
|
||||
self.audio.play_unshared(name, c, volume, pitch);
|
||||
} else {
|
||||
self.audio.play(name, c, volume, pitch);
|
||||
}
|
||||
}
|
||||
}
|
||||
LevelEvent::Sound {
|
||||
@@ -3403,6 +3473,25 @@ impl Game<'_> {
|
||||
|| self.interact_mob()?
|
||||
|| self.interact_knot())
|
||||
}
|
||||
/// `ItemStack.applyAfterUseComponentSideEffects`: a used stack with a
|
||||
/// `use_cooldown` component starts its entry (`UseCooldown.apply`). A
|
||||
/// server's guest also gets `ClientboundCooldownPacket`
|
||||
/// (`ServerItemCooldowns.onCooldownStarted`), queued for their turn's end.
|
||||
pub(super) fn apply_use_cooldown(&mut self, stack: &ItemStack) {
|
||||
let default = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.use_cooldown.clone());
|
||||
let Some(component) = horizon_client::cooldowns::component(stack, default.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
let group = horizon_client::cooldowns::group(stack, default.as_ref());
|
||||
self.cooldowns.add(Arc::clone(&group), component.ticks());
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_cooldowns.push((group, component.ticks()));
|
||||
}
|
||||
}
|
||||
/// One hand's turn in `startUseItem`: `useItemOn` the block aimed at,
|
||||
/// then `useItem`. Whether the click ends here (it acted or failed).
|
||||
fn use_hand(&mut self, block_hit: Option<&Hit>) -> Result<bool> {
|
||||
@@ -3487,6 +3576,17 @@ impl Game<'_> {
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// ServerPlayerGameMode.useItem and useItemOn: an item on cooldown is
|
||||
// not used (PASS, so the other hand may still act).
|
||||
if self
|
||||
.cooldowns
|
||||
.is_on_cooldown(&horizon_client::cooldowns::group(
|
||||
&stack,
|
||||
info.use_cooldown.as_ref(),
|
||||
))
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
if block_hit.is_some() && (self.use_bone_meal(&stack)? || self.ignite_tnt(&stack)?) {
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -3813,6 +3913,8 @@ impl Game<'_> {
|
||||
}
|
||||
if ticks >= duration {
|
||||
self.using = None;
|
||||
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
self.finish_consuming(&info, consumable, &stack);
|
||||
} else {
|
||||
self.using = Some((slot, ticks));
|
||||
@@ -4310,6 +4412,7 @@ impl Game<'_> {
|
||||
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
|
||||
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
|
||||
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
|
||||
MotionEvent::CauldronExtinguish { pos } => self.cauldron_extinguish(pos),
|
||||
MotionEvent::InsideWitherRose => self.wither_rose_contact(),
|
||||
MotionEvent::SmashLanding {
|
||||
state,
|
||||
@@ -4415,6 +4518,7 @@ impl Game<'_> {
|
||||
// Player.tick's ItemCooldowns.tick for the window's player (a
|
||||
// server's players count theirs down in `tick_guests`).
|
||||
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
|
||||
self.cooldowns.tick();
|
||||
horizon_client::spans::time("tick_using", || self.tick_using());
|
||||
horizon_client::spans::time("tick_hud", || self.tick_hud());
|
||||
// The player's digging comes in with its packets, before the level
|
||||
@@ -4692,6 +4796,24 @@ impl Game<'_> {
|
||||
}
|
||||
window.set_cursor_visible(!self.captured);
|
||||
}
|
||||
/// `GuiGraphicsExtractor.applyCursor` -> `Window.selectCursor` at the end
|
||||
/// of a frame: the cursor the frame's widgets asked for, set on the window
|
||||
/// only when it changes.
|
||||
fn apply_cursor_icon(&mut self) {
|
||||
use horizon_client::widgets::Cursor;
|
||||
let wanted = horizon_client::widgets::take_cursor();
|
||||
if wanted == self.cursor_icon {
|
||||
return;
|
||||
}
|
||||
if let Some(window) = &self.window {
|
||||
window.set_cursor(match wanted {
|
||||
Cursor::Arrow => winit::window::CursorIcon::Default,
|
||||
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
|
||||
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
|
||||
});
|
||||
}
|
||||
self.cursor_icon = wanted;
|
||||
}
|
||||
/// LevelLoadingScreen.onClose -> Gui.setScreen(null) -> grabMouse.
|
||||
fn capture_loaded_world(&mut self) {
|
||||
// Loading may finish after focus was lost or another screen opened.
|
||||
@@ -4797,17 +4919,17 @@ impl Game<'_> {
|
||||
count_text: Option<String>,
|
||||
) {
|
||||
// GuiGraphicsExtractor.itemDecorations, laid out at the origin and
|
||||
// moved to (x, y). Horizon tracks no item cooldowns to shade yet.
|
||||
let max_damage = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(0, |info| info.max_damage as i32);
|
||||
// moved to (x, y): the damage bar, the cooldown shade and the count.
|
||||
let info = self.res.items().get(&stack.item);
|
||||
let max_damage = info.map_or(0, |info| info.max_damage as i32);
|
||||
let cooldown =
|
||||
horizon_client::cooldowns::group(stack, info.and_then(|i| i.use_cooldown.as_ref()));
|
||||
let partial = self.clock.fraction() as f32;
|
||||
let decorations = hud_overlays::ItemDecorations {
|
||||
count: stack.count,
|
||||
count_text,
|
||||
bar: hud_overlays::damage_bar(stack.damage as i32, max_damage),
|
||||
cooldown: 0.0,
|
||||
cooldown: self.cooldowns.percent(&cooldown, partial),
|
||||
};
|
||||
let mut draws = vec![hud_layout::GuiDraw::Push(vec![
|
||||
hud_layout::PoseOp::Translate(x, y),
|
||||
@@ -4824,11 +4946,14 @@ impl Game<'_> {
|
||||
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
// The pack prompt replaces the connecting screen (setScreen), but
|
||||
// in game it opens over the world.
|
||||
let mut batch = if self.server_packs.prompt.is_some() && self.player.is_none() {
|
||||
let mut batch = if (self.server_packs.prompt.is_some() || self.dialog_open())
|
||||
&& self.player.is_none()
|
||||
{
|
||||
HudBatch::new(size)
|
||||
} else {
|
||||
self.hud_layers(size)
|
||||
};
|
||||
self.draw_dialog(&mut batch);
|
||||
self.draw_pack_prompt(&mut batch);
|
||||
// Gui.extractRenderState: the debug overlay comes after the screen
|
||||
// and the toasts, so it is over everything, menus included.
|
||||
@@ -4927,6 +5052,7 @@ impl Game<'_> {
|
||||
// Hud: the action bar and titles draw over the hotbar (nextStratum).
|
||||
self.draw_boss_bars(&mut batch);
|
||||
self.draw_extras(&mut batch);
|
||||
self.draw_mod_shapes(&mut batch);
|
||||
}
|
||||
if show_hud {
|
||||
// Hud.nextContextualInfoState: the jump bar, the experience bar or
|
||||
@@ -5141,7 +5267,8 @@ impl Game<'_> {
|
||||
| BlockEntity::TrialSpawner(_)
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_),
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::Skull(_),
|
||||
) => {}
|
||||
None => {
|
||||
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
|
||||
@@ -5410,7 +5537,8 @@ impl Game<'_> {
|
||||
| BlockEntity::TrialSpawner(_)
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_),
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::Skull(_),
|
||||
) => {}
|
||||
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
|
||||
None => {
|
||||
@@ -6391,7 +6519,11 @@ impl Game<'_> {
|
||||
use horizon_client::menu::Input;
|
||||
let button = i32::from(right);
|
||||
if self.remote.is_some() {
|
||||
self.remote_throw(right, input);
|
||||
if self.screen == Screen::Creative {
|
||||
self.remote_creative_drop(right);
|
||||
} else {
|
||||
self.remote_throw(right, input);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let before = self.carried.clone();
|
||||
@@ -6748,8 +6880,10 @@ impl Game<'_> {
|
||||
alpha: f64,
|
||||
mut out: Vec<Vertex>,
|
||||
) -> Vec<Vertex> {
|
||||
self.entity_spans.borrow_mut().clear();
|
||||
self.prepare_entity_culling(camera);
|
||||
self.prepare_trims();
|
||||
self.mark_entity(out.len(), "minecraft:entity_flame");
|
||||
let fire_yaw = if self.camera_type == perspective::CameraType::ThirdPersonFront {
|
||||
self.yaw + 180.0
|
||||
} else {
|
||||
@@ -6841,14 +6975,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// `ItemEntityRenderer`.
|
||||
self.mark_entity(out.len(), "minecraft:item");
|
||||
self.dropped_item_geometry(camera, alpha, &mut out);
|
||||
// `VaultRenderer`.
|
||||
self.mark_entity(out.len(), "");
|
||||
self.vault_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
|
||||
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
|
||||
// Mobs and their projectiles.
|
||||
// Mobs and their projectiles (a span each).
|
||||
self.mob_geometry(camera, alpha, &mut out);
|
||||
// In first person only a shader pack's shadow pass draws the player.
|
||||
self.mark_player(out.len(), self.camera_type.first_person());
|
||||
self.player_model_geometry(camera, self.clock.camera_fraction(), &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:player");
|
||||
self.guest_model_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:evoker_fangs");
|
||||
self.fangs_geometry(camera, alpha, &mut out);
|
||||
let mut see_through = std::mem::take(&mut self.see_through_vertices);
|
||||
see_through.clear();
|
||||
@@ -6856,6 +6997,7 @@ impl Game<'_> {
|
||||
self.text_display_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
self.see_through_vertices = see_through;
|
||||
// Entity shadows (EntityRenderer.extractShadow).
|
||||
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
|
||||
if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() {
|
||||
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
|
||||
let darken = self.sky_darken();
|
||||
@@ -6919,6 +7061,7 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
},
|
||||
banner: &|p| self.banner_layers_at(p),
|
||||
skull: &|p| self.skull_skin_at(p),
|
||||
glyph: &|c| self.gui.atlas.glyph_cell(c),
|
||||
};
|
||||
// Block entities outside the view frustum are skipped (with a
|
||||
@@ -6949,13 +7092,14 @@ impl Game<'_> {
|
||||
v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
|
||||
})
|
||||
});
|
||||
if self.entity_culler.is_some() {
|
||||
if self.entity_culler.is_some() || self.pack_ids.is_some() {
|
||||
for block in visible {
|
||||
let start = out.len();
|
||||
self.mark_block_entity(start, block.1);
|
||||
self.res
|
||||
.block_entity_render()
|
||||
.render(&scene, std::iter::once(block), &mut out);
|
||||
if !off_screen(block.1) {
|
||||
if !off_screen(block.1) && self.entity_culler.is_some() {
|
||||
self.cull_entity_vertices(&mut out, start);
|
||||
}
|
||||
}
|
||||
@@ -6964,6 +7108,7 @@ impl Game<'_> {
|
||||
.block_entity_render()
|
||||
.render(&scene, visible, &mut out);
|
||||
}
|
||||
self.mark_stage(out.len(), "DESTROY");
|
||||
for (pos, stage) in self.cracks(camera) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.res
|
||||
@@ -6972,6 +7117,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// Experience orbs (ExperienceOrbRenderer): pulsing billboards from a 4x4 icon sheet.
|
||||
self.mark_entity(out.len(), "minecraft:experience_orb");
|
||||
if let Some(r) = self
|
||||
.gui
|
||||
.atlas
|
||||
@@ -7035,6 +7181,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// Falling blocks use their baked model, including non-cubic shapes.
|
||||
self.mark_entity(out.len(), "minecraft:falling_block");
|
||||
for f in &self.falling {
|
||||
let Some((_, tint)) = self.block_face_sprite(f.state) else {
|
||||
continue;
|
||||
@@ -7065,20 +7212,35 @@ impl Game<'_> {
|
||||
vertex.color = horizon_client::hud_layout::multiply(vertex.color, tint);
|
||||
}
|
||||
}
|
||||
self.mark_entity(out.len(), "");
|
||||
self.moving_piston_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:tnt");
|
||||
self.tnt_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:painting");
|
||||
self.painting_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:item_frame");
|
||||
self.frame_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:armor_stand");
|
||||
self.armor_stand_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:oak_boat");
|
||||
self.boat_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:minecart");
|
||||
self.cart_geometry(camera, alpha, &mut out);
|
||||
// Beams: `gbuffers_beaconbeam`, not a block entity's program (5.4 has no stage for them).
|
||||
self.mark_stage(out.len(), "NONE");
|
||||
self.beacon_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:fishing_bobber");
|
||||
self.fishing_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:leash_knot");
|
||||
self.leash_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:trident");
|
||||
self.trident_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:firework_rocket");
|
||||
self.rocket_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:splash_potion");
|
||||
self.thrown_potion_geometry(camera, alpha, &mut out);
|
||||
// Particles (ParticleEngine.extract): camera-facing quads.
|
||||
self.mark_stage(out.len(), "PARTICLES");
|
||||
let world = &self.world;
|
||||
self.particles.render(
|
||||
camera,
|
||||
@@ -7089,6 +7251,7 @@ impl Game<'_> {
|
||||
&mut out,
|
||||
);
|
||||
// Breaking progress overlays (destroy_stage_0..9): ours and the other players'.
|
||||
self.mark_stage(out.len(), "DESTROY");
|
||||
for (pos, stage) in self.cracks(camera) {
|
||||
let o = (glam::DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
|
||||
- camera)
|
||||
@@ -8401,6 +8564,8 @@ impl Game<'_> {
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
// ItemStack.useOn: a use that acted counts as one of the item.
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.damage_held(damage);
|
||||
self.post_block_game_event("minecraft:block_change", pos, self.sculk_actor(), Some(new));
|
||||
Ok(true)
|
||||
@@ -8423,6 +8588,35 @@ impl Game<'_> {
|
||||
self.level_set(pos, id)?;
|
||||
Ok(true)
|
||||
}
|
||||
/// `LayeredCauldronBlock.handleEntityOnFireInside` for a burning player
|
||||
/// in the cauldron at `pos` (`mayInteract`: not a spectator): the fill
|
||||
/// level drops (`lowerFillLevel`, a `block_change` with no source). A
|
||||
/// server's cauldron is the server's.
|
||||
pub(super) fn cauldron_extinguish(&mut self, pos: BlockPos) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let at = [pos.x, pos.y, pos.z];
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(old) = registry
|
||||
.state(self.world.block(at[0], at[1], at[2]))
|
||||
.filter(|s| {
|
||||
matches!(
|
||||
s.name.as_str(),
|
||||
"minecraft:water_cauldron" | "minecraft:powder_snow_cauldron"
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(new) = registry.resolve(&horizon_client::cauldron::extinguished(&old)) else {
|
||||
return;
|
||||
};
|
||||
if self.level_set(at, new).is_ok() {
|
||||
self.post_block_game_event("minecraft:block_change", at, None, Some(new));
|
||||
}
|
||||
}
|
||||
fn damage_held(&mut self, amount: u32) {
|
||||
if self.creative {
|
||||
return;
|
||||
@@ -8605,6 +8799,14 @@ impl Game<'_> {
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
// HoneycombItem.useOn's game event, and ItemStack.useOn's used statistic.
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
hit.pos,
|
||||
self.sculk_actor(),
|
||||
Some(state),
|
||||
);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
@@ -9196,10 +9398,9 @@ impl Game<'_> {
|
||||
)
|
||||
}
|
||||
fn apply_settings(&mut self) {
|
||||
if let Some(r) = &self.renderer {
|
||||
self.settings
|
||||
.clamp_gui_scale([r.config.width as i32, r.config.height as i32]);
|
||||
}
|
||||
// LocalPlayer.refreshChatAbilities: the chat shows what the Chat option allows.
|
||||
let abilities = self.chat_abilities();
|
||||
self.ui.chat.set_abilities(&self.gui.atlas.font, abilities);
|
||||
self.streaming.radius = self.settings.render_distance;
|
||||
// Options.biomeBlendRadius, ambientOcclusion and cutoutLeaves:
|
||||
// LevelExtractor.allChanged when they change.
|
||||
@@ -9962,6 +10163,7 @@ impl Game<'_> {
|
||||
self.pitch,
|
||||
),
|
||||
};
|
||||
let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch);
|
||||
self.audio
|
||||
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
|
||||
let block = [
|
||||
@@ -10193,6 +10395,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.shader_pack_inputs(camera, alpha as f32)
|
||||
};
|
||||
let entity_spans = std::mem::take(self.entity_spans.get_mut());
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
@@ -10257,6 +10460,7 @@ impl Game<'_> {
|
||||
color: sky.cloud_color,
|
||||
}),
|
||||
entities: &entities,
|
||||
entity_spans: &entity_spans,
|
||||
dynamic_lights: &self.dynamic_lights,
|
||||
lightning: horizon_client::gfx::lightning::Input {
|
||||
bolts: &bolts,
|
||||
@@ -10303,11 +10507,15 @@ impl Game<'_> {
|
||||
menu_blur: self.menu_blur(),
|
||||
};
|
||||
let result = match self.renderer.as_mut() {
|
||||
Some(renderer) => horizon_client::spans::time("renderer", || renderer.render(&frame)),
|
||||
Some(renderer) => {
|
||||
renderer.set_pass_timing(self.debug);
|
||||
horizon_client::spans::time("renderer", || renderer.render(&frame))
|
||||
}
|
||||
None => Err("renderer".into()),
|
||||
};
|
||||
// Restore storage even when rendering fails (for example, a lost surface).
|
||||
self.entity_vertices = entities;
|
||||
*self.entity_spans.get_mut() = entity_spans;
|
||||
self.see_through_vertices = see_through;
|
||||
result
|
||||
}
|
||||
@@ -10379,6 +10587,7 @@ impl Game<'_> {
|
||||
|| (self.title.is_none()
|
||||
&& self.player.is_some()
|
||||
&& (self.screen.blurs_background(self.pause_menu)
|
||||
|| self.dialog_open()
|
||||
|| (self.screen == Screen::Win && self.win_screen_pauses())));
|
||||
let radius = self.settings.menu_blur as f32;
|
||||
(blurred && radius >= 1.0).then_some(radius)
|
||||
@@ -10431,6 +10640,9 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.screen == Screen::Mod && self.mod_screen_key(key) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.sort_key_press(key) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -10573,6 +10785,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// A mouse button while playing (window or control socket).
|
||||
fn play_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
// A mod's claimed item takes the use button (capability `input`).
|
||||
if self.mod_use(button, pressed) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.sleeping.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -10692,6 +10908,7 @@ impl Game<'_> {
|
||||
// The loading screen's chunk map fills ring by ring, as vanilla's.
|
||||
self.streaming.loading =
|
||||
self.options.execution_role.is_client() && self.loading_reason().is_some();
|
||||
self.tick_pregen();
|
||||
let (uploads, unloaded) =
|
||||
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
|
||||
if let Some(lod) = &mut self.lod {
|
||||
@@ -11034,6 +11251,12 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::Advancements {
|
||||
self.advancements_drag();
|
||||
}
|
||||
if self.screen == Screen::Stats {
|
||||
self.stats_drag();
|
||||
}
|
||||
if self.screen == Screen::WorldOptions {
|
||||
self.world_options_drag();
|
||||
}
|
||||
if self.screen == Screen::Creative {
|
||||
let (_, oy) = Self::creative_origin(&b);
|
||||
self.creative_screen.drag_scrollbar(self.cursor.1 - oy);
|
||||
@@ -11070,7 +11293,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
}) * wheel_sensitivity
|
||||
};
|
||||
let d = scaled(raw_y) as f32;
|
||||
if let Some(title) = &mut self.title
|
||||
if self.dialog_open() {
|
||||
self.dialog_scroll(d);
|
||||
} else if let Some(title) = &mut self.title
|
||||
&& self.screen != Screen::Win
|
||||
{
|
||||
let size = self
|
||||
@@ -11084,7 +11309,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::Playing
|
||||
&& self.sleeping.is_none()
|
||||
&& self.player.is_some()
|
||||
&& !self.extras_scroll(d)
|
||||
&& !self.mod_wheel(d)
|
||||
&& let Some((wheel, (_, vertical_steps))) =
|
||||
self.mouse
|
||||
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
|
||||
@@ -11140,6 +11365,11 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.pack_prompt_click()?;
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.dialog_open() => {
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.dialog_click()?;
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
|
||||
if self.options_waiting_for_key() && state == ElementState::Pressed {
|
||||
@@ -11254,6 +11484,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
self.extras_mouse(button, pressed);
|
||||
self.mod_mouse_key(button, pressed);
|
||||
match self.settings.keys.translate_mouse(button) {
|
||||
Some(Err(b)) => self.play_mouse(b, pressed)?,
|
||||
Some(Ok(k)) if pressed => {
|
||||
@@ -11313,6 +11544,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
Screen::WorldOptions if pressed && button == MouseButton::Left => {
|
||||
self.world_options_click()
|
||||
}
|
||||
Screen::WorldOptions if button == MouseButton::Left => {
|
||||
self.world_options_release()
|
||||
}
|
||||
Screen::Friends if pressed && button == MouseButton::Left => {
|
||||
self.friends_screen_click()
|
||||
}
|
||||
@@ -11336,9 +11570,8 @@ impl ApplicationHandler for Game<'_> {
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
self.stats_click(size);
|
||||
}
|
||||
Screen::Mod if pressed && button == MouseButton::Left => {
|
||||
self.mod_screen_click()
|
||||
}
|
||||
Screen::Stats if button == MouseButton::Left => self.stats_release(),
|
||||
Screen::Mod if pressed => self.mod_screen_click(button),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -11350,6 +11583,14 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.answer_pack_prompt(false)?;
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.dialog_open() => {
|
||||
let shortcut = self.edit_shortcut(&event);
|
||||
if event.state == ElementState::Pressed
|
||||
&& let PhysicalKey::Code(key) = event.physical_key
|
||||
{
|
||||
self.dialog_key(key, shortcut, event.text.as_deref())?
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. }
|
||||
if self.screen == Screen::Chat && self.title.is_none() =>
|
||||
{
|
||||
@@ -11479,7 +11720,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
let PhysicalKey::Code(key) = event.physical_key else {
|
||||
return Ok(());
|
||||
};
|
||||
self.extras_key(key, event.state == ElementState::Pressed, event.repeat);
|
||||
self.mod_key(key, event.state == ElementState::Pressed, event.repeat);
|
||||
// key.smoothCamera toggles the cinematic camera.
|
||||
if self.screen == Screen::Playing
|
||||
&& event.state == ElementState::Pressed
|
||||
@@ -11571,12 +11812,14 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
self.extras_frame(elapsed);
|
||||
self.mods_frame(elapsed);
|
||||
self.poll_moved_extras();
|
||||
self.extras_retry()?;
|
||||
self.apply_mouse_look();
|
||||
self.pad_look(elapsed.as_secs_f32());
|
||||
self.advance_flight();
|
||||
let ticked = Instant::now();
|
||||
let rendered = self.render()?;
|
||||
self.apply_cursor_icon();
|
||||
if let Some(threshold) = self.options.spikes
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
{
|
||||
@@ -11830,6 +12073,7 @@ impl<'a> Game<'a> {
|
||||
world_loader: None,
|
||||
pending_pack_reload: false,
|
||||
server_packs: Default::default(),
|
||||
dialogs: Default::default(),
|
||||
world_renderer_ready: false,
|
||||
collision: res.collision(),
|
||||
motion: res.motion(),
|
||||
@@ -11884,6 +12128,7 @@ impl<'a> Game<'a> {
|
||||
left: false,
|
||||
right: false,
|
||||
cursor: (0.0, 0.0),
|
||||
cursor_icon: horizon_client::widgets::Cursor::Arrow,
|
||||
captured: false,
|
||||
focused: true,
|
||||
modifiers: winit::keyboard::ModifiersState::empty(),
|
||||
@@ -11902,6 +12147,8 @@ impl<'a> Game<'a> {
|
||||
dynamic_lights: Vec::new(),
|
||||
luminance: None,
|
||||
see_through_vertices: Vec::new(),
|
||||
entity_spans: Default::default(),
|
||||
pack_ids: None,
|
||||
health_hud: hud_layout::HealthHud::default(),
|
||||
started: now,
|
||||
previous: now,
|
||||
@@ -11959,6 +12206,7 @@ impl<'a> Game<'a> {
|
||||
offhand: None,
|
||||
hand: Hand::Main,
|
||||
shield_cooldown: 0,
|
||||
cooldowns: Default::default(),
|
||||
command_source: None,
|
||||
titles: Default::default(),
|
||||
tool_highlight: Default::default(),
|
||||
@@ -12074,10 +12322,12 @@ impl<'a> Game<'a> {
|
||||
agent: agent::Agent::default(),
|
||||
view_frustum: None,
|
||||
entity_culler: None,
|
||||
pack_shadow_casters: (false, false),
|
||||
pending_uploads: Vec::new(),
|
||||
frame_graph: Default::default(),
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
native_shaders: Default::default(),
|
||||
sky_darken_memo: Default::default(),
|
||||
persist: Default::default(),
|
||||
remote: None,
|
||||
@@ -12104,9 +12354,11 @@ impl<'a> Game<'a> {
|
||||
lan_events: Vec::new(),
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
lan_global_events: Vec::new(),
|
||||
lid_openers: HashMap::new(),
|
||||
lan_acting: None,
|
||||
guest_packets: Vec::new(),
|
||||
guest_cooldowns: Vec::new(),
|
||||
lan_acting_name: None,
|
||||
lan_acting_uuid: None,
|
||||
chat_capture: None,
|
||||
@@ -12836,6 +13088,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
hud,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
entity_spans: &[],
|
||||
dynamic_lights: &[],
|
||||
clouds: None,
|
||||
lightning: Default::default(),
|
||||
@@ -12910,6 +13163,7 @@ fn start_game<'a>(
|
||||
// Benchmarks measure the game alone.
|
||||
if game.options.benchmark.is_none() {
|
||||
game.mods.dev = game.options.dev_mods.clone();
|
||||
game.start_moved_extras();
|
||||
game.start_mods();
|
||||
}
|
||||
game.start_agent_control()?;
|
||||
@@ -12937,11 +13191,11 @@ fn start_game<'a>(
|
||||
);
|
||||
}
|
||||
if let Ok(spec) = std::env::var("HORIZON_DISPENSE_FIXTURE") {
|
||||
game.dispense_fixture(&spec)?;
|
||||
game.run_component_harness(integrated::ComponentHarness::Dispenser(spec))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Ok(spec) = std::env::var("HORIZON_SCULK_FIXTURE") {
|
||||
game.sculk_fixture(&spec)?;
|
||||
game.run_component_harness(integrated::ComponentHarness::Sculk(spec))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if game.options.execution_role == execution_role::ExecutionRole::DedicatedServer {
|
||||
@@ -12963,8 +13217,8 @@ fn start_game<'a>(
|
||||
Some(folder) => {
|
||||
let (seed, creative) = (game.options.seed, game.options.creative);
|
||||
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some() {
|
||||
// The command harness runs before entering the window loop.
|
||||
game.open_world(&folder, None, Some(seed), creative, None)?;
|
||||
// Headless, but with the same server and memory connection as play.
|
||||
game.open_integrated(&folder, None, Some(seed), creative, None)?;
|
||||
} else {
|
||||
// Start after the window is handed over, so --world uses the
|
||||
// same responsive initialization path as the title screen.
|
||||
@@ -12991,11 +13245,10 @@ fn start_game<'a>(
|
||||
game.title = Some(title);
|
||||
}
|
||||
}
|
||||
if let (Some(script), Some(output)) = (
|
||||
std::env::var_os("HORIZON_COMMAND_SCRIPT"),
|
||||
std::env::var_os("HORIZON_COMMAND_OUTPUT"),
|
||||
) {
|
||||
game.run_command_harness(script.as_ref(), output.as_ref())?;
|
||||
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
|
||||
&& std::env::var_os("HORIZON_COMMAND_OUTPUT").is_some()
|
||||
{
|
||||
game.wait_command_harness()?;
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(game))
|
||||
@@ -13096,10 +13349,6 @@ impl Loading {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(r) = &self.renderer {
|
||||
self.settings
|
||||
.clamp_gui_scale([r.config.width as i32, r.config.height as i32]);
|
||||
}
|
||||
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
|
||||
self.window = Some(window);
|
||||
}
|
||||
@@ -13138,8 +13387,6 @@ impl Loading {
|
||||
self.settings.horizon.api,
|
||||
) {
|
||||
Ok(r) => {
|
||||
self.settings
|
||||
.clamp_gui_scale([r.config.width as i32, r.config.height as i32]);
|
||||
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
|
||||
self.renderer = Some(r);
|
||||
}
|
||||
|
||||
@@ -390,6 +390,9 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn screen_label(&self) -> &'static str {
|
||||
if self.dialog_open() {
|
||||
return "dialog";
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return "title";
|
||||
}
|
||||
@@ -485,6 +488,7 @@ impl Game<'_> {
|
||||
})
|
||||
});
|
||||
json!({
|
||||
"dialog": self.dialog_status(),
|
||||
"screen": self.screen_label(),
|
||||
"menu": self.title.as_ref().map(|_| self.title_value()),
|
||||
"world": (self.title.is_none()).then(|| match &self.integrated {
|
||||
@@ -682,6 +686,18 @@ impl Game<'_> {
|
||||
v["craft_size"] = json!(self.craft_size);
|
||||
v["station"] = json!(self.station.as_ref().map(|s| format!("{:?}", s.kind)));
|
||||
v["container"] = json!(self.container);
|
||||
// The title a server gave the menu, span by span as it is drawn.
|
||||
if let Some(title) = self.remote_menu_title() {
|
||||
let spans = title.spans.iter().map(|s| {
|
||||
json!({
|
||||
"text": s.text,
|
||||
"color": s.style.color.map(|c| format!("#{c:06X}")),
|
||||
"bold": s.style.is_bold(),
|
||||
"italic": s.style.is_italic(),
|
||||
})
|
||||
});
|
||||
v["title"] = json!(spans.collect::<Vec<_>>());
|
||||
}
|
||||
// The recipe book's buttons (GUI pixels): the book button, then
|
||||
// the recipe buttons of the page, page arrows, filter and tabs.
|
||||
let book = self.recipe_book_targets();
|
||||
|
||||
@@ -8,10 +8,15 @@ use horizon_client::{
|
||||
block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker},
|
||||
block_entity_render::BlockAnim,
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
containers::BlockEntity,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
save::number,
|
||||
skins,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// The handle of the local player among a bell's gathered entities.
|
||||
@@ -84,6 +89,16 @@ impl Game<'_> {
|
||||
self.lid_openers_changed(other, open);
|
||||
}
|
||||
}
|
||||
_ if self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.name
|
||||
.as_ref()
|
||||
== "minecraft:barrel" =>
|
||||
{
|
||||
self.barrel_openers_changed(key, open);
|
||||
}
|
||||
Some(Ticker::Shulker { .. }) => {
|
||||
let shulker = self.be_anims.shulkers.entry(pos).or_default();
|
||||
let (count, _) = if open {
|
||||
@@ -102,15 +117,48 @@ impl Game<'_> {
|
||||
/// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here
|
||||
/// and for the guests.
|
||||
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
let count = self.lid_openers.entry(key).or_default();
|
||||
*count = (*count + if open { 1 } else { -1 }).max(0);
|
||||
let count = *count;
|
||||
if count == 0 {
|
||||
self.lid_openers.remove(&key);
|
||||
}
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
let pos = [key.0, key.1, key.2];
|
||||
self.block_entity_event(pos, 1, count);
|
||||
self.lan_block_event(pos, 1, count);
|
||||
// TrappedChestBlockEntity.signalOpenCount: its signal is the count
|
||||
// (`TrappedChestBlock.getSignal`), so the blocks around it and
|
||||
// below it hear of a change.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if before != count && self.res.facts().get(state).name.as_ref() == "minecraft:trapped_chest"
|
||||
{
|
||||
let p = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
let block = b.block(state);
|
||||
b.update_neighbors_at(level, ticks, p, block);
|
||||
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel
|
||||
/// (`open=true`), the last one to leave closes it (`updateBlockState`).
|
||||
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
if (before == 0) != (count == 0) {
|
||||
self.set_bool_prop(
|
||||
key,
|
||||
"open",
|
||||
count > 0,
|
||||
horizon_world::level::flags::UPDATE_ALL,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// One opener more (`open`) or less at `key`: the count before and after
|
||||
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
|
||||
fn count_opener(&mut self, key: (i32, i32, i32), open: bool) -> (i32, i32) {
|
||||
let count = self.lid_openers.entry(key).or_default();
|
||||
let before = *count;
|
||||
*count = (before + if open { 1 } else { -1 }).max(0);
|
||||
let after = *count;
|
||||
if after == 0 {
|
||||
self.lid_openers.remove(&key);
|
||||
}
|
||||
(before, after)
|
||||
}
|
||||
/// `BellBlockEntity.updateEntities`: the living entities whose bounding
|
||||
/// box meets the bell's block box inflated by 48.
|
||||
@@ -392,4 +440,25 @@ impl Game<'_> {
|
||||
pub(super) fn chest_lid(&self, pos: [i32; 3], partial: f32) -> f32 {
|
||||
self.be_anims.lid(pos, partial)
|
||||
}
|
||||
/// `SkullBlockRenderer.getPlayerSkin`: the skin sprite of the profile of
|
||||
/// the player head at `pos`. The skin download is started here; while it
|
||||
/// is in flight (or when it cannot be got) the profile's default skin
|
||||
/// stands in. A head with no profile answers None: `SKIN_BY_TYPE`.
|
||||
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
|
||||
let Some(BlockEntity::Skull(skull)) =
|
||||
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let skin = skull.skin.as_ref()?;
|
||||
if let Some((url, location)) = &skin.download {
|
||||
self.skin_downloads
|
||||
.borrow_mut()
|
||||
.state(skins::Kind::Skin, url);
|
||||
if self.gui.atlas.sprites.contains_key(location) {
|
||||
return Some(location);
|
||||
}
|
||||
}
|
||||
Some(skin.fallback)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
//! (game rules, the world clock, difficulty), bound to the game.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
|
||||
chat::{
|
||||
Chat, ChatAbilities, ChatAction, ChatScreen, ChatSource, ChatVisibility, Commands,
|
||||
SYSTEM_INDICATOR,
|
||||
},
|
||||
command::Registries,
|
||||
gamerules::GameRules,
|
||||
hud::HudBatch,
|
||||
@@ -425,6 +428,7 @@ impl Game<'_> {
|
||||
self.ui.chat.recent.len(),
|
||||
b.width as i32,
|
||||
b.height as i32,
|
||||
self.chat_abilities(),
|
||||
);
|
||||
screen.auto_suggestions = self.settings.auto_suggestions;
|
||||
self.ui.chat_screen = Some(screen);
|
||||
@@ -443,6 +447,10 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// `Minecraft.computeChatAbilities` from the Chat option.
|
||||
pub(super) fn chat_abilities(&self) -> ChatAbilities {
|
||||
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
|
||||
}
|
||||
/// A chat message from the game (ChatComponent.addServerSystemMessage).
|
||||
pub(super) fn system_message(&mut self, text: Text) {
|
||||
if let Some(capture) = self.chat_capture.as_mut() {
|
||||
@@ -452,7 +460,11 @@ impl Game<'_> {
|
||||
// Connected to a server (single player's own included), the chat on
|
||||
// screen is the connection's: the game's own messages join it.
|
||||
if self.remote.is_some() {
|
||||
self.chat_line(text);
|
||||
self.chat_line(text, ChatSource::Server);
|
||||
return;
|
||||
}
|
||||
// The Chat option's Hidden level takes the server's messages too.
|
||||
if !self.ui.chat.shows(ChatSource::Server) {
|
||||
return;
|
||||
}
|
||||
let focused = self.screen == Screen::Chat;
|
||||
@@ -461,6 +473,7 @@ impl Game<'_> {
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
Some(SYSTEM_INDICATOR),
|
||||
ChatSource::Server,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
@@ -570,7 +583,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// ChatScreen.handleChatInput: history, then a command or a chat line.
|
||||
/// The Chat option's restrictions stop the message here too, so a
|
||||
/// control-socket `chat` cannot send what the player's setting hides.
|
||||
pub(super) fn handle_chat_input(&mut self, message: &str) {
|
||||
if !self.chat_abilities().allows(message) {
|
||||
return;
|
||||
}
|
||||
self.ui.chat.add_recent(message);
|
||||
// On a server (single player's own included) the server runs it,
|
||||
// unless a client mod owns the command.
|
||||
@@ -592,9 +610,14 @@ impl Game<'_> {
|
||||
);
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let heard = self.mods.running().then(|| line.plain());
|
||||
self.ui
|
||||
.chat
|
||||
.add(&self.gui.atlas, line, None, self.ticks, focused);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
ChatSource::Player,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
if let Some(line) = heard {
|
||||
self.mods_chat(&line);
|
||||
}
|
||||
@@ -898,9 +921,14 @@ impl Game<'_> {
|
||||
Err(e) => Text::translate("screenshot.failure", &[Text::literal(e.clone())]),
|
||||
};
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui
|
||||
.chat
|
||||
.add(&self.gui.atlas, message, None, self.ticks, focused);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
message,
|
||||
None,
|
||||
ChatSource::Client,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Headless command runner for `tools/test_command_exec_parity.py`: with
|
||||
//! `HORIZON_COMMAND_SCRIPT` and `HORIZON_COMMAND_OUTPUT` set, the opened world
|
||||
//! runs without a window. Each script line is a JSON operation: `cmd` runs a
|
||||
//! runs on its server thread with a connected, headless client. Each script
|
||||
//! line is a JSON operation: `cmd` runs a
|
||||
//! command as the player (Commands.performPrefixedCommand) and records every
|
||||
//! system message it produced, as styled spans, with the results reported to
|
||||
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
|
||||
@@ -41,30 +42,100 @@ pub(super) fn spans(text: &Text) -> Value {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Pump the real client's connection, without predicting movement: the
|
||||
/// oracle's player stays where commands put it. Errors cross the server
|
||||
/// boundary through Integrated::stop, including script and save failures.
|
||||
pub(super) fn wait_command_harness(&mut self) -> Result<()> {
|
||||
let server = self.integrated.take().ok_or("command harness: no server")?;
|
||||
while !server.stopped() {
|
||||
self.poll_stationary_client()?;
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
server.stop()
|
||||
}
|
||||
|
||||
/// The owner's authoritative body and level, using the same swaps as a
|
||||
/// server command. Always put them back, also when a script operation fails.
|
||||
fn harness_owner<T>(&mut self, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
let owner = self
|
||||
.serving
|
||||
.as_ref()
|
||||
.ok_or("command harness: not a server")?
|
||||
.owner;
|
||||
let mut host = self.lan.take().ok_or("command harness: no hub")?;
|
||||
let guest = host.hub.guests.iter().find(|g| g.uuid == owner && g.placed);
|
||||
let Some(guest) = guest else {
|
||||
self.lan = Some(host);
|
||||
return Err("command harness: owner is not connected".into());
|
||||
};
|
||||
let (conn, dim, back) = (guest.conn, super::Dim::from_id(&guest.dimension), self.dim);
|
||||
if let Err(error) = self.enter_level(Some(&mut host), dim) {
|
||||
self.lan = Some(host);
|
||||
return Err(error);
|
||||
}
|
||||
let swap = self
|
||||
.enter_guest(&mut host, conn)
|
||||
.expect("the owner is not already acting");
|
||||
self.lan = Some(host);
|
||||
let result = f(self);
|
||||
let mut host = self.lan.take().expect("the operation puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
self.run_level_teleports(&mut host);
|
||||
let restored = self.enter_level(Some(&mut host), back);
|
||||
self.lan = Some(host);
|
||||
restored?;
|
||||
result
|
||||
}
|
||||
|
||||
/// The player's portal process finished in the nether portal it stands
|
||||
/// in (the oracle's player is not ticked, so neither is the wait).
|
||||
fn harness_portal(&mut self) -> Result<()> {
|
||||
let Some(p) = &self.player else {
|
||||
return Err("command harness: portal without a player".into());
|
||||
};
|
||||
let entry = horizon_core::position::BlockPos::containing(p.position);
|
||||
let block = self.world.block(entry.x, entry.y, entry.z);
|
||||
if &*self.res.facts().get(block).name != "minecraft:nether_portal" {
|
||||
return Err("command harness: the player is not in a nether portal".into());
|
||||
}
|
||||
self.player_through_nether_portal(entry)
|
||||
let (conn, plan) = self.harness_owner(|g| {
|
||||
let p = g
|
||||
.player
|
||||
.as_ref()
|
||||
.ok_or("command harness: portal without a player")?;
|
||||
let entry = horizon_core::position::BlockPos::containing(p.position);
|
||||
let block = g.world.block(entry.x, entry.y, entry.z);
|
||||
if &*g.res.facts().get(block).name != "minecraft:nether_portal" {
|
||||
return Err("command harness: the player is not in a nether portal".into());
|
||||
}
|
||||
let plan = g.portal_plan(
|
||||
entry,
|
||||
p.position,
|
||||
p.dimensions.width,
|
||||
p.dimensions.height,
|
||||
true,
|
||||
);
|
||||
Ok((
|
||||
g.lan_acting.ok_or("command harness: no acting owner")?,
|
||||
plan,
|
||||
))
|
||||
})?;
|
||||
// Start travel after restoring the owner's body: change_level updates
|
||||
// its hub record, which leave_guest would otherwise overwrite.
|
||||
self.in_owner_level(|g| {
|
||||
let mut host = g.lan.take().ok_or("command harness: no hub")?;
|
||||
g.start_server_trip(&mut host, conn, plan.dim, Some(plan));
|
||||
g.lan = Some(host);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
/// Load terrain until the player stands in lit chunks.
|
||||
fn harness_settle(&mut self, deadline: Instant) -> Result<()> {
|
||||
loop {
|
||||
self.update_world();
|
||||
self.spawn_player()?;
|
||||
if let Some(p) = &self.player {
|
||||
let (cx, cz) = (
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
);
|
||||
if (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))) {
|
||||
// Finish requested terrain before inspecting it, so worker
|
||||
// timing cannot change which command positions are loaded.
|
||||
if !self.streaming.busy()
|
||||
&& (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz)))
|
||||
{
|
||||
// The oracle's settle also waits for the entities saved
|
||||
// in those chunks (areEntitiesActuallyLoadedAndTicking):
|
||||
// after a dimension change they come back from the save.
|
||||
@@ -95,7 +166,19 @@ impl Game<'_> {
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(600),
|
||||
);
|
||||
self.harness_settle(deadline)?;
|
||||
// Join/configuration uses the normal hub, but never advances the world.
|
||||
while !self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.guests.iter().any(|g| g.placed && g.loaded))
|
||||
{
|
||||
self.tick_server(true)?;
|
||||
if Instant::now() > deadline {
|
||||
return Err("command harness: owner did not connect".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline))?;
|
||||
let mut out = String::new();
|
||||
for line in std::fs::read_to_string(script)?.lines() {
|
||||
if line.trim().is_empty() {
|
||||
@@ -107,9 +190,12 @@ impl Game<'_> {
|
||||
let command = op["cmd"].as_str().unwrap_or("");
|
||||
self.chat_capture = Some(Vec::new());
|
||||
self.cmds.results = Some(Vec::new());
|
||||
self.as_console(|g| {
|
||||
g.run_command(command.strip_prefix('/').unwrap_or(command))
|
||||
});
|
||||
self.harness_owner(|g| {
|
||||
g.as_console(|g| {
|
||||
g.run_command(command.strip_prefix('/').unwrap_or(command))
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
let results = self.cmds.results.take().unwrap_or_default();
|
||||
if op["record"].as_bool() != Some(false) {
|
||||
@@ -124,17 +210,8 @@ impl Game<'_> {
|
||||
}
|
||||
"tick" => {
|
||||
self.chat_capture = Some(Vec::new());
|
||||
// The oracle's player has no client moving it: hold the
|
||||
// player where commands put it while the game ticks.
|
||||
let held = self.player.as_ref().map(|p| p.position);
|
||||
for _ in 0..op["n"].as_u64().unwrap_or(1) {
|
||||
self.update_world();
|
||||
self.tick()?;
|
||||
if let (Some(p), Some(at)) = (self.player.as_mut(), held) {
|
||||
p.position = at;
|
||||
p.velocity = horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0);
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.tick_server(false)?;
|
||||
}
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
if op["record"].as_bool() == Some(true) {
|
||||
@@ -147,7 +224,17 @@ impl Game<'_> {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
"settle" => self.harness_settle(deadline)?,
|
||||
"settle" => {
|
||||
// Finish portal arrivals using connection-only passes too.
|
||||
while !self.server_trips.is_empty() {
|
||||
self.tick_server(true)?;
|
||||
if Instant::now() > deadline {
|
||||
return Err("command harness: portal did not arrive".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline))?;
|
||||
}
|
||||
"portal" => self.harness_portal()?,
|
||||
other => return Err(format!("command harness: unknown op {other}").into()),
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ mod items;
|
||||
mod misc;
|
||||
mod place;
|
||||
mod players;
|
||||
pub(super) mod pregen;
|
||||
mod raid;
|
||||
mod terrain_cmds;
|
||||
mod text_commands;
|
||||
@@ -751,13 +752,15 @@ impl Game<'_> {
|
||||
&& !id.ends_with("get")
|
||||
&& let Ok(t) = self.entities(ctx, "target", false)
|
||||
{
|
||||
self.cmds.waypoint_dirty.push(t[0]);
|
||||
let target = self.waypoint_target(t[0]);
|
||||
self.cmds.waypoint_dirty.push(target);
|
||||
}
|
||||
result
|
||||
}
|
||||
"ride.mount" | "ride.dismount" => self.cmd_ride(id, ctx),
|
||||
"spreadplayers" => self.cmd_spreadplayers(ctx),
|
||||
id if id.starts_with("forceload.") => self.cmd_forceload(id, ctx),
|
||||
id if id.starts_with("pregen.") => self.cmd_pregen(id, ctx),
|
||||
"fillbiome" => self.cmd_fillbiome(ctx),
|
||||
id if id.starts_with("worldborder.") => self.cmd_worldborder(id, ctx),
|
||||
id if id.starts_with("loot.") => self.cmd_loot(id, ctx),
|
||||
@@ -2499,12 +2502,14 @@ impl Game<'_> {
|
||||
};
|
||||
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
|
||||
let counting = max == 0;
|
||||
// Inventory.clearOrCountMatchingItems: main, armor, crafting grid, carried.
|
||||
// Inventory slots 0..40: main, armor feet to head, off hand;
|
||||
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
|
||||
let mut removed = 0i32;
|
||||
let mut budget = if max < 0 { i32::MAX } else { max };
|
||||
let all = |g: &Self| -> Vec<Option<ItemStack>> {
|
||||
let mut v: Vec<Option<ItemStack>> = g.inventory.clone();
|
||||
v.extend(g.armor.iter().cloned());
|
||||
v.extend(g.armor.iter().rev().cloned());
|
||||
v.push(g.offhand.clone());
|
||||
v.extend(g.craft_grid.iter().cloned());
|
||||
v.push(g.carried.clone());
|
||||
v
|
||||
@@ -2518,7 +2523,8 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
|
||||
slots.extend(self.inventory.iter_mut());
|
||||
slots.extend(self.armor.iter_mut());
|
||||
slots.extend(self.armor.iter_mut().rev());
|
||||
slots.push(&mut self.offhand);
|
||||
slots.extend(self.craft_grid.iter_mut());
|
||||
slots.push(&mut self.carried);
|
||||
for (slot, matches) in slots.into_iter().zip(matching) {
|
||||
@@ -2836,9 +2842,14 @@ impl Game<'_> {
|
||||
capture.push(line.clone());
|
||||
}
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui
|
||||
.chat
|
||||
.add(&self.gui.atlas, line, None, self.ticks, focused);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
horizon_client::chat::ChatSource::Player,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
Ok(1)
|
||||
}
|
||||
/// MessageArgument: `@p`-style selectors in a message become names.
|
||||
|
||||
@@ -57,6 +57,8 @@ pub(in crate::game) struct CommandExtras {
|
||||
/// The `CommandResultCallback` results of the outermost source, when a
|
||||
/// harness records them.
|
||||
pub results: Option<Vec<(bool, i32)>>,
|
||||
/// The running `/pregen` task.
|
||||
pub pregen: Option<super::pregen::PregenTask>,
|
||||
}
|
||||
|
||||
use horizon_core::version::{BUILD_TIME, DATA_VERSION, STABLE, VANILLA_VERSION, VERSION_ID};
|
||||
@@ -212,8 +214,22 @@ impl Game<'_> {
|
||||
|
||||
// ---- /waypoint ------------------------------------------------------
|
||||
|
||||
/// A guest's body is only `Player` during its turn; retained command state
|
||||
/// must use its connection so the next server tick still finds that body.
|
||||
pub(super) fn waypoint_target(&self, t: Target) -> Target {
|
||||
match t {
|
||||
Target::Player => self.lan_acting.map_or(t, Target::Guest),
|
||||
_ => t,
|
||||
}
|
||||
}
|
||||
|
||||
/// A living entity's `waypoint_transmit_range` (`isTransmittingWaypoint`).
|
||||
fn transmit_range(&mut self, t: Target) -> f64 {
|
||||
if matches!(t, Target::Guest(_)) {
|
||||
return self
|
||||
.for_player(t, |g| g.transmit_range(Target::Player))
|
||||
.unwrap_or(0.0);
|
||||
}
|
||||
if t == Target::Player {
|
||||
return self
|
||||
.player
|
||||
@@ -242,7 +258,7 @@ impl Game<'_> {
|
||||
fn waypoint_transmitters(&mut self) -> Vec<Target> {
|
||||
if self.cmds.waypoints.is_none() {
|
||||
let tracked = if self.transmit_range(Target::Player) > 0.0 {
|
||||
vec![Target::Player]
|
||||
vec![self.waypoint_target(Target::Player)]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -255,6 +271,12 @@ impl Game<'_> {
|
||||
.collect();
|
||||
self.cmds.waypoints = Some(alive.clone());
|
||||
alive
|
||||
.into_iter()
|
||||
.map(|t| match t {
|
||||
Target::Guest(conn) if self.lan_acting == Some(conn) => Target::Player,
|
||||
_ => t,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The waypoint part of a living entity's tick: dirty transmit ranges
|
||||
/// track or untrack it.
|
||||
@@ -277,7 +299,7 @@ impl Game<'_> {
|
||||
fn waypoint_icon(&self, t: Target) -> WaypointIcon {
|
||||
self.cmds
|
||||
.waypoint_icons
|
||||
.get(&t)
|
||||
.get(&self.waypoint_target(t))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -387,6 +409,7 @@ impl Game<'_> {
|
||||
&[Text::colored(name, rgb)],
|
||||
)
|
||||
};
|
||||
let t = self.waypoint_target(t);
|
||||
self.cmds.waypoint_icons.insert(t, icon);
|
||||
// WaypointCommand.mutateIcon: untrack, change, track again.
|
||||
self.waypoint_transmitters();
|
||||
@@ -606,6 +629,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| lan.hub.singleplayer_owner != Some(g.uuid))
|
||||
.filter(|g| match &s.player_name {
|
||||
Some(name) => g.name.eq_ignore_ascii_case(name),
|
||||
None => s.kind == 'a' || s.kind == 'e',
|
||||
@@ -617,7 +641,7 @@ impl Game<'_> {
|
||||
if host.is_empty() && guests.is_empty() {
|
||||
return Err(err("argument.entity.notfound.player", &[]));
|
||||
}
|
||||
if self.lan.is_none() {
|
||||
if self.lan.is_none() || (self.serving() && self.published_port().is_none()) {
|
||||
return Err(err("commands.kick.singleplayer.failed", &[]));
|
||||
}
|
||||
let reason_raw = match ctx.get("reason") {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! `/pregen`, Horizon's own command: generate, light and save every chunk of a
|
||||
//! square or a circle ahead of the players (docs/pregeneration.md). The task
|
||||
//! lives on the server and hands its band to the level's streaming, whose
|
||||
//! workers do the work; chunks behind the band unload and are saved as any
|
||||
//! chunk the players leave.
|
||||
use super::{CommandError, Context, Dim, Game, R};
|
||||
use horizon_client::{
|
||||
lan::data::component_nbt,
|
||||
pregen::{Pregen, Shape},
|
||||
text::{self, Text},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often the progress is reported.
|
||||
const REPORT_EVERY: Duration = Duration::from_secs(5);
|
||||
/// How long the band may stay put before the task looks for what holds it.
|
||||
const STALL: Duration = Duration::from_secs(30);
|
||||
|
||||
pub(in crate::game) struct PregenTask {
|
||||
pregen: Pregen,
|
||||
dim: Dim,
|
||||
paused: bool,
|
||||
/// Time spent running, up to the last pause, and when it last resumed.
|
||||
ran: Duration,
|
||||
resumed: Instant,
|
||||
reported: Instant,
|
||||
/// When the band last moved, to notice a task that stopped advancing.
|
||||
moved: Instant,
|
||||
/// The player who started it, told the progress too (a guest's connection).
|
||||
owner: Option<u32>,
|
||||
}
|
||||
|
||||
impl PregenTask {
|
||||
fn running_for(&self) -> Duration {
|
||||
if self.paused {
|
||||
self.ran
|
||||
} else {
|
||||
self.ran + self.resumed.elapsed()
|
||||
}
|
||||
}
|
||||
fn progress(&self) -> Text {
|
||||
let p = &self.pregen;
|
||||
let seconds = self.running_for().as_secs_f64();
|
||||
let rate = p.done as f64 / seconds.max(1e-3);
|
||||
let left = p.total - p.done;
|
||||
let eta = if rate > 0.0 {
|
||||
duration(left as f64 / rate)
|
||||
} else {
|
||||
"unknown".into()
|
||||
};
|
||||
Text::literal(format!(
|
||||
"Pregenerating {}: {} of {} chunks ({:.1}%), {rate:.1} chunks/s, {} left",
|
||||
self.dim.id(),
|
||||
p.done,
|
||||
p.total,
|
||||
100.0 * p.done as f64 / p.total.max(1) as f64,
|
||||
if p.done == p.total {
|
||||
"nothing".into()
|
||||
} else {
|
||||
eta
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(message: &str) -> CommandError {
|
||||
CommandError::new(Text::literal(message))
|
||||
}
|
||||
|
||||
/// `1h 02m 03s`.
|
||||
fn duration(seconds: f64) -> String {
|
||||
let s = seconds.round() as u64;
|
||||
match (s / 3600, s / 60 % 60, s % 60) {
|
||||
(0, 0, s) => format!("{s}s"),
|
||||
(0, m, s) => format!("{m}m {s:02}s"),
|
||||
(h, m, s) => format!("{h}h {m:02}m {s:02}s"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn cmd_pregen(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
match id {
|
||||
"pregen.start" => {
|
||||
if self.cmds.pregen.is_some() {
|
||||
return Err(failure(
|
||||
"Pregeneration is already running: /pregen cancel stops it",
|
||||
));
|
||||
}
|
||||
let radius = Self::int(ctx, "radius").unwrap_or(0);
|
||||
let shape = if ctx.literals.iter().any(|l| l == "circle") {
|
||||
Shape::Circle
|
||||
} else {
|
||||
Shape::Square
|
||||
};
|
||||
let [x, z] = if ctx.get("center").is_some() {
|
||||
self.column_arg(ctx, "center")?
|
||||
} else {
|
||||
let p = self.source_position();
|
||||
[p[0].floor() as i32, p[2].floor() as i32]
|
||||
};
|
||||
let pregen = Pregen::new((x >> 4, z >> 4), radius >> 4, shape);
|
||||
let now = Instant::now();
|
||||
let total = pregen.total;
|
||||
self.feedback(Text::literal(format!(
|
||||
"Pregenerating {total} chunks of {}: a {} of radius {radius} around {x} {z}",
|
||||
self.dim.id(),
|
||||
match shape {
|
||||
Shape::Square => "square",
|
||||
Shape::Circle => "circle",
|
||||
},
|
||||
)));
|
||||
self.cmds.pregen = Some(PregenTask {
|
||||
pregen,
|
||||
dim: self.dim,
|
||||
paused: false,
|
||||
ran: Duration::ZERO,
|
||||
resumed: now,
|
||||
reported: now,
|
||||
moved: now,
|
||||
owner: self.lan_acting,
|
||||
});
|
||||
Ok(total as i32)
|
||||
}
|
||||
"pregen.pause" | "pregen.continue" => {
|
||||
let pause = id == "pregen.pause";
|
||||
let task = self
|
||||
.cmds
|
||||
.pregen
|
||||
.as_mut()
|
||||
.ok_or_else(|| failure("No pregeneration is running"))?;
|
||||
if task.paused != pause {
|
||||
task.paused = pause;
|
||||
if pause {
|
||||
task.ran += task.resumed.elapsed();
|
||||
} else {
|
||||
task.resumed = Instant::now();
|
||||
}
|
||||
}
|
||||
let (progress, dim) = (task.progress(), task.dim);
|
||||
if pause && self.dim == dim {
|
||||
self.streaming.set_band(None);
|
||||
}
|
||||
self.feedback(
|
||||
Text::literal(if pause { "Paused. " } else { "Continuing. " }).append(progress),
|
||||
);
|
||||
Ok(1)
|
||||
}
|
||||
"pregen.cancel" => {
|
||||
let task = self
|
||||
.cmds
|
||||
.pregen
|
||||
.take()
|
||||
.ok_or_else(|| failure("No pregeneration is running"))?;
|
||||
if self.dim == task.dim {
|
||||
self.streaming.set_band(None);
|
||||
}
|
||||
self.feedback(Text::literal("Cancelled. ").append(task.progress()));
|
||||
Ok(task.pregen.done as i32)
|
||||
}
|
||||
_ => {
|
||||
let task = self
|
||||
.cmds
|
||||
.pregen
|
||||
.as_ref()
|
||||
.ok_or_else(|| failure("No pregeneration is running"))?;
|
||||
let progress = task.progress();
|
||||
let done = task.pregen.done as i32;
|
||||
self.feedback(progress);
|
||||
Ok(done)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the task's band past the rows that are lit and hand it to the
|
||||
/// level's streaming, and report the progress now and then. Runs before
|
||||
/// each streaming update of the level in place.
|
||||
pub(in crate::game) fn tick_pregen(&mut self) {
|
||||
let Some(task) = self.cmds.pregen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if task.dim != self.dim || task.paused {
|
||||
return;
|
||||
}
|
||||
// A row is done once its chunks are lit and post-processed
|
||||
// (`postprocess_chunks`, on the tick), as vanilla's are by the time
|
||||
// they are FULL: then they may unload and be saved.
|
||||
let (streaming, chunks) = (&mut self.streaming, &self.world.chunks);
|
||||
if task.pregen.advance(|x, z| {
|
||||
streaming.is_lit(x, z)
|
||||
&& chunks
|
||||
.get(&(x, z))
|
||||
.is_some_and(|c| c.postprocess.is_empty())
|
||||
}) {
|
||||
task.moved = Instant::now();
|
||||
}
|
||||
streaming.set_band(task.pregen.band());
|
||||
if task.moved.elapsed() >= STALL {
|
||||
// Nothing advanced for a while: say what the first row waits
|
||||
// for, and queue light again for chunks that lost their job.
|
||||
task.moved = Instant::now();
|
||||
let row = task.pregen.first_row();
|
||||
let waiting = row
|
||||
.iter()
|
||||
.filter(|k| chunks.get(k).is_some_and(|c| !c.postprocess.is_empty()))
|
||||
.count();
|
||||
let [missing, lighting, unlit] = streaming.unstick_light(&mut self.world, &row);
|
||||
log::warn!(
|
||||
"Horizon: pregeneration waits on row {:?}: {missing} not loaded, {lighting} being lit, \
|
||||
{unlit} unlit with no light job (queued again), {waiting} not post-processed; {:?}",
|
||||
row.first(),
|
||||
streaming.stages()
|
||||
);
|
||||
}
|
||||
let finished = task.pregen.finished();
|
||||
if finished || task.reported.elapsed() >= REPORT_EVERY {
|
||||
task.reported = Instant::now();
|
||||
let (owner, mut message) = (task.owner, task.progress());
|
||||
if finished {
|
||||
message = Text::literal("Done. ")
|
||||
.append(message)
|
||||
.with_color(text::GREEN);
|
||||
self.cmds.pregen = None;
|
||||
}
|
||||
self.pregen_report(owner, message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the level in place has a running task, which the server pumps
|
||||
/// between its ticks (`pump_pregen`).
|
||||
pub(in crate::game) fn pregen_runs_here(&self) -> bool {
|
||||
self.cmds
|
||||
.pregen
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.dim == self.dim && !t.paused)
|
||||
}
|
||||
|
||||
/// The level a running task needs ticked, whoever is in it.
|
||||
pub(in crate::game) fn pregen_level(&self) -> Option<Dim> {
|
||||
self.cmds.pregen.as_ref().map(|t| t.dim)
|
||||
}
|
||||
|
||||
/// Keep the workers fed until `until`: collect what they finished and
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
|
||||
while self.pregen_runs_here() {
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
break;
|
||||
}
|
||||
self.streaming
|
||||
.wait((until - now).min(Duration::from_millis(5)));
|
||||
self.update_world();
|
||||
}
|
||||
}
|
||||
|
||||
/// The console and the player who started the task get the progress.
|
||||
fn pregen_report(&mut self, owner: Option<u32>, message: Text) {
|
||||
if self.options.dedicated.is_some() {
|
||||
super::super::dedicated::log("INFO", &message.plain());
|
||||
} else {
|
||||
log::info!("{}", message.plain());
|
||||
}
|
||||
if let Some(host) = &self.lan {
|
||||
if let Some(g) = owner.and_then(|c| host.hub.guest(c)) {
|
||||
g.send(&C::SystemChat(play::SystemChat {
|
||||
content: component_nbt(&message.component()),
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
} else if self.options.dedicated.is_none() {
|
||||
self.system_message(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ fn ticks_to_seconds(ticks: i64) -> String {
|
||||
impl Game<'_> {
|
||||
// ---- /forceload ----------------------------------------------------------
|
||||
|
||||
fn column_arg(&self, ctx: &Context, name: &str) -> R<[i32; 2]> {
|
||||
pub(super) fn column_arg(&self, ctx: &Context, name: &str) -> R<[i32; 2]> {
|
||||
let c = self
|
||||
.coords(ctx, name)
|
||||
.ok_or_else(|| err("argument.pos2d.incomplete", &[]))?;
|
||||
|
||||
@@ -241,6 +241,13 @@ impl Game<'_> {
|
||||
"pad" => Ok(Some(self.pad_command(&words)?)),
|
||||
"key" => {
|
||||
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
|
||||
// A server's dialog takes the keys, in configuration too.
|
||||
if self.dialog_open() {
|
||||
if arg(2)? != "up" {
|
||||
self.dialog_key(key, None, None)?;
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
|
||||
if self.sign_edit.is_some() {
|
||||
@@ -288,11 +295,12 @@ impl Game<'_> {
|
||||
self.debug_key(key, KeyAction::Release);
|
||||
}
|
||||
}
|
||||
// The zoom extra sees the key as the keyboard hook does.
|
||||
// Mods' keys (capability `input`) see the key as the keyboard
|
||||
// hook does.
|
||||
let action = arg(2)?;
|
||||
self.extras_key(physical, action != "up", action == "repeat");
|
||||
self.mod_key(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.extras_key(physical, false, false);
|
||||
self.mod_key(physical, false, false);
|
||||
}
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
|
||||
@@ -367,15 +375,19 @@ impl Game<'_> {
|
||||
match arg(2)? {
|
||||
"down" => {
|
||||
self.extras_mouse(button, true);
|
||||
self.mod_mouse_key(button, true);
|
||||
self.play_mouse(button, true)?
|
||||
}
|
||||
"up" => {
|
||||
self.extras_mouse(button, false);
|
||||
self.mod_mouse_key(button, false);
|
||||
self.play_mouse(button, false)?
|
||||
}
|
||||
"click" => {
|
||||
self.extras_mouse(button, true);
|
||||
self.mod_mouse_key(button, true);
|
||||
self.extras_mouse(button, false);
|
||||
self.mod_mouse_key(button, false);
|
||||
self.play_mouse(button, true)?;
|
||||
self.play_mouse(button, false)?;
|
||||
}
|
||||
@@ -533,13 +545,17 @@ impl Game<'_> {
|
||||
))
|
||||
}
|
||||
"type" => {
|
||||
self.require_world()?;
|
||||
let text = line
|
||||
.trim_start()
|
||||
.strip_prefix("type")
|
||||
.unwrap_or("")
|
||||
.strip_prefix(' ')
|
||||
.unwrap_or("");
|
||||
// A server's dialog, in configuration too.
|
||||
if self.dialog_type(text) {
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
if self.book.is_some() {
|
||||
self.book_type(text);
|
||||
return ok();
|
||||
|
||||
@@ -43,10 +43,6 @@ fn arrow_damage(arrow: &mut Projectile, fallback: &mut impl RandomSource) -> f32
|
||||
#[derive(Default)]
|
||||
pub(super) struct Creatures {
|
||||
pub projectiles: Vec<Projectile>,
|
||||
/// Player status effects applied by mobs: (effect, ticks left, amplifier).
|
||||
pub pearl_cooldown: u32,
|
||||
/// The wind charge's use cooldown (ticks).
|
||||
pub wind_cooldown: u32,
|
||||
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
|
||||
pub wind_bursts: Vec<super::wind::Burst>,
|
||||
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
|
||||
@@ -642,7 +638,6 @@ impl Game<'_> {
|
||||
self.tick_allays();
|
||||
self.tick_breeze_effects();
|
||||
self.tick_sniffer_effects();
|
||||
self.tick_player_effects();
|
||||
self.piglin_pickups();
|
||||
self.sulfur_cube_pickups();
|
||||
// Hurt and death sounds of damage dealt outside the mob ticks.
|
||||
@@ -1572,13 +1567,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.give_player_effect("minecraft:wither", 40, 0);
|
||||
}
|
||||
/// Burning applied to the player by mobs.
|
||||
fn tick_player_effects(&mut self) {
|
||||
self.creatures.wind_cooldown = self.creatures.wind_cooldown.saturating_sub(1);
|
||||
if self.creatures.pearl_cooldown > 0 {
|
||||
self.creatures.pearl_cooldown -= 1;
|
||||
}
|
||||
}
|
||||
pub(super) fn hurt_player_by(&mut self, damage_type: &str, amount: f32) {
|
||||
if self.creative {
|
||||
return;
|
||||
@@ -2879,14 +2867,14 @@ impl Game<'_> {
|
||||
let kind = match item {
|
||||
"minecraft:snowball" => ProjectileKind::Snowball,
|
||||
"minecraft:egg" | "minecraft:blue_egg" | "minecraft:brown_egg" => ProjectileKind::Egg,
|
||||
"minecraft:ender_pearl" => {
|
||||
if self.creatures.pearl_cooldown > 0 {
|
||||
return Ok(true);
|
||||
"minecraft:ender_pearl" => ProjectileKind::EnderPearl,
|
||||
"minecraft:wind_charge" => {
|
||||
let thrown = self.throw_wind_charge()?;
|
||||
if thrown {
|
||||
self.apply_use_cooldown(stack);
|
||||
}
|
||||
self.creatures.pearl_cooldown = 20;
|
||||
ProjectileKind::EnderPearl
|
||||
return Ok(thrown);
|
||||
}
|
||||
"minecraft:wind_charge" => return self.throw_wind_charge(),
|
||||
_ if item.ends_with("_spawn_egg") => return self.use_spawn_egg(stack),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
@@ -2911,6 +2899,9 @@ impl Game<'_> {
|
||||
);
|
||||
self.creatures.projectiles.push(proj);
|
||||
self.player_projectile_shot();
|
||||
// ItemStack.use's applyAfterUseComponentSideEffects: the pearl's
|
||||
// second is the `use_cooldown` component, as for any thrown item.
|
||||
self.apply_use_cooldown(stack);
|
||||
let sound = match kind {
|
||||
ProjectileKind::Snowball => "minecraft:entity.snowball.throw",
|
||||
ProjectileKind::Egg => "minecraft:entity.egg.throw",
|
||||
@@ -3031,6 +3022,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.mark_entity(entity_start, &mob.kind.id);
|
||||
let light = self.world.light(
|
||||
mob.position.x.floor() as i32,
|
||||
(mob.position.y + mob.eye_height()).floor() as i32,
|
||||
|
||||
@@ -415,6 +415,13 @@ impl Game<'_> {
|
||||
],
|
||||
);
|
||||
}
|
||||
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
|
||||
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
|
||||
debug_overlay::shader_pass_lines(&r.shader_pass_times())
|
||||
});
|
||||
if !pass_lines.is_empty() {
|
||||
d.group("shader pack", pass_lines);
|
||||
}
|
||||
// tps: DebugEntryTps for local and remote connections.
|
||||
if self.player.is_some() {
|
||||
let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64);
|
||||
|
||||
@@ -155,6 +155,14 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
|
||||
for w in &props.warnings {
|
||||
log("WARN", w);
|
||||
}
|
||||
// Horizon's own commands are the owner's choice, off as on vanilla.
|
||||
if props
|
||||
.raw
|
||||
.get(horizon_client::command::vanilla::PREGEN_PROPERTY)
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true"))
|
||||
{
|
||||
horizon_client::command::vanilla::set_horizon_commands(true);
|
||||
}
|
||||
props.store(&settings)?;
|
||||
let eula_file = PathBuf::from("eula.txt");
|
||||
let agreed = match eula::agreed(&eula_file) {
|
||||
@@ -667,7 +675,9 @@ impl Game<'_> {
|
||||
self.poll_server_control(&mut state);
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
if pause_after > 0 {
|
||||
if players == 0 {
|
||||
// A `/pregen` task keeps the server busy as a player would:
|
||||
// paused, nothing would post-process its chunks.
|
||||
if players == 0 && self.pregen_level().is_none() {
|
||||
state.empty_ticks += 1;
|
||||
} else {
|
||||
state.empty_ticks = 0;
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
//! A server's dialogs bound to the game (`ClientCommonPacketListenerImpl.
|
||||
//! showDialog` and `clearDialog`, `DialogScreen`, its `WarningScreen` and the
|
||||
//! `WaitingForResponseScreen`): the screen over whatever is open (the
|
||||
//! connecting screen in configuration), its inputs, and the actions its
|
||||
//! buttons send back.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
clipboard::EditShortcut,
|
||||
confirm_screen::Confirm,
|
||||
dialog::{AfterAction, Click, Control, Dialog, InputValue},
|
||||
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
|
||||
hud::{HudBatch, rgba},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::time::Instant;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// An open `DialogScreen`.
|
||||
struct Open {
|
||||
dialog: Dialog,
|
||||
values: Vec<InputValue>,
|
||||
/// The box of each text input.
|
||||
fields: Vec<Option<ui::EditBox>>,
|
||||
focus: Option<usize>,
|
||||
scroll: ui::Scroll,
|
||||
}
|
||||
|
||||
/// The dialog screens a server can open.
|
||||
#[derive(Default)]
|
||||
pub(super) struct DialogState {
|
||||
open: Option<Open>,
|
||||
/// `WaitingForResponseScreen`, since when.
|
||||
waiting: Option<Instant>,
|
||||
/// `DialogScreen.WarningScreen`, over the dialog.
|
||||
warning: bool,
|
||||
}
|
||||
|
||||
fn warning_texts() -> (Text, Text, [Text; 2]) {
|
||||
let t = |key: &str| Text::translate(key, &[]);
|
||||
(
|
||||
t("menu.custom_screen_info.title"),
|
||||
t("menu.custom_screen_info.contents"),
|
||||
[t("menu.disconnect"), t("gui.back")],
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether a dialog screen takes the input.
|
||||
pub(super) fn dialog_open(&self) -> bool {
|
||||
let d = &self.dialogs;
|
||||
d.open.is_some() || d.waiting.is_some() || d.warning
|
||||
}
|
||||
|
||||
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
|
||||
/// under the warning screen, it is what Back returns to.
|
||||
pub(super) fn show_dialog(&mut self, tag: &Tag) {
|
||||
let Some(dialog) = Dialog::parse(tag) else {
|
||||
log::warn!("Horizon: failed to show dialog for data {tag:?}");
|
||||
return;
|
||||
};
|
||||
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
|
||||
let fields = dialog
|
||||
.inputs
|
||||
.iter()
|
||||
.map(|i| match &i.control {
|
||||
Control::Text {
|
||||
width,
|
||||
initial,
|
||||
max_length,
|
||||
..
|
||||
} => {
|
||||
let mut edit = ui::EditBox::new(0.0, 0.0, *width as f32, 20.0);
|
||||
edit.max_length = (*max_length).max(1) as usize;
|
||||
edit.set_value(initial);
|
||||
edit.focused = false;
|
||||
Some(edit)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
self.dialogs.open = Some(Open {
|
||||
dialog,
|
||||
values,
|
||||
fields,
|
||||
focus: None,
|
||||
scroll: ui::Scroll::default(),
|
||||
});
|
||||
self.dialogs.waiting = None;
|
||||
self.capture(false);
|
||||
}
|
||||
|
||||
/// `clearDialog`: the dialog closes (under the warning screen, Back then
|
||||
/// returns to what was open before it).
|
||||
pub(super) fn clear_dialog(&mut self) {
|
||||
if self.dialogs.open.take().is_some() && !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Every dialog screen goes (a disconnect, or the level loading screen).
|
||||
pub(super) fn close_dialogs(&mut self) {
|
||||
self.dialogs = DialogState::default();
|
||||
}
|
||||
|
||||
fn dialog_closed(&mut self) {
|
||||
if self.player.is_some() && self.screen == super::Screen::Playing && self.title.is_none() {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// The input values as the controls hold them now.
|
||||
fn dialog_values(open: &mut Open) {
|
||||
for (value, field) in open.values.iter_mut().zip(&open.fields) {
|
||||
if let Some(f) = field {
|
||||
*value = InputValue::Text(f.value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
|
||||
/// `after_action` leaves.
|
||||
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
|
||||
match after {
|
||||
AfterAction::None => {}
|
||||
AfterAction::Close => self.dialogs.open = None,
|
||||
AfterAction::WaitForResponse => {
|
||||
self.dialogs.open = None;
|
||||
self.dialogs.waiting = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
match click {
|
||||
Some(Click::Custom { id, payload }) => {
|
||||
if let Some(r) = &self.remote {
|
||||
r.session.send_custom_click(&id, payload);
|
||||
}
|
||||
}
|
||||
Some(Click::RunCommand(command)) => {
|
||||
// In configuration there is nothing to run it (vanilla warns).
|
||||
if self.player.is_some() {
|
||||
let command = command.strip_prefix('/').unwrap_or(&command);
|
||||
self.send_chat(&format!("/{command}"));
|
||||
} else {
|
||||
log::warn!(
|
||||
"Horizon: commands are not supported in configuration, trying to run '{command}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Click::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
|
||||
Some(Click::CopyToClipboard(value)) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"),
|
||||
None => {}
|
||||
}
|
||||
if !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `show_dialog` click event's dialog: a registry id or the dialog itself.
|
||||
fn show_dialog_ref(&mut self, dialog: &Tag) {
|
||||
match dialog {
|
||||
Tag::String(id) => {
|
||||
let id = id.to_string_lossy();
|
||||
let name = if id.contains(':') {
|
||||
id.clone()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
let found = self.remote.as_ref().and_then(|r| {
|
||||
let entries = r.registries.registries.get("minecraft:dialog")?;
|
||||
entries.iter().find(|(n, _)| *n == name)?.1.clone()
|
||||
});
|
||||
match found {
|
||||
Some(tag) => self.show_dialog(&tag),
|
||||
None => log::warn!("Horizon: unknown dialog {id}"),
|
||||
}
|
||||
}
|
||||
tag => self.show_dialog(tag),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientboundShowDialogPacket` in play: a registry entry or the dialog.
|
||||
pub(super) fn show_play_dialog(
|
||||
&mut self,
|
||||
dialog: &horizon_protocol::packets::play::DialogHolder,
|
||||
) {
|
||||
use horizon_protocol::packets::play::DialogHolder;
|
||||
match dialog {
|
||||
DialogHolder::Direct(tag) => self.show_dialog(tag),
|
||||
DialogHolder::Registry(id) => {
|
||||
let tag = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.registries.data("minecraft:dialog", *id).cloned());
|
||||
match tag {
|
||||
Some(tag) => self.show_dialog(&tag),
|
||||
None => log::warn!("Horizon: unknown dialog {id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dialog_layout(&self) -> Option<Layout> {
|
||||
let open = self.dialogs.open.as_ref()?;
|
||||
Some(Layout::new(
|
||||
&open.dialog,
|
||||
&self.gui.atlas.font,
|
||||
self.gui_size(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn draw_dialog(&mut self, batch: &mut HudBatch) {
|
||||
if !self.dialog_open() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let cursor = self.cursor;
|
||||
let atlas = &self.gui.atlas;
|
||||
if self.player.is_none() {
|
||||
// In place of the connecting screen, on its background.
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
|
||||
}
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
|
||||
if self.dialogs.warning {
|
||||
let (title, message, [disconnect, back]) = warning_texts();
|
||||
Confirm::new(&atlas.font, (w, h), &title, &message).draw(
|
||||
batch,
|
||||
atlas,
|
||||
cursor,
|
||||
[&disconnect, &back],
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
// HeaderAndFooterLayout(33, 0) centred: the title, the button 33 below.
|
||||
let ticks = (since.elapsed().as_millis() / 50) as u32;
|
||||
let (label, visible, active) = waiting_button(ticks);
|
||||
let title = Text::translate("gui.waitingForResponse.title", &[]);
|
||||
let top = ((h - 53.0) / 2.0).trunc();
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&title,
|
||||
((w - tw) / 2.0).trunc(),
|
||||
top + 12.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let [ax, ay, aw, ah] = layout.area;
|
||||
open.scroll.clamp(ay, ay + ah, layout.content);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&open.dialog.title,
|
||||
layout.title.0,
|
||||
layout.title.1,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
let [wx, wy, ww, wh] = layout.warning;
|
||||
let warn_hover = ui::hovered(cursor, wx, wy, ww, wh);
|
||||
horizon_client::widgets::record("warning", layout.warning, true);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
WARNING_SPRITES[if warn_hover { 2 } else { 0 }],
|
||||
wx,
|
||||
wy,
|
||||
ww,
|
||||
wh,
|
||||
u32::MAX,
|
||||
);
|
||||
let buttons = open.dialog.buttons();
|
||||
let in_area = |y: f32| y >= ay && y < ay + ah;
|
||||
let mut tooltip = None;
|
||||
let mut start = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && start.is_none() {
|
||||
start = Some(batch.vertices.len());
|
||||
} else if !clipped && let Some(s) = start.take() {
|
||||
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
|
||||
}
|
||||
match part {
|
||||
Part::Line(text, x, y) => {
|
||||
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
|
||||
}
|
||||
Part::Field { input, rect } => {
|
||||
if let Some(Some(edit)) = open.fields.get_mut(input) {
|
||||
[edit.x, edit.y, edit.width, edit.height] = rect;
|
||||
edit.focused = open.focus == Some(input);
|
||||
edit.draw(batch, atlas, None);
|
||||
}
|
||||
}
|
||||
Part::Check { input, rect } => {
|
||||
let [x, y, cw, ch] = rect;
|
||||
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, cw, ch);
|
||||
let on = open.values.get(input) == Some(&InputValue::Bool(true));
|
||||
let sprite = match (on, hover) {
|
||||
(true, true) => "gui/sprites/widget/checkbox_selected_highlighted",
|
||||
(true, false) => "gui/sprites/widget/checkbox_selected",
|
||||
(false, true) => "gui/sprites/widget/checkbox_highlighted",
|
||||
(false, false) => "gui/sprites/widget/checkbox",
|
||||
};
|
||||
let Control::Boolean { label, .. } = &open.dialog.inputs[input].control else {
|
||||
continue;
|
||||
};
|
||||
horizon_client::widgets::record(&label.plain(), rect, true);
|
||||
batch.sprite_scaled(atlas, sprite, x, y, BOX, BOX, u32::MAX);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
label,
|
||||
x + BOX + 4.0,
|
||||
y + 4.0,
|
||||
rgba(224, 224, 224, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
Part::Button { index, rect } => {
|
||||
let [x, y, bw, bh] = rect;
|
||||
let hover =
|
||||
(!clipped || in_area(cursor.1)) && ui::hovered(cursor, x, y, bw, bh);
|
||||
let b = buttons[index];
|
||||
ui::button(batch, atlas, x, y, bw, bh, &b.label, true, hover);
|
||||
if hover && let Some(t) = &b.tooltip {
|
||||
tooltip = Some(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
|
||||
}
|
||||
if layout.content > ah {
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
ax + aw - 6.0,
|
||||
ay,
|
||||
ay + ah,
|
||||
layout.content,
|
||||
open.scroll.amount,
|
||||
);
|
||||
}
|
||||
if warn_hover {
|
||||
tooltip = Some(Text::translate("menu.custom_screen_info.tooltip", &[]));
|
||||
}
|
||||
if let Some(t) = tooltip {
|
||||
let lines: Vec<Text> = atlas
|
||||
.font
|
||||
.split_lines(&t, 170.0)
|
||||
.into_iter()
|
||||
.map(|l| l.0)
|
||||
.collect();
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// A left click on a dialog screen.
|
||||
pub(super) fn dialog_click(&mut self) -> Result<()> {
|
||||
let cursor = self.cursor;
|
||||
if self.dialogs.warning {
|
||||
let (title, message, _) = warning_texts();
|
||||
let answer = Confirm::new(&self.gui.atlas.font, self.gui_size(), &title, &message)
|
||||
.answer_at(cursor);
|
||||
match answer {
|
||||
Some(true) => {
|
||||
self.audio.play_button_click();
|
||||
let reason = Text::translate("menu.custom_screen_info.disconnect", &[]);
|
||||
return self.leave_server(Some(reason.plain()));
|
||||
}
|
||||
Some(false) => {
|
||||
self.audio.play_button_click();
|
||||
self.dialogs.warning = false;
|
||||
if !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
let (w, h) = self.gui_size();
|
||||
let active = waiting_button((since.elapsed().as_millis() / 50) as u32).2;
|
||||
let (bx, by) = (
|
||||
((w - 200.0) / 2.0).trunc(),
|
||||
((h - 53.0) / 2.0).trunc() + 33.0,
|
||||
);
|
||||
if active && ui::hovered(cursor, bx, by, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.dialogs.waiting = None;
|
||||
self.dialog_closed();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return Ok(());
|
||||
};
|
||||
let [wx, wy, ww, wh] = layout.warning;
|
||||
if ui::hovered(cursor, wx, wy, ww, wh) {
|
||||
self.audio.play_button_click();
|
||||
self.dialogs.warning = true;
|
||||
return Ok(());
|
||||
}
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
let mut action = None;
|
||||
open.focus = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && !(cursor.1 >= ay && cursor.1 < ay + ah) {
|
||||
continue;
|
||||
}
|
||||
match part {
|
||||
Part::Field {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
open.focus = Some(input);
|
||||
if let Some(Some(edit)) = open.fields.get_mut(input) {
|
||||
edit.focused = true;
|
||||
edit.click(&self.gui.atlas, cursor.0, false, false);
|
||||
}
|
||||
}
|
||||
Part::Check {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
if let Some(InputValue::Bool(b)) = open.values.get_mut(input) {
|
||||
*b = !*b;
|
||||
}
|
||||
action = Some(None);
|
||||
}
|
||||
Part::Button {
|
||||
index,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
action = Some(Some(index));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(action) = action else {
|
||||
return Ok(());
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
let Some(index) = action else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
Self::dialog_values(open);
|
||||
let d = &open.dialog;
|
||||
let click = d.buttons()[index]
|
||||
.action
|
||||
.as_ref()
|
||||
.and_then(|a| a.click(&d.inputs, &open.values));
|
||||
let after = d.after_action;
|
||||
self.run_dialog_action(click, after)
|
||||
}
|
||||
|
||||
/// The mouse wheel over a dialog: its body scrolls 10 a step.
|
||||
pub(super) fn dialog_scroll(&mut self, delta: f32) {
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
if let Some(open) = self.dialogs.open.as_mut() {
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
|
||||
}
|
||||
}
|
||||
|
||||
/// A key pressed on a dialog screen: Escape closes what allows it, Tab
|
||||
/// moves between the text inputs, the rest goes to the focused one
|
||||
/// (`shortcut`: the press as an edit shortcut; `text`: what it types).
|
||||
pub(super) fn dialog_key(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
shortcut: Option<EditShortcut>,
|
||||
text: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let escape = key == KeyCode::Escape;
|
||||
if self.dialogs.warning {
|
||||
if escape {
|
||||
self.dialogs.warning = false;
|
||||
if !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
// shouldCloseOnEsc: once Back works.
|
||||
if escape && waiting_button((since.elapsed().as_millis() / 50) as u32).2 {
|
||||
self.dialogs.waiting = None;
|
||||
self.dialog_closed();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let ctrl = self.modifiers.control_key() || self.modifiers.super_key();
|
||||
let shift = self.modifiers.shift_key();
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
if escape {
|
||||
if !open.dialog.can_close_with_escape {
|
||||
return Ok(());
|
||||
}
|
||||
// onClose: the cancel action, and the screen closes.
|
||||
Self::dialog_values(open);
|
||||
let d = &open.dialog;
|
||||
let click = d.on_cancel().and_then(|a| a.click(&d.inputs, &open.values));
|
||||
return self.run_dialog_action(click, AfterAction::Close);
|
||||
}
|
||||
if key == KeyCode::Tab {
|
||||
let texts: Vec<usize> = (0..open.fields.len())
|
||||
.filter(|&i| open.fields[i].is_some())
|
||||
.collect();
|
||||
if !texts.is_empty() {
|
||||
let at = open.focus.and_then(|f| texts.iter().position(|&i| i == f));
|
||||
let next = match at {
|
||||
None if shift => texts.len() - 1,
|
||||
None => 0,
|
||||
Some(p) if shift => (p + texts.len() - 1) % texts.len(),
|
||||
Some(p) => (p + 1) % texts.len(),
|
||||
};
|
||||
open.focus = Some(texts[next]);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(Some(edit)) = open.focus.and_then(|f| open.fields.get_mut(f)) else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(shortcut) = shortcut {
|
||||
edit.shortcut(shortcut, &mut self.clipboard);
|
||||
return Ok(());
|
||||
}
|
||||
if edit.key(key, ctrl, shift) {
|
||||
return Ok(());
|
||||
}
|
||||
let typed: String = text
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect();
|
||||
if !typed.is_empty() {
|
||||
edit.insert_text(&typed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `type TEXT` with a dialog open: into the focused text input, or the
|
||||
/// first one.
|
||||
pub(super) fn dialog_type(&mut self, text: &str) -> bool {
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let focus = open
|
||||
.focus
|
||||
.or_else(|| open.fields.iter().position(Option::is_some));
|
||||
let Some(Some(edit)) = focus.and_then(|f| open.fields.get_mut(f)) else {
|
||||
return false;
|
||||
};
|
||||
open.focus = focus;
|
||||
edit.insert_text(text);
|
||||
true
|
||||
}
|
||||
|
||||
/// What `state` reports: the open dialog and its values, or the waiting
|
||||
/// and warning screens.
|
||||
pub(super) fn dialog_status(&self) -> serde_json::Value {
|
||||
let d = &self.dialogs;
|
||||
let screen = if d.warning {
|
||||
Some("warning")
|
||||
} else if d.waiting.is_some() {
|
||||
Some("waiting_for_response")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let open = d.open.as_ref().map(|open| {
|
||||
let mut values = open.values.clone();
|
||||
for (value, field) in values.iter_mut().zip(&open.fields) {
|
||||
if let Some(f) = field {
|
||||
*value = InputValue::Text(f.value.clone());
|
||||
}
|
||||
}
|
||||
open.dialog.json(&values)
|
||||
});
|
||||
serde_json::json!({ "open": open, "screen": screen })
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,10 @@ enum Target {
|
||||
impl Game<'_> {
|
||||
/// Run every case of `spec` ("CASES,OUTPUT") and write the results.
|
||||
pub(super) fn dispense_fixture(&mut self, spec: &str) -> Result<()> {
|
||||
assert!(
|
||||
!self.options.execution_role.is_client(),
|
||||
"fixture requires a server Game"
|
||||
);
|
||||
let (input, output) = spec
|
||||
.split_once(',')
|
||||
.ok_or("HORIZON_DISPENSE_FIXTURE=CASES,OUTPUT")?;
|
||||
|
||||
@@ -1971,9 +1971,10 @@ impl Game<'_> {
|
||||
self.spawn_orbs([p.x, p.y, p.z], (xp as f32 * 0.08) as u32);
|
||||
}
|
||||
if t == 1 {
|
||||
// Level event 1028: the death roar, heard everywhere.
|
||||
self.audio
|
||||
.play("minecraft:entity.ender_dragon.death", None, 5.0, 1.0);
|
||||
// EnderDragon.tickDeath: global level event 1028, the death
|
||||
// roar, heard everywhere.
|
||||
let at = [p.x, p.y, p.z].map(|v| v.floor() as i32);
|
||||
self.global_level_event(1028, at, 0);
|
||||
}
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
d.position.y += 0.1;
|
||||
|
||||
@@ -316,9 +316,10 @@ impl Game<'_> {
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Global LevelEvent 1038: END_PORTAL_SPAWN.
|
||||
self.audio
|
||||
.play("minecraft:block.end_portal.spawn", None, 1.0, 1.0);
|
||||
// EnderEyeItem.useOn: global level event 1038 (END_PORTAL_SPAWN),
|
||||
// heard by every player (vanilla places it at the frame's corner
|
||||
// + (1, 0, 1); only its direction matters).
|
||||
self.global_level_event(1038, c, 0);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ fn opaque(facts: &StateFacts) -> bool {
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn prepare_entity_culling(&mut self, camera: glam::DVec3) {
|
||||
// Spans tell the pack's passes which entities are hidden; without them, nothing changes.
|
||||
self.pack_shadow_casters = match (&self.renderer, &self.pack_ids) {
|
||||
(Some(r), Some(_)) => r.pack_shadow_casters(),
|
||||
_ => (false, false),
|
||||
};
|
||||
let at = camera.to_array().map(|v| v.floor() as i32);
|
||||
self.entity_culler = (self.settings.horizon.entity_culling
|
||||
// Packs can replace an opaque block's model or texture.
|
||||
@@ -28,24 +33,29 @@ impl Game<'_> {
|
||||
/// Use complete generated geometry so equipment, animation and custom
|
||||
/// models cannot stick out of an assumed collision-sized culling box.
|
||||
pub(super) fn cull_entity_vertices(&self, vertices: &mut Vec<Vertex>, start: usize) {
|
||||
if let Some(culler) = &self.entity_culler {
|
||||
culler.cull_vertices(vertices, start, |pos| {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !opaque(self.res.facts().get(state)) {
|
||||
return false;
|
||||
}
|
||||
let Some(y) = pos[1].checked_sub(MIN_Y) else {
|
||||
return false;
|
||||
};
|
||||
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
|
||||
// Newly edited or not-yet-uploaded terrain is not an occluder.
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.has_section(section))
|
||||
&& !self.world.dirty_sections.contains(§ion)
|
||||
&& !self.streaming.is_meshing(section)
|
||||
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
});
|
||||
let Some(culler) = &self.entity_culler else {
|
||||
return;
|
||||
};
|
||||
let hidden = culler.hidden(vertices, start, |pos| {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !opaque(self.res.facts().get(state)) {
|
||||
return false;
|
||||
}
|
||||
let Some(y) = pos[1].checked_sub(MIN_Y) else {
|
||||
return false;
|
||||
};
|
||||
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
|
||||
// Newly edited or not-yet-uploaded terrain is not an occluder.
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.has_section(section))
|
||||
&& !self.world.dirty_sections.contains(§ion)
|
||||
&& !self.streaming.is_meshing(section)
|
||||
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
});
|
||||
// A shader pack's shadow pass still draws what the camera cannot see.
|
||||
if hidden && !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len())) {
|
||||
vertices.truncate(start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
//! Horizon's extras in the game (`docs/extras.md`): the zoom key and wheel,
|
||||
//! clicks for the keystroke overlay, frame times for the FPS counter, the
|
||||
//! minimap's tiles and image, and their HUD. Each hook does nothing while its
|
||||
//! extra is off.
|
||||
//! Horizon's extras in the game (`docs/extras.md`): the minimap's tiles and
|
||||
//! image, and its HUD; a mod's zoom (`render.set-zoom`) on the camera; clicks
|
||||
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
|
||||
//! Each hook does nothing while its extra is off and no mod runs.
|
||||
use super::{Dim, Game, Screen};
|
||||
use horizon_client::{
|
||||
extras::{Corner, Cps, FpsCounter, Zoom, fov_target, turn_scale},
|
||||
extras_hud::{self, Corners, Keystrokes},
|
||||
extras::{Corner, Cps, FpsCounter, fov_target},
|
||||
extras_hud::{self, Corners},
|
||||
hud::{HudBatch, MINIMAP_SPRITE},
|
||||
keybinds,
|
||||
minimap::{self, Layer, Minimap, View},
|
||||
multiplayer,
|
||||
reconnect::{Retry, Tracker},
|
||||
title::Mode,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
use winit::event::MouseButton;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ExtrasState {
|
||||
zoom: Zoom,
|
||||
fps: FpsCounter,
|
||||
/// Left and right mouse buttons: held, and their clicks.
|
||||
mouse: [bool; 2],
|
||||
@@ -36,41 +34,16 @@ impl Game<'_> {
|
||||
fn extras_now(&self) -> f64 {
|
||||
self.started.elapsed().as_secs_f64()
|
||||
}
|
||||
fn zoom_key(&self) -> Option<KeyCode> {
|
||||
let e = &self.settings.extras;
|
||||
e.zoom.then(|| keybinds::key_code(&e.zoom_key)).flatten()
|
||||
}
|
||||
/// A key press or release. The zoom key zooms only while playing with
|
||||
/// no text open; the key itself always goes on to the screen and the key
|
||||
/// binds, as a vanilla key mapping's would.
|
||||
pub(super) fn extras_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
|
||||
if self.zoom_key() != Some(key) {
|
||||
return;
|
||||
}
|
||||
let playing = self.screen == Screen::Playing
|
||||
&& self.title.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.book.is_none();
|
||||
let level = self.settings.extras.zoom_level;
|
||||
self.extras.zoom.key(pressed, repeat, playing, level);
|
||||
}
|
||||
/// Wheel steps while playing; true when zooming took them from the hotbar.
|
||||
pub(super) fn extras_scroll(&mut self, steps: f32) -> bool {
|
||||
self.settings.extras.zoom && self.extras.zoom.scroll(steps)
|
||||
}
|
||||
/// Mouse buttons while playing, for zoom and the keystroke overlay.
|
||||
/// Mouse buttons while playing, for mods' clicks.
|
||||
pub(super) fn extras_mouse(&mut self, button: MouseButton, pressed: bool) {
|
||||
let e = &self.settings.extras;
|
||||
if e.zoom && e.zoom_key == keybinds::mouse_name(button) {
|
||||
self.extras.zoom.key(pressed, false, true, e.zoom_level);
|
||||
}
|
||||
let i = match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
_ => return,
|
||||
};
|
||||
self.extras.mouse[i] = pressed;
|
||||
if pressed && self.settings.extras.keystrokes {
|
||||
// For mods (`input.clicks-per-second`).
|
||||
if pressed && self.mods.running() {
|
||||
let now = self.extras_now();
|
||||
self.extras.cps[i].click(now);
|
||||
}
|
||||
@@ -80,20 +53,32 @@ impl Game<'_> {
|
||||
// Under agent control the window may lack focus without pausing.
|
||||
let focused = self.focused || self.control.is_some();
|
||||
let playing = self.screen == Screen::Playing && self.title.is_none() && focused;
|
||||
if !playing || !self.settings.extras.zoom {
|
||||
self.extras.zoom.release();
|
||||
}
|
||||
if !playing {
|
||||
self.extras.mouse = [false; 2];
|
||||
}
|
||||
self.update_minimap();
|
||||
if self.settings.extras.fps {
|
||||
// For mods (`render.frame-stats`).
|
||||
if self.mods.running() {
|
||||
let now = self.extras_now();
|
||||
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
|
||||
} else {
|
||||
self.extras.fps.clear();
|
||||
}
|
||||
}
|
||||
/// The frame counter for mods: fps, min, 1% low.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn frame_reading(&self) -> (u32, u32, u32) {
|
||||
let r = self.extras.fps.reading();
|
||||
(r.fps, r.min, r.low)
|
||||
}
|
||||
/// Attack and use clicks in the last second, and whether each is held.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn mouse_reading(&mut self) -> ((u32, u32), [bool; 2]) {
|
||||
let now = self.extras_now();
|
||||
let [l, r] = &mut self.extras.cps;
|
||||
let cps = (l.per_second(now) as u32, r.per_second(now) as u32);
|
||||
(cps, self.extras.mouse)
|
||||
}
|
||||
/// Auto-reconnect: a connection to `address` as `name` starts.
|
||||
pub(super) fn extras_connecting(&mut self, address: &str, name: &str) {
|
||||
self.extras.reconnect.connecting(address, name);
|
||||
@@ -143,25 +128,44 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The minimap's view of the player, if it shows.
|
||||
fn minimap_view(&self) -> Option<View> {
|
||||
let e = &self.settings.extras;
|
||||
/// The minimap that shows, the extra's or else a mod's
|
||||
/// (`render.set-minimap`): its corner, its side in GUI pixels and its
|
||||
/// view of the player.
|
||||
fn minimap_shown(&self) -> Option<(Corner, f32, View)> {
|
||||
let p = self.player.as_ref()?;
|
||||
let shown = e.minimap && self.title.is_none() && !self.ui.hide_gui;
|
||||
shown.then_some(View {
|
||||
if self.title.is_some() || self.ui.hide_gui {
|
||||
return None;
|
||||
}
|
||||
let e = &self.settings.extras;
|
||||
let (corner, size, rotate, blocks_per_pixel, round) = if e.minimap {
|
||||
let size = f32::from(e.minimap_size);
|
||||
(
|
||||
e.minimap_corner,
|
||||
size,
|
||||
e.minimap_rotate,
|
||||
e.minimap_zoom,
|
||||
e.minimap_round,
|
||||
)
|
||||
} else {
|
||||
let m = self.mod_minimap()?;
|
||||
(m.corner, m.size, m.rotate, m.blocks_per_pixel, m.round)
|
||||
};
|
||||
let view = View {
|
||||
x: p.position.x,
|
||||
z: p.position.z,
|
||||
yaw: self.yaw,
|
||||
rotate: e.minimap_rotate,
|
||||
blocks_per_pixel: e.minimap_zoom,
|
||||
round: e.minimap_round,
|
||||
})
|
||||
rotate,
|
||||
blocks_per_pixel,
|
||||
round,
|
||||
};
|
||||
Some((corner, size, view))
|
||||
}
|
||||
/// Refresh the minimap's tiles within the frame budget, recompose its
|
||||
/// image when the view or a tile changed, and queue the upload.
|
||||
fn update_minimap(&mut self) {
|
||||
let Some(view) = self.minimap_view() else {
|
||||
if !self.settings.extras.minimap || self.player.is_none() {
|
||||
let Some((_, _, view)) = self.minimap_shown() else {
|
||||
let wanted = self.settings.extras.minimap || self.mod_minimap().is_some();
|
||||
if !wanted || self.player.is_none() {
|
||||
self.extras.minimap.clear();
|
||||
self.extras.minimap_dim = None;
|
||||
}
|
||||
@@ -209,45 +213,43 @@ impl Game<'_> {
|
||||
rgba: self.extras.minimap.image.clone(),
|
||||
});
|
||||
}
|
||||
/// The zoom in effect (1 unless the key is held).
|
||||
fn zoom_factor(&self) -> f32 {
|
||||
self.extras.zoom.factor(self.scoping())
|
||||
/// A mod's zoom in effect (`render.set-zoom`): (factor, smooth, turning
|
||||
/// scale); none is 1.
|
||||
fn zoom_now(&self) -> (f32, bool, f64) {
|
||||
self.mod_zoom().map_or((1.0, true, 1.0), |z| {
|
||||
(z.factor, z.smooth, f64::from(z.turn))
|
||||
})
|
||||
}
|
||||
/// The field-of-view modifier target, divided by a smooth zoom.
|
||||
pub(super) fn zoomed_fov_target(&self, vanilla: f32) -> f32 {
|
||||
if self.settings.extras.zoom_smooth {
|
||||
fov_target(vanilla, self.zoom_factor())
|
||||
} else {
|
||||
vanilla
|
||||
match self.zoom_now() {
|
||||
(factor, true, _) => fov_target(vanilla, factor),
|
||||
_ => vanilla,
|
||||
}
|
||||
}
|
||||
/// The field of view in degrees, divided by an instant zoom.
|
||||
pub(super) fn zoomed_fov_degrees(&self, fov: f32) -> f32 {
|
||||
if self.settings.extras.zoom_smooth {
|
||||
fov
|
||||
} else {
|
||||
fov / self.zoom_factor()
|
||||
match self.zoom_now() {
|
||||
(factor, false, _) => fov / factor,
|
||||
_ => fov,
|
||||
}
|
||||
}
|
||||
/// Mouse turning while zoomed.
|
||||
pub(super) fn zoom_turn_scale(&self) -> f64 {
|
||||
turn_scale(self.zoom_factor(), self.settings.extras.zoom_sensitivity)
|
||||
self.zoom_now().2
|
||||
}
|
||||
/// The FPS counter and keystrokes, in the HUD layer (hidden with F1 by
|
||||
/// the caller). The top left corner gives way to the debug screen.
|
||||
/// The minimap and mods' corner panels, in the HUD layer (hidden with F1
|
||||
/// by the caller). The top left corner gives way to the debug screen.
|
||||
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
|
||||
let e = &self.settings.extras;
|
||||
let mod_panels = self.mods.has_panels();
|
||||
if !e.fps && !e.keystrokes && !e.minimap && !mod_panels {
|
||||
let map = self.minimap_shown();
|
||||
if map.is_none() && !mod_panels {
|
||||
return;
|
||||
}
|
||||
let (fps_on, fps_corner, lows) = (e.fps, e.fps_corner, e.fps_lows);
|
||||
let (keys_on, keys_corner, cps_on) = (e.keystrokes, e.keystrokes_corner, e.keystrokes_cps);
|
||||
let (map_corner, map_size) = (e.minimap_corner, f32::from(e.minimap_size));
|
||||
let visible = |corner| !(self.debug && corner == Corner::TopLeft);
|
||||
let mut corners = Corners::new(batch.width, batch.height, self.effect_rows_height());
|
||||
let atlas = &self.gui.atlas;
|
||||
if let Some(view) = self.minimap_view()
|
||||
if let Some((map_corner, map_size, view)) = map
|
||||
&& visible(map_corner)
|
||||
&& !self.extras.minimap.image.is_empty()
|
||||
&& let Some(image) = atlas.sprites.get(MINIMAP_SPRITE).copied()
|
||||
@@ -266,32 +268,6 @@ impl Game<'_> {
|
||||
};
|
||||
extras_hud::draw_minimap(batch, atlas, &mut corners, &look);
|
||||
}
|
||||
if fps_on && visible(fps_corner) {
|
||||
let reading = self.extras.fps.reading();
|
||||
extras_hud::draw_fps(batch, atlas, &mut corners, fps_corner, reading, lows);
|
||||
}
|
||||
if keys_on && visible(keys_corner) {
|
||||
let now = self.extras_now();
|
||||
let cps = cps_on.then(|| {
|
||||
let [l, r] = &mut self.extras.cps;
|
||||
(l.per_second(now), r.per_second(now))
|
||||
});
|
||||
let held = |k| self.keys.contains(&k);
|
||||
let label = |name| keybinds::display_name(self.settings.keys.get(name)).plain();
|
||||
let k = Keystrokes {
|
||||
moves: [
|
||||
(held(KeyCode::KeyW), label("key.forward")),
|
||||
(held(KeyCode::KeyA), label("key.left")),
|
||||
(held(KeyCode::KeyS), label("key.back")),
|
||||
(held(KeyCode::KeyD), label("key.right")),
|
||||
],
|
||||
jump: held(KeyCode::Space),
|
||||
attack: self.extras.mouse[0],
|
||||
use_: self.extras.mouse[1],
|
||||
cps,
|
||||
};
|
||||
extras_hud::draw_keystrokes(batch, &self.gui.atlas, &mut corners, keys_corner, &k);
|
||||
}
|
||||
if mod_panels {
|
||||
self.draw_mod_panels(batch, &mut corners);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ pub(super) struct RulesScreen {
|
||||
initial: GameRules,
|
||||
/// IntegerRuleEntry's boxes, by rule.
|
||||
numbers: Vec<(&'static str, EditBox)>,
|
||||
scroll: f32,
|
||||
/// RuleList's scrolling: the wheel and the held scrollbar.
|
||||
scroll: ui::Scroll,
|
||||
/// ConfirmScreen "Game Rule Changes" in its place.
|
||||
confirm: bool,
|
||||
}
|
||||
@@ -120,7 +121,7 @@ impl RulesScreen {
|
||||
values: rules.clone(),
|
||||
initial: rules.clone(),
|
||||
numbers,
|
||||
scroll: 0.0,
|
||||
scroll: ui::Scroll::default(),
|
||||
confirm: false,
|
||||
}
|
||||
}
|
||||
@@ -156,12 +157,17 @@ impl RulesScreen {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn max_scroll(rows: usize, h: f32) -> f32 {
|
||||
(ROW * rows as f32 + 4.0 - (h - HEADER - FOOTER)).max(0.0)
|
||||
}
|
||||
/// Entry `i`'s top: 2 under the list's top, less the scroll.
|
||||
fn row_top(&self, i: usize) -> f32 {
|
||||
HEADER + 2.0 - self.scroll.trunc() + ROW * i as f32
|
||||
HEADER + 2.0 - self.scroll.amount.trunc() + ROW * i as f32
|
||||
}
|
||||
/// The list's scrollbar column (AbstractSelectionList: 6 pixels right of
|
||||
/// the entries) and the pixels it scrolls.
|
||||
fn bar(w: f32) -> f32 {
|
||||
row_left(w) + ROW_WIDTH + 6.0 + 2.0
|
||||
}
|
||||
fn content(&self) -> f32 {
|
||||
ROW * self.rows().len() as f32 + 4.0
|
||||
}
|
||||
/// getEntryAtPosition: the row under the pointer, inside the list.
|
||||
fn hovered(&self, rows: usize, (x, y): (f32, f32), (w, h): (f32, f32)) -> Option<usize> {
|
||||
@@ -212,10 +218,28 @@ impl RulesScreen {
|
||||
/// The mouse wheel (AbstractSelectionList: half an entry a step).
|
||||
pub(super) fn wheel(&mut self, delta: f32, h: f32) {
|
||||
if !self.confirm {
|
||||
let max = Self::max_scroll(self.rows().len(), h);
|
||||
self.scroll = (self.scroll - delta * ROW / 2.0).clamp(0.0, max);
|
||||
let content = self.content();
|
||||
self.scroll
|
||||
.wheel(delta, ROW / 2.0, HEADER, h - FOOTER, content);
|
||||
}
|
||||
}
|
||||
/// A press on the scrollbar holds it (AbstractSelectionList.updateScrolling).
|
||||
pub(super) fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
|
||||
let content = self.content();
|
||||
self.scroll
|
||||
.press(cursor, Self::bar(w), HEADER, h - FOOTER, content)
|
||||
}
|
||||
/// The pointer moved while the scrollbar is held.
|
||||
pub(super) fn drag(&mut self, y: f32, h: f32) {
|
||||
if self.scroll.held() {
|
||||
let content = self.content();
|
||||
self.scroll.drag(y, HEADER, h - FOOTER, content);
|
||||
}
|
||||
}
|
||||
/// The button came up: the scrollbar lets go.
|
||||
pub(super) fn release(&mut self) {
|
||||
self.scroll.release();
|
||||
}
|
||||
fn focused(&mut self) -> Option<(Option<&'static str>, &mut EditBox)> {
|
||||
if self.search.focused {
|
||||
return Some((None, &mut self.search));
|
||||
@@ -229,7 +253,7 @@ impl RulesScreen {
|
||||
/// number sets its rule when valid (IntegerRuleEntry's responder).
|
||||
fn edited(&mut self, rule: Option<&'static str>) {
|
||||
let Some(name) = rule else {
|
||||
self.scroll = 0.0;
|
||||
self.scroll.amount = 0.0;
|
||||
return;
|
||||
};
|
||||
let Some(r) = gamerules::rule(name) else {
|
||||
@@ -286,6 +310,11 @@ impl RulesScreen {
|
||||
if on(cx, cy, 150.0, 20.0) {
|
||||
return self.close();
|
||||
}
|
||||
// A press on the scrollbar holds it and clicks nothing else.
|
||||
if self.press_scrollbar(cursor, size) {
|
||||
self.unfocus();
|
||||
return None;
|
||||
}
|
||||
// A click moves the focus to the widget under it.
|
||||
let (sx, sy) = search_at(w);
|
||||
if on(sx, sy, SEARCH_WIDTH, 15.0) {
|
||||
@@ -360,7 +389,8 @@ impl RulesScreen {
|
||||
return;
|
||||
}
|
||||
let rows = self.rows();
|
||||
self.scroll = self.scroll.clamp(0.0, Self::max_scroll(rows.len(), h));
|
||||
let content = ROW * rows.len() as f32 + 4.0;
|
||||
self.scroll.clamp(HEADER, h - FOOTER, content);
|
||||
// RuleList: the in-world list background, its entries clipped to it,
|
||||
// then the separators and scrollbar.
|
||||
ui::list_background(batch, atlas, HEADER, h - FOOTER, true);
|
||||
@@ -421,16 +451,14 @@ impl RulesScreen {
|
||||
}
|
||||
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
|
||||
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
|
||||
let content = ROW * rows.len() as f32 + 4.0;
|
||||
let bar_x = left + ROW_WIDTH + 6.0 + 2.0;
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
bar_x,
|
||||
Self::bar(w),
|
||||
HEADER,
|
||||
h - FOOTER,
|
||||
content,
|
||||
self.scroll.trunc(),
|
||||
self.scroll.amount.trunc(),
|
||||
);
|
||||
// The header: the title centred over the search box.
|
||||
let title = Text::translate("editGamerule.title", &[]);
|
||||
@@ -536,6 +564,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// AbstractScrollArea: a press on the bar holds it, a drag moves the
|
||||
/// list (to the ends past them), a release lets go.
|
||||
#[test]
|
||||
fn the_scroll_bar_can_be_held_dragged_and_let_go() {
|
||||
let mut s = RulesScreen::new(&GameRules::default());
|
||||
let (w, h) = (427.0, 240.0);
|
||||
let (top, bottom, content) = (HEADER, h - FOOTER, s.content());
|
||||
assert!(
|
||||
content > bottom - top,
|
||||
"the rules overflow a 240-high screen"
|
||||
);
|
||||
let bar = RulesScreen::bar(w);
|
||||
assert!(!s.press_scrollbar((bar - 4.0, top + 5.0), (w, h)));
|
||||
assert!(s.press_scrollbar((bar + 3.0, top + 5.0), (w, h)));
|
||||
s.drag(top + 40.0, h);
|
||||
assert!(s.scroll.amount > 0.0);
|
||||
s.drag(bottom + 10.0, h);
|
||||
assert_eq!(s.scroll.amount, content - (bottom - top));
|
||||
s.release();
|
||||
s.drag(top, h);
|
||||
assert_eq!(s.scroll.amount, content - (bottom - top));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_change_rules_and_bad_numbers_hold_done() {
|
||||
let mut s = RulesScreen::new(&GameRules::default());
|
||||
|
||||
@@ -143,7 +143,7 @@ pub(super) struct Serving {
|
||||
flags: Arc<Flags>,
|
||||
tasks: Receiver<Task>,
|
||||
/// The window's profile.
|
||||
owner: u128,
|
||||
pub(super) owner: u128,
|
||||
/// Open to LAN or closed from inside a player's command, while the hub
|
||||
/// is out of its place: applied after the tick.
|
||||
pending: Option<Publish>,
|
||||
@@ -357,6 +357,16 @@ impl Game<'_> {
|
||||
});
|
||||
let _ = ready.send(Ok((pipe, self.level.name.clone())));
|
||||
self.settle_spawn()?;
|
||||
if let (Some(script), Some(output)) = (
|
||||
std::env::var_os("HORIZON_COMMAND_SCRIPT"),
|
||||
std::env::var_os("HORIZON_COMMAND_OUTPUT"),
|
||||
) {
|
||||
// The script owns the clock: connection setup and terrain loading
|
||||
// cannot add wall-clock-dependent world ticks to oracle output.
|
||||
let result = self.run_command_harness(script.as_ref(), output.as_ref());
|
||||
let stopped = self.stop_integrated();
|
||||
return result.and(stopped);
|
||||
}
|
||||
let mut next = Instant::now();
|
||||
while !flags.stop.load(Ordering::Relaxed) {
|
||||
self.run_tasks();
|
||||
@@ -692,6 +702,48 @@ fn serve(
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
) -> Result<()> {
|
||||
let mut server = server_game(res, pack, options, settings)?;
|
||||
server.run_integrated(launch, flags, tasks, ready)
|
||||
}
|
||||
|
||||
/// Component oracles supply their own synthetic levels and entity phases.
|
||||
/// They still execute on a server Game, never the client's world.
|
||||
pub(super) enum ComponentHarness {
|
||||
Dispenser(String),
|
||||
Sculk(String),
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn run_component_harness(&self, fixture: ComponentHarness) -> Result<()> {
|
||||
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
|
||||
let mut options = self.options.clone();
|
||||
options.execution_role = ExecutionRole::IntegratedServer;
|
||||
options.dedicated = Some(Vec::new());
|
||||
options.server_thread = false;
|
||||
let result = std::thread::Builder::new()
|
||||
.name("Server thread".into())
|
||||
.spawn(move || {
|
||||
let run = || -> Result<()> {
|
||||
let mut server = server_game(&res, pack, options, settings)?;
|
||||
match fixture {
|
||||
ComponentHarness::Dispenser(spec) => server.dispense_fixture(&spec),
|
||||
ComponentHarness::Sculk(spec) => server.sculk_fixture(&spec),
|
||||
}
|
||||
};
|
||||
run().map_err(|e| e.to_string())
|
||||
})?
|
||||
.join()
|
||||
.map_err(|_| "the component harness server panicked")?;
|
||||
result.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
fn server_game(
|
||||
res: &Arc<Resources>,
|
||||
pack: PackResources,
|
||||
options: Options,
|
||||
settings: Settings,
|
||||
) -> Result<Game<'_>> {
|
||||
// It draws nothing: only the font, which books and chat measure with.
|
||||
let atlas = HudAtlas::fonts(&options.resources)?;
|
||||
let gui = UiResources {
|
||||
@@ -702,8 +754,7 @@ fn serve(
|
||||
}),
|
||||
};
|
||||
let audio = Audio::silent(&options.resources);
|
||||
let mut server = Game::new(res, pack, gui, options, settings, audio, None)?;
|
||||
server.run_integrated(launch, flags, tasks, ready)
|
||||
Game::new(res, pack, gui, options, settings, audio, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1136,9 +1136,9 @@ impl Game<'_> {
|
||||
for e in events {
|
||||
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, nor bone meal's growth (BoneMealItem.useOn's
|
||||
// levelEvent names no player).
|
||||
let except = if matches!(*id, 2015 | 1505) {
|
||||
// particles, bone meal's growth, or anvil result sounds
|
||||
// (these levelEvent calls name no excluded player).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
@@ -1177,6 +1177,9 @@ impl Game<'_> {
|
||||
for (id, event, at) in std::mem::take(&mut self.lan_entity_events) {
|
||||
host.hub.entity_event(id, event, at);
|
||||
}
|
||||
for (kind, pos, data) in std::mem::take(&mut self.lan_global_events) {
|
||||
host.hub.global_level_event(kind, pos, data);
|
||||
}
|
||||
self.lan_broadcast_effects(host);
|
||||
if host.hub.guests.is_empty() {
|
||||
host.mob_state.clear();
|
||||
|
||||
@@ -38,6 +38,11 @@ pub(super) struct GuestPlay {
|
||||
use_delay: u32,
|
||||
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
|
||||
pub(super) shield_cooldown: u32,
|
||||
/// `ServerGamePacketListenerImpl.dropSpamThrottler` (`TickThrottler(20, 1480)`): 20 for each
|
||||
/// item a creative player drops from the creative screen, one less each tick.
|
||||
creative_drops: u32,
|
||||
/// The guest's `Player.getCooldowns()` (`Game::cooldowns` for its turn).
|
||||
pub(super) cooldowns: horizon_client::cooldowns::Cooldowns,
|
||||
attack_ticks: u32,
|
||||
/// The main hand's item at the guest's last tick (`Game::attack_item`).
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
@@ -84,6 +89,7 @@ pub(super) struct GuestPlay {
|
||||
equipped: Option<Vec<Option<ItemStack>>>,
|
||||
/// The guest's cast fishing hook (`Player.fishing`).
|
||||
pub(super) hook: Option<super::fishing::Hook>,
|
||||
sculk: super::sculk::PlayerEvents,
|
||||
}
|
||||
|
||||
/// A guest's command waiting for the hub (`LanHost::commands`).
|
||||
@@ -125,6 +131,8 @@ pub(super) struct GuestSwap {
|
||||
using: Option<(usize, u32)>,
|
||||
use_delay: u32,
|
||||
pub(super) shield_cooldown: u32,
|
||||
/// The guest's item cooldowns (`Game::cooldowns` for its turn).
|
||||
cooldowns: horizon_client::cooldowns::Cooldowns,
|
||||
attack_ticks: u32,
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
swing: horizon_client::first_person::SwingState,
|
||||
@@ -252,6 +260,7 @@ impl Game<'_> {
|
||||
if fresh {
|
||||
// A new body (joined, respawned, changed level): nothing on it yet.
|
||||
play.equipped = None;
|
||||
play.sculk = Default::default();
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
// The guest's body.
|
||||
@@ -259,6 +268,7 @@ impl Game<'_> {
|
||||
host.play.insert(conn, play);
|
||||
return None;
|
||||
};
|
||||
self.sculk.swap_player(&mut play.sculk);
|
||||
// The guest's own CombatTracker: damage to them is not the host's.
|
||||
// A new body starts with no record of the last one.
|
||||
let guest_combat = if fresh {
|
||||
@@ -328,6 +338,7 @@ impl Game<'_> {
|
||||
let using = std::mem::replace(&mut self.using, play.using.take());
|
||||
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
|
||||
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
|
||||
let cooldowns = std::mem::replace(&mut self.cooldowns, std::mem::take(&mut play.cooldowns));
|
||||
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
|
||||
let attack_item = std::mem::replace(&mut self.attack_item, play.attack_item.take());
|
||||
let swing = std::mem::replace(&mut self.swing, play.swing);
|
||||
@@ -412,6 +423,7 @@ impl Game<'_> {
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -469,6 +481,7 @@ impl Game<'_> {
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -492,6 +505,7 @@ impl Game<'_> {
|
||||
projectiles,
|
||||
modifiers,
|
||||
} = swap;
|
||||
self.sculk.swap_player(&mut play.sculk);
|
||||
// The guest's damage record back into their slot, the host's in place.
|
||||
let guest_combat = std::mem::replace(&mut self.combat, combat);
|
||||
self.guest_combat.insert(conn, guest_combat);
|
||||
@@ -508,6 +522,16 @@ impl Game<'_> {
|
||||
for packet in std::mem::replace(&mut self.guest_packets, packets) {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
// ServerItemCooldowns.onCooldownStarted: the guest's own cooldown.
|
||||
for (group, duration) in std::mem::take(&mut self.guest_cooldowns) {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration,
|
||||
}),
|
||||
);
|
||||
}
|
||||
(self.lan_acting, self.lan_acting_name, self.lan_acting_uuid) = acting;
|
||||
play.death_cause = std::mem::replace(&mut self.ui.death_cause, death_cause);
|
||||
// An ender chest the guest opened: the menu shows their inventory
|
||||
@@ -537,6 +561,7 @@ impl Game<'_> {
|
||||
play.using = std::mem::replace(&mut self.using, using);
|
||||
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
|
||||
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
|
||||
play.cooldowns = std::mem::replace(&mut self.cooldowns, cooldowns);
|
||||
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
|
||||
play.attack_item = std::mem::replace(&mut self.attack_item, attack_item);
|
||||
play.swing = std::mem::replace(&mut self.swing, swing);
|
||||
@@ -1064,6 +1089,35 @@ impl Game<'_> {
|
||||
}
|
||||
S::MoveVehicle(p) => self.guest_move_vehicle(host, conn, &p),
|
||||
S::PaddleBoat(p) => self.guest_paddle_boat(host, conn, [p.left, p.right]),
|
||||
// handleSetCreativeModeSlot's drop: a slot below 0 is a stack thrown out of the
|
||||
// creative screen (`handleCreativeModeItemDrop`), dropped from the player's hand.
|
||||
S::SetCreativeModeSlot(p) if p.slot < 0 => {
|
||||
if !host.hub.guests[gi].creative() {
|
||||
return;
|
||||
}
|
||||
let Some(stack) = self.item_from_network(host, &p.item) else {
|
||||
return;
|
||||
};
|
||||
if stack.count
|
||||
> self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(64, |i| i.max_stack)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
if play.creative_drops >= 1480 {
|
||||
eprintln!(
|
||||
"Player {} was dropping items too fast in creative mode, ignoring.",
|
||||
host.hub.guests[gi].name
|
||||
);
|
||||
return;
|
||||
}
|
||||
play.creative_drops += 20;
|
||||
self.as_guest(host, conn, |g| g.player_drop(stack));
|
||||
}
|
||||
S::SetCreativeModeSlot(p) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if !g.creative() || !(0..46).contains(&p.slot) {
|
||||
@@ -1781,8 +1835,20 @@ impl Game<'_> {
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled).
|
||||
// (the shield an axe disabled, a thrown pearl).
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
play.creative_drops = play.creative_drops.saturating_sub(1);
|
||||
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
|
||||
// the guest (a duration of 0 removes it).
|
||||
for group in play.cooldowns.tick() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let digging = play.digging;
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
@@ -1808,14 +1874,13 @@ impl Game<'_> {
|
||||
g.use_delay -= 1;
|
||||
}
|
||||
});
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: a creative
|
||||
// player's START breaks the block once (destroyAndAck) and
|
||||
// leaves nothing being destroyed; its client sends no STOP.
|
||||
// Kept, the dig broke whatever was put there next, every
|
||||
// destroy delay, for as long as the player broke nothing else.
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: creative and
|
||||
// instant survival STARTs finish without a STOP from the client.
|
||||
// tick_mining clears mining on completion; forget the START too,
|
||||
// so a flower or crop planted here later is not broken again.
|
||||
if digging.is_some()
|
||||
&& host.hub.guests[gi].creative()
|
||||
&& let Some(play) = host.play.get_mut(&conn)
|
||||
&& (host.hub.guests[gi].creative() || play.mining.is_none())
|
||||
{
|
||||
play.digging = None;
|
||||
}
|
||||
@@ -2112,6 +2177,12 @@ impl Game<'_> {
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
let mut events = std::mem::take(&mut w.player.events);
|
||||
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
|
||||
for event in &events {
|
||||
if let MotionEvent::CauldronExtinguish { pos } = event {
|
||||
game.cauldron_extinguish(*pos);
|
||||
}
|
||||
}
|
||||
// WitherRoseBlock.entityInside along those moves.
|
||||
for event in &events {
|
||||
if matches!(event, MotionEvent::InsideWitherRose) {
|
||||
@@ -2153,6 +2224,7 @@ impl Game<'_> {
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
self.as_guest(host, conn, |game| game.tick_player_sculk_events());
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
|
||||
|
||||
@@ -171,6 +171,19 @@ impl Game<'_> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The same guests' feet and whether they stand on the ground, for the
|
||||
/// blocks that also ask that (`BigDripleafBlock.canEntityTilt`).
|
||||
pub(super) fn lan_guest_feet(&self) -> Vec<([f64; 3], bool)> {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.map(|g| (g.position, g.on_ground))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The guests a projectile can hit, under their entity ids.
|
||||
pub(super) fn lan_projectile_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
|
||||
@@ -161,12 +161,12 @@ impl Game<'_> {
|
||||
+ (self.particles.random().next_float() - self.particles.random().next_float())
|
||||
* 0.2)
|
||||
* 0.7;
|
||||
self.audio.play(
|
||||
"minecraft:entity.generic.explode",
|
||||
Some(p.center),
|
||||
4.0,
|
||||
pitch,
|
||||
);
|
||||
// The packet's explosionSound: a wind charge's burst, a bed's
|
||||
// or anchor's blast, a modded sound.
|
||||
let sound = self
|
||||
.remote_sound_name(&p.sound)
|
||||
.unwrap_or_else(|| "minecraft:entity.generic.explode".to_owned());
|
||||
self.audio.play(&sound, Some(p.center), 4.0, pitch);
|
||||
}
|
||||
if let Some(options) = self.received_particle(&p.particle, p.center) {
|
||||
self.particles.add(&options, p.center, [1.0, 0.0, 0.0]);
|
||||
|
||||
@@ -388,7 +388,10 @@ impl Restrictions {
|
||||
1 => Some("chat_restriction.chat_disabled_by_options"),
|
||||
_ => None,
|
||||
};
|
||||
let (chat, commands) = (chat_visibility == 0, chat_visibility < 2);
|
||||
let abilities = horizon_client::chat::ChatAbilities::new(
|
||||
horizon_client::chat::ChatVisibility::from_option(chat_visibility),
|
||||
);
|
||||
let (chat, commands) = (abilities.send_messages, abilities.send_commands);
|
||||
// The body's widgets: text blocks (lines, top) and the action's top.
|
||||
let mut blocks = Vec::new();
|
||||
let mut action = None;
|
||||
@@ -802,6 +805,27 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer moved while a list's scrollbar is held: the game rules'
|
||||
/// list follows it (AbstractSelectionList.mouseDragged).
|
||||
pub(super) fn world_options_drag(&mut self) {
|
||||
let h = self.gui_size().1;
|
||||
if let Some(o) = self.world_options.as_mut()
|
||||
&& o.view == View::GameRules
|
||||
&& let Some(rules) = o.rules.as_mut()
|
||||
{
|
||||
rules.drag(self.cursor.1, h);
|
||||
}
|
||||
}
|
||||
|
||||
/// The button came up: a held scrollbar lets go (onRelease).
|
||||
pub(super) fn world_options_release(&mut self) {
|
||||
if let Some(o) = self.world_options.as_mut()
|
||||
&& let Some(rules) = o.rules.as_mut()
|
||||
{
|
||||
rules.release();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn world_options_click(&mut self) {
|
||||
let Some(mut o) = self.world_options.take() else {
|
||||
return;
|
||||
|
||||
@@ -79,6 +79,8 @@ impl Game<'_> {
|
||||
if horizon_client::mobs::leash::remove(m).is_none() {
|
||||
return;
|
||||
}
|
||||
// Leashable.tickLeash gates a dying entity's lead drop on entity_drops.
|
||||
let drop = drop && (m.alive() || self.ui.rules.bool("entity_drops"));
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if sound {
|
||||
self.lead_sound("break", at);
|
||||
|
||||
@@ -101,6 +101,39 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerLevel.globalLevelEvent`: heard by every player on the server
|
||||
/// (an end portal opening, the dragon's death roar): here, and sent to
|
||||
/// every guest whatever their dimension.
|
||||
pub(super) fn global_level_event(&mut self, id: i32, pos: [i32; 3], data: i32) {
|
||||
self.global_level_event_sound(id, pos);
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_global_events.push((id, pos, data));
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler.globalLevelEvent`: the sound two blocks from the
|
||||
/// camera towards the event, so it is heard from anywhere.
|
||||
pub(super) fn global_level_event_sound(&mut self, id: i32, pos: [i32; 3]) {
|
||||
let (sound, volume) = match id {
|
||||
1023 => ("minecraft:entity.wither.spawn", 1.0),
|
||||
1028 => ("minecraft:entity.ender_dragon.death", 5.0),
|
||||
1038 => ("minecraft:block.end_portal.spawn", 1.0),
|
||||
_ => return,
|
||||
};
|
||||
let at = self.eye_position().map(|eye| {
|
||||
let d = [
|
||||
f64::from(pos[0]) + 0.5 - eye[0],
|
||||
f64::from(pos[1]) + 0.5 - eye[1],
|
||||
f64::from(pos[2]) + 0.5 - eye[2],
|
||||
];
|
||||
let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if length < 1.0e-4 {
|
||||
return eye;
|
||||
}
|
||||
[0, 1, 2].map(|k| eye[k] + d[k] / length * 2.0)
|
||||
});
|
||||
// Each guest plays it from the global packet.
|
||||
self.audio.play_unshared(sound, at, volume, 1.0);
|
||||
}
|
||||
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
|
||||
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
|
||||
@@ -21,7 +21,7 @@ use super::{
|
||||
use horizon_client::{
|
||||
Result, block_entity_anim::BlockEntityAnims, random_tick_sections::RandomTickSections,
|
||||
save::SaveDir, streaming::Streaming, tick_chunk_membership::TickChunkMembership,
|
||||
ticket_levels::ChunkTickets, world::World,
|
||||
ticket_levels::ChunkTickets, vanillagen::VanillaTerrain, world::World,
|
||||
};
|
||||
use horizon_world::level::{LevelEvent, Ticks};
|
||||
use std::{
|
||||
@@ -39,6 +39,7 @@ const IDLE_TICKS: u32 = 300;
|
||||
pub(super) struct Parked {
|
||||
world: World,
|
||||
streaming: Streaming,
|
||||
locator: Option<Arc<VanillaTerrain>>,
|
||||
save: Arc<SaveDir>,
|
||||
block_ticks: Ticks,
|
||||
tick_chunks: TickChunkMembership,
|
||||
@@ -109,13 +110,21 @@ pub(super) struct Levels {
|
||||
impl Game<'_> {
|
||||
/// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`).
|
||||
fn open_level(&self, dim: Dim) -> Result<Parked> {
|
||||
let (streaming, save) =
|
||||
self.make_streaming(&self.world_root.clone(), dim, self.level.seed)?;
|
||||
let (streaming, save, locator) = super::world_loading::make_streaming(
|
||||
&self.res,
|
||||
&self.pack,
|
||||
&self.options,
|
||||
&self.settings,
|
||||
&self.world_root,
|
||||
dim,
|
||||
self.level.seed,
|
||||
)?;
|
||||
let mut world = World::new(self.res.facts().air, self.level.seed);
|
||||
world.fast_lava = dim == Dim::Nether;
|
||||
Ok(Parked {
|
||||
world,
|
||||
streaming,
|
||||
locator,
|
||||
save,
|
||||
block_ticks: Ticks::new(self.level.seed as i32),
|
||||
tick_chunks: Default::default(),
|
||||
@@ -163,6 +172,9 @@ impl Game<'_> {
|
||||
use std::mem::swap;
|
||||
swap(&mut self.world, &mut p.world);
|
||||
swap(&mut self.streaming, &mut p.streaming);
|
||||
let mut locator = self.end.locator();
|
||||
swap(&mut locator, &mut p.locator);
|
||||
self.end.set_locator(locator);
|
||||
swap(&mut self.save, &mut p.save);
|
||||
swap(&mut self.block_ticks, &mut p.block_ticks);
|
||||
swap(&mut self.tick_chunks, &mut p.tick_chunks);
|
||||
@@ -365,7 +377,12 @@ impl Game<'_> {
|
||||
}
|
||||
*next += self.clock.tick_duration();
|
||||
if *next > now {
|
||||
std::thread::sleep(*next - now);
|
||||
// A `/pregen` task keeps the workers fed until the next tick.
|
||||
self.pump_pregen(*next);
|
||||
let now = Instant::now();
|
||||
if *next > now {
|
||||
std::thread::sleep(*next - now);
|
||||
}
|
||||
} else if now - *next > Duration::from_secs(2) {
|
||||
*next = now;
|
||||
}
|
||||
@@ -373,7 +390,7 @@ impl Game<'_> {
|
||||
|
||||
/// The levels this server tick runs, in `MinecraftServer.levels` order
|
||||
/// (the Overworld, the Nether, the End): every level with a player in
|
||||
/// it, or the Overworld while nobody is online.
|
||||
/// it or a `/pregen` task, or the Overworld while nobody is online.
|
||||
pub(super) fn levels_to_tick(&self) -> Vec<Dim> {
|
||||
let guests = self
|
||||
.lan
|
||||
@@ -381,7 +398,9 @@ impl Game<'_> {
|
||||
.map_or(&[][..], |h| h.hub.guests.as_slice());
|
||||
let mut dims: Vec<Dim> = [Dim::Overworld, Dim::Nether, Dim::End]
|
||||
.into_iter()
|
||||
.filter(|d| guests.iter().any(|g| g.dimension == d.id()))
|
||||
.filter(|d| {
|
||||
guests.iter().any(|g| g.dimension == d.id()) || self.pregen_level() == Some(*d)
|
||||
})
|
||||
.collect();
|
||||
if dims.is_empty() {
|
||||
dims.push(Dim::Overworld);
|
||||
|
||||
@@ -923,15 +923,16 @@ impl Game<'_> {
|
||||
batch.rect_uv(ox, oy, 176.0, height, r, u32::MAX);
|
||||
}
|
||||
let grey = horizon_client::hud::rgba(64, 64, 64, 255);
|
||||
let title = self
|
||||
.remote_menu_title()
|
||||
.unwrap_or_else(|| self.container_title(self.translate(key, fallback)));
|
||||
let title = &self.remote_menu_title().unwrap_or_else(|| {
|
||||
horizon_client::text::Text::literal(self.container_title(self.translate(key, fallback)))
|
||||
.resolve_legacy()
|
||||
});
|
||||
let title_x = if screen == Screen::Hopper {
|
||||
8.0
|
||||
} else {
|
||||
(176.0 - horizon_client::hud::HudBatch::text_width(atlas, &title)) / 2.0
|
||||
(176.0 - atlas.font.width_text(title)) / 2.0
|
||||
};
|
||||
batch.text(atlas, &title, ox + title_x, oy + 6.0, grey, false);
|
||||
batch.text_styled(atlas, title, ox + title_x, oy + 6.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + height - 94.0, grey, false);
|
||||
true
|
||||
|
||||
@@ -33,7 +33,7 @@ fn recipe_matches(condition: &Value, id: &str) -> bool {
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Level.setBlock` of one boolean property of the block at `pos`.
|
||||
fn set_bool_prop(&mut self, pos: Pos, key: &str, value: bool, flags: i32) {
|
||||
pub(crate) fn set_bool_prop(&mut self, pos: Pos, key: &str, value: bool, flags: i32) {
|
||||
let registry = self.pack.assets.registry;
|
||||
let old = self.world.block(pos.0, pos.1, pos.2);
|
||||
let Some(mut state) = registry.state(old).cloned() else {
|
||||
|
||||
@@ -1463,6 +1463,7 @@ impl Game<'_> {
|
||||
lid: &lid,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|c| self.gui.atlas.glyph_cell(c),
|
||||
};
|
||||
self.res
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Capability `input` (docs/mods.md): the use button on items a mod claims,
|
||||
//! the mod's own keys and the mouse wheel; and `render`'s zoom. With no mod
|
||||
//! claiming items, keys or the wheel, each hook here returns after one
|
||||
//! check.
|
||||
use super::Game;
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
#[cfg(feature = "mods")]
|
||||
use {super::Screen, horizon_mods::HeldItem};
|
||||
|
||||
/// A claimed item's use while the button is down.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) struct ModUse {
|
||||
item: HeldItem,
|
||||
/// `Game::ticks` when it started.
|
||||
since: u64,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
pub(super) fn mod_use(&mut self, _button: MouseButton, _pressed: bool) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn mod_key(&mut self, _key: KeyCode, _pressed: bool, _repeat: bool) {}
|
||||
pub(super) fn mod_mouse_key(&mut self, _button: MouseButton, _pressed: bool) {}
|
||||
pub(super) fn mod_wheel(&mut self, _steps: f32) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn mod_zoom(&self) -> Option<ModZoom> {
|
||||
None
|
||||
}
|
||||
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod's zoom (`render.set-zoom`), clamped by the runtime.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ModZoom {
|
||||
pub(super) factor: f32,
|
||||
pub(super) smooth: bool,
|
||||
pub(super) turn: f32,
|
||||
}
|
||||
|
||||
/// A mod's minimap (`render.set-minimap`), clamped by the runtime.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ModMinimap {
|
||||
pub(super) corner: horizon_client::extras::Corner,
|
||||
pub(super) size: f32,
|
||||
pub(super) blocks_per_pixel: f32,
|
||||
pub(super) rotate: bool,
|
||||
pub(super) round: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
/// The main hand's stack as `input` shows it; none when it is empty.
|
||||
pub(super) fn mod_held(&self) -> Option<HeldItem> {
|
||||
let stack = self.inventory.get(self.selected)?.as_ref()?;
|
||||
Some(HeldItem {
|
||||
id: stack.item.to_string(),
|
||||
count: stack.count,
|
||||
slot: self.selected as u32,
|
||||
})
|
||||
}
|
||||
/// The use button while playing: true when a mod claimed the held item,
|
||||
/// so the game does nothing else with it (no placing, eating, swing).
|
||||
pub(super) fn mod_use(&mut self, button: MouseButton, pressed: bool) -> bool {
|
||||
if button != MouseButton::Right || !self.mods.mods.claims_any() {
|
||||
return false;
|
||||
}
|
||||
if !pressed {
|
||||
return self.stop_mod_use();
|
||||
}
|
||||
let Some(item) = self.mod_held().filter(|h| self.mods.mods.claims(&h.id)) else {
|
||||
return false;
|
||||
};
|
||||
self.mods.mods.use_start(&item);
|
||||
self.mods.using = Some(ModUse {
|
||||
item,
|
||||
since: self.ticks,
|
||||
});
|
||||
true
|
||||
}
|
||||
/// Ends a claimed use: the button came up, the player switched slots,
|
||||
/// or a screen opened. False when none was going.
|
||||
pub(super) fn stop_mod_use(&mut self) -> bool {
|
||||
let Some(u) = self.mods.using.take() else {
|
||||
return false;
|
||||
};
|
||||
let ticks = self.ticks.saturating_sub(u.since).min(u64::from(u32::MAX)) as u32;
|
||||
self.mods.mods.use_stop(&u.item, ticks);
|
||||
true
|
||||
}
|
||||
/// Once a tick: a claimed use stops when its item leaves the hand or a
|
||||
/// screen opens, as vanilla's `releaseUsingItem`.
|
||||
pub(super) fn tick_mod_use(&mut self) {
|
||||
let Some(u) = &self.mods.using else {
|
||||
return;
|
||||
};
|
||||
let still = self
|
||||
.mod_held()
|
||||
.is_some_and(|h| h.id == u.item.id && h.slot == u.item.slot);
|
||||
if !still || self.screen != Screen::Playing {
|
||||
self.stop_mod_use();
|
||||
}
|
||||
}
|
||||
/// A key went down or up: a mod's registered key while playing (no
|
||||
/// screen) is its `key-down` / `key-up`. Vanilla's mapping on the same
|
||||
/// key still runs.
|
||||
pub(super) fn mod_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
|
||||
if repeat || !self.mods.mods.has_keys() {
|
||||
return;
|
||||
}
|
||||
if pressed && (self.screen != Screen::Playing || self.title.is_some()) {
|
||||
return;
|
||||
}
|
||||
if let Some(name) = horizon_client::keybinds::key_name(key) {
|
||||
self.mods.mods.key(name, pressed);
|
||||
}
|
||||
}
|
||||
/// A mouse button while playing: a mod's key bound to it
|
||||
/// (`key.mouse.4`...), as `mod_key`.
|
||||
pub(super) fn mod_mouse_key(&mut self, button: MouseButton, pressed: bool) {
|
||||
if self.mods.mods.has_keys() {
|
||||
let name = horizon_client::keybinds::mouse_name(button);
|
||||
self.mods.mods.key(&name, pressed);
|
||||
}
|
||||
}
|
||||
/// Wheel steps while playing: true when a mod holds the wheel, so the
|
||||
/// hotbar doesn't move.
|
||||
pub(super) fn mod_wheel(&mut self, steps: f32) -> bool {
|
||||
self.mods.mods.wheel(steps)
|
||||
}
|
||||
/// Playing, with nothing over the world: mods' keys and zoom apply.
|
||||
fn mod_playing(&self) -> bool {
|
||||
self.screen == Screen::Playing
|
||||
&& self.title.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.book.is_none()
|
||||
}
|
||||
/// Once a frame, before the mods run: a screen that opened lets their
|
||||
/// held keys up.
|
||||
pub(super) fn mod_input_frame(&mut self) {
|
||||
let playing = self.mod_playing();
|
||||
if self.mods.playing && !playing {
|
||||
self.mods.mods.release_keys();
|
||||
}
|
||||
self.mods.playing = playing;
|
||||
}
|
||||
/// The strongest zoom a mod asks for, while playing; the spyglass's
|
||||
/// own view wins.
|
||||
pub(super) fn mod_zoom(&self) -> Option<ModZoom> {
|
||||
if !self.mod_playing() || self.scoping() {
|
||||
return None;
|
||||
}
|
||||
let z = self.mods.mods.zoom()?;
|
||||
Some(ModZoom {
|
||||
factor: z.factor,
|
||||
smooth: z.smooth,
|
||||
turn: z.turn,
|
||||
})
|
||||
}
|
||||
/// The minimap the first mod showing one asks for.
|
||||
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
|
||||
use horizon_client::extras::Corner;
|
||||
use horizon_mods::RenderCorner;
|
||||
let m = self.mods.mods.minimap()?;
|
||||
Some(ModMinimap {
|
||||
corner: match m.corner {
|
||||
RenderCorner::TopLeft => Corner::TopLeft,
|
||||
RenderCorner::TopRight => Corner::TopRight,
|
||||
RenderCorner::BottomLeft => Corner::BottomLeft,
|
||||
RenderCorner::BottomRight => Corner::BottomRight,
|
||||
},
|
||||
size: m.size,
|
||||
blocks_per_pixel: m.blocks_per_pixel,
|
||||
rotate: m.rotate,
|
||||
round: m.round,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Capability `render` (docs/mods.md): the shapes mods keep on the HUD,
|
||||
//! drawn by the game's own HUD renderer, and their camera shakes. Mods
|
||||
//! never supply shader code. With no mod drawing, each hook here returns
|
||||
//! after one check.
|
||||
use super::Game;
|
||||
use horizon_client::hud::HudBatch;
|
||||
#[cfg(feature = "mods")]
|
||||
use {
|
||||
horizon_client::{hud::rgba, items::ItemStack, text::Text},
|
||||
horizon_mods::Shape,
|
||||
std::time::Instant,
|
||||
};
|
||||
|
||||
/// The camera's jolts from `render.shake`, while any is fading.
|
||||
#[cfg(feature = "mods")]
|
||||
#[derive(Default)]
|
||||
pub(super) struct Shake {
|
||||
jolts: Vec<Jolt>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
struct Jolt {
|
||||
start: Instant,
|
||||
strength: f32,
|
||||
seconds: f32,
|
||||
}
|
||||
|
||||
/// The HUD batch's colour (ABGR) from a mod's 0xAARRGGBB.
|
||||
#[cfg(feature = "mods")]
|
||||
fn argb(color: u32) -> u32 {
|
||||
let [a, r, g, b] = color.to_be_bytes();
|
||||
rgba(r, g, b, a)
|
||||
}
|
||||
|
||||
/// The view's turn at full strength, in degrees: yaw, then pitch.
|
||||
#[cfg(feature = "mods")]
|
||||
const SHAKE_DEGREES: (f32, f32) = (3.0, 2.0);
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Shake {
|
||||
pub(super) fn add(&mut self, strength: f32, seconds: f32) {
|
||||
self.jolts.push(Jolt {
|
||||
start: Instant::now(),
|
||||
strength,
|
||||
seconds,
|
||||
});
|
||||
}
|
||||
/// How far the view turns now (yaw, pitch degrees), `scale` being the
|
||||
/// player's Distortion Effects; fades jolts out and forgets ended ones.
|
||||
fn offset(&mut self, now: Instant, scale: f32) -> (f32, f32) {
|
||||
self.jolts
|
||||
.retain(|j| now.duration_since(j.start).as_secs_f32() < j.seconds);
|
||||
let mut strength = 0.0;
|
||||
for j in &self.jolts {
|
||||
let left = 1.0 - now.duration_since(j.start).as_secs_f32() / j.seconds;
|
||||
strength += j.strength * left * left;
|
||||
}
|
||||
let strength = strength.min(1.0) * scale.clamp(0.0, 1.0);
|
||||
if strength <= 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
// Smooth noise: sines at unrelated rates, so it never repeats visibly.
|
||||
let t = self
|
||||
.jolts
|
||||
.first()
|
||||
.map_or(0.0, |j| now.duration_since(j.start).as_secs_f32());
|
||||
let wave = |a: f32, b: f32, c: f32| {
|
||||
((t * a).sin() * 0.5 + (t * b).sin() * 0.3 + (t * c).sin() * 0.2).clamp(-1.0, 1.0)
|
||||
};
|
||||
(
|
||||
strength * SHAKE_DEGREES.0 * wave(23.0, 37.3, 51.7),
|
||||
strength * SHAKE_DEGREES.1 * wave(29.1, 43.9, 61.3),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
pub(super) fn draw_mod_shapes(&self, _batch: &mut HudBatch) {}
|
||||
pub(super) fn shaken_view(&mut self, yaw: f32, pitch: f32) -> (f32, f32) {
|
||||
(yaw, pitch)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
/// The running mods' HUD shapes, in order, over the HUD.
|
||||
pub(super) fn draw_mod_shapes(&self, batch: &mut HudBatch) {
|
||||
for shape in self.mods.mods.hud_shapes() {
|
||||
self.draw_shapes(batch, std::slice::from_ref(shape), (0.0, 0.0));
|
||||
}
|
||||
}
|
||||
/// Shapes with their GUI coordinates moved by `(ox, oy)`.
|
||||
pub(super) fn draw_shapes(&self, batch: &mut HudBatch, shapes: &[Shape], (ox, oy): (f32, f32)) {
|
||||
let atlas = &self.gui.atlas;
|
||||
for shape in shapes {
|
||||
match shape {
|
||||
Shape::Fill(f) => {
|
||||
let r = f.rect;
|
||||
batch.fill(atlas, ox + r.x, oy + r.y, r.w, r.h, argb(f.color));
|
||||
}
|
||||
Shape::Quad(q) => {
|
||||
let (a, b, c, d) = q.corners;
|
||||
let corners = [a, b, c, d].map(|p| [ox + p.x, oy + p.y]);
|
||||
batch.fill_quad(atlas, corners, argb(q.color));
|
||||
}
|
||||
Shape::Sprite(s) => {
|
||||
let r = s.rect;
|
||||
let name = s.sprite.strip_prefix("minecraft:").unwrap_or(&s.sprite);
|
||||
let name = format!("gui/sprites/{name}");
|
||||
batch.sprite_scaled(atlas, &name, ox + r.x, oy + r.y, r.w, r.h, argb(s.color));
|
||||
}
|
||||
Shape::Item(i) => {
|
||||
if self.res.items().get(&i.item).is_none() {
|
||||
continue;
|
||||
}
|
||||
let start = batch.vertices.len();
|
||||
let stack = ItemStack::new(i.item.as_str(), 1);
|
||||
let (x, y) = (ox + i.at.x, oy + i.at.y);
|
||||
self.draw_item_model(batch, &stack, x, y);
|
||||
batch.scale_since(start, x, y, i.scale, i.scale);
|
||||
}
|
||||
Shape::Text(t) => {
|
||||
let text = Text::literal(t.text.clone());
|
||||
let centre = if t.centered {
|
||||
(HudBatch::text_width(atlas, &t.text) / 2.0).floor()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let x = ox + t.at.x - centre;
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&text,
|
||||
x,
|
||||
oy + t.at.y,
|
||||
argb(t.color | 0xFF00_0000),
|
||||
t.shadow,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The view's yaw and pitch after the mods' camera shakes; unchanged
|
||||
/// while none is fading.
|
||||
pub(super) fn shaken_view(&mut self, yaw: f32, pitch: f32) -> (f32, f32) {
|
||||
if self.mods.shake.jolts.is_empty() {
|
||||
return (yaw, pitch);
|
||||
}
|
||||
let scale = self.settings.file.double("screenEffectScale") as f32;
|
||||
let (dy, dp) = self.mods.shake.offset(Instant::now(), scale);
|
||||
(yaw + dy, (pitch + dp).clamp(-90.0, 90.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "mods"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn a_jolt_fades_out_and_distortion_scales_it() {
|
||||
let mut shake = Shake::default();
|
||||
shake.add(1.0, 2.0);
|
||||
let start = shake.jolts[0].start;
|
||||
let at = |s: f32| start + Duration::from_secs_f32(s);
|
||||
let size = |(y, p): (f32, f32)| y.abs() + p.abs();
|
||||
// Off with Distortion Effects at 0, inside the limits otherwise.
|
||||
assert_eq!(shake.offset(at(0.3), 0.0), (0.0, 0.0));
|
||||
let (y, p) = shake.offset(at(0.3), 1.0);
|
||||
assert!(y.abs() <= SHAKE_DEGREES.0 && p.abs() <= SHAKE_DEGREES.1);
|
||||
// Fading: the largest turn over a window shrinks.
|
||||
let mut peak = |from: f32| {
|
||||
(0..50)
|
||||
.map(|i| size(shake.offset(at(from + i as f32 * 0.002), 1.0)))
|
||||
.fold(0.0, f32::max)
|
||||
};
|
||||
let early = peak(0.1);
|
||||
let late = peak(1.6);
|
||||
assert!(late < early, "{late} !< {early}");
|
||||
// Gone once it ends.
|
||||
assert_eq!(shake.offset(at(2.1), 1.0), (0.0, 0.0));
|
||||
assert!(shake.jolts.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -21,19 +21,27 @@ use {
|
||||
extras::Corner,
|
||||
extras_hud,
|
||||
items::ItemStack,
|
||||
keybinds,
|
||||
mod_list::Installed,
|
||||
options_ui::Page,
|
||||
text::{self, Text},
|
||||
ui,
|
||||
},
|
||||
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
|
||||
horizon_mods::{Clock, ModKey, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
|
||||
std::time::Instant,
|
||||
winit::{event::MouseButton, keyboard::KeyCode},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ModsState {
|
||||
#[cfg(feature = "mods")]
|
||||
mods: Mods,
|
||||
pub(super) mods: Mods,
|
||||
/// Capability `input`: a claimed item's use while the button is down.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) using: Option<super::mod_input::ModUse>,
|
||||
/// Capability `render`'s camera shakes.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) shake: super::mod_render::Shake,
|
||||
/// `--dev-mod PATH`: always on, reloaded when rebuilt.
|
||||
pub(super) dev: Vec<PathBuf>,
|
||||
/// When dev mods were last checked for a rebuild.
|
||||
@@ -53,8 +61,38 @@ pub(super) struct ModsState {
|
||||
/// A `ui` mod's screen or settings page while it is up.
|
||||
#[cfg(feature = "mods")]
|
||||
screen: Option<ModScreen>,
|
||||
/// The HUD font's advances for `render.text-width`, made once.
|
||||
#[cfg(feature = "mods")]
|
||||
font_widths: std::sync::Arc<[f32]>,
|
||||
/// Extras that are official mods now, being installed.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) moved: super::moved_extras::MovedState,
|
||||
/// Playing at the last frame, for `mod_input_frame`.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) playing: bool,
|
||||
}
|
||||
|
||||
/// The mappings `input.mapping` answers for, with the default key their
|
||||
/// binding is translated to (none: a mouse button, attack or use).
|
||||
#[cfg(feature = "mods")]
|
||||
const MAPPINGS: [(&str, Option<winit::keyboard::KeyCode>); 9] = {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
[
|
||||
("key.forward", Some(K::KeyW)),
|
||||
("key.left", Some(K::KeyA)),
|
||||
("key.back", Some(K::KeyS)),
|
||||
("key.right", Some(K::KeyD)),
|
||||
("key.jump", Some(K::Space)),
|
||||
("key.sneak", Some(K::ShiftLeft)),
|
||||
("key.sprint", Some(K::ControlLeft)),
|
||||
("key.attack", None),
|
||||
("key.use", None),
|
||||
]
|
||||
};
|
||||
/// Characters `render.text-width` knows exactly: U+0000 to U+024F.
|
||||
#[cfg(feature = "mods")]
|
||||
const FONT_CHARS: u32 = 0x250;
|
||||
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods: the
|
||||
/// widgets the mod pushed, laid out and drawn with the menus' own widgets.
|
||||
#[cfg(feature = "mods")]
|
||||
@@ -67,6 +105,8 @@ struct ModScreen {
|
||||
under: Screen,
|
||||
/// One scroll per `list` widget, in widget order.
|
||||
scrolls: Vec<ui::Scroll>,
|
||||
/// A key row waiting for the key to bind: the mod key's name.
|
||||
binding: Option<String>,
|
||||
}
|
||||
impl ModsState {
|
||||
/// The chat's command tree: vanilla's, plus the running mods' commands.
|
||||
@@ -93,10 +133,10 @@ impl ModsState {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// Whether a mod draws a HUD panel.
|
||||
/// Whether a mod draws a HUD panel or a corner panel.
|
||||
pub(super) fn has_panels(&self) -> bool {
|
||||
#[cfg(feature = "mods")]
|
||||
if self.mods.panels().next().is_some() {
|
||||
if self.mods.panels().next().is_some() || self.mods.corner_panels().next().is_some() {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
@@ -206,7 +246,10 @@ impl Game<'_> {
|
||||
pub(super) fn open_mod_screen(&mut self, _owner: usize, _settings: bool) {}
|
||||
pub(super) fn close_mod_screen(&mut self, _tell: bool) {}
|
||||
pub(super) fn draw_mod_screen(&mut self, _batch: &mut HudBatch) {}
|
||||
pub(super) fn mod_screen_click(&mut self) {}
|
||||
pub(super) fn mod_screen_click(&mut self, _button: winit::event::MouseButton) {}
|
||||
pub(super) fn mod_screen_key(&mut self, _key: winit::keyboard::KeyCode) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn mod_screen_scroll(&mut self, _delta: f32) {}
|
||||
}
|
||||
|
||||
@@ -229,6 +272,8 @@ impl Game<'_> {
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let mods = Mods::load(&paths, on, vanilla_command);
|
||||
self.mods.mods = mods;
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
// Entries were replaced wholesale: a screen's owner index is stale.
|
||||
self.mods.screen = None;
|
||||
self.list_mods();
|
||||
@@ -322,8 +367,8 @@ impl Game<'_> {
|
||||
self.start_mods();
|
||||
}
|
||||
}
|
||||
/// What mods read this tick.
|
||||
fn mod_snapshot(&self) -> Snapshot {
|
||||
/// What mods read this tick (or frame, for mods with a `frame` hook).
|
||||
fn mod_snapshot(&mut self) -> Snapshot {
|
||||
let player = self.player.as_ref().map(|p| Player {
|
||||
x: p.position.x,
|
||||
y: p.position.y,
|
||||
@@ -338,7 +383,36 @@ impl Game<'_> {
|
||||
game_time: self.world.game_time.max(0) as u64,
|
||||
day_time: self.world.day_time.max(0) as u64,
|
||||
});
|
||||
Snapshot { player, clock }
|
||||
let (cps, mouse) = self.mouse_reading();
|
||||
// The movement and click mappings, as the keystroke overlay reads them:
|
||||
// keys by the default key their binding is translated to.
|
||||
let mappings = MAPPINGS
|
||||
.iter()
|
||||
.map(|&(name, key)| {
|
||||
let label = keybinds::display_name(self.settings.keys.get(name)).plain();
|
||||
let down = match key {
|
||||
Some(k) => self.keys.contains(&k),
|
||||
None => mouse[usize::from(name == "key.use")],
|
||||
};
|
||||
(name, label, down)
|
||||
})
|
||||
.collect();
|
||||
if self.mods.font_widths.is_empty() {
|
||||
let atlas = &self.gui.atlas;
|
||||
self.mods.font_widths = (0..FONT_CHARS)
|
||||
.map(|c| char::from_u32(c).map_or(6.0, |c| atlas.glyph_cell(c).1))
|
||||
.collect();
|
||||
}
|
||||
Snapshot {
|
||||
player,
|
||||
clock,
|
||||
gui_size: self.gui_size(),
|
||||
held: self.mod_held(),
|
||||
frame: self.frame_reading(),
|
||||
mappings,
|
||||
cps,
|
||||
font_widths: self.mods.font_widths.clone(),
|
||||
}
|
||||
}
|
||||
/// A game tick.
|
||||
pub(super) fn tick_mods(&mut self) {
|
||||
@@ -350,6 +424,7 @@ impl Game<'_> {
|
||||
if !self.mods.mods.is_running() {
|
||||
return;
|
||||
}
|
||||
self.tick_mod_use();
|
||||
let snapshot = self.mod_snapshot();
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
self.mods.mods.tick(self.ticks);
|
||||
@@ -365,6 +440,8 @@ impl Game<'_> {
|
||||
{
|
||||
self.mods.checked = Some(Instant::now());
|
||||
if self.mods.mods.reload_changed(vanilla_command) {
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
self.system_message(Text::literal("Mods reloaded").with_color(text::GRAY));
|
||||
@@ -373,6 +450,11 @@ impl Game<'_> {
|
||||
if !self.mods.mods.is_running() {
|
||||
return;
|
||||
}
|
||||
self.mod_input_frame();
|
||||
if self.mods.mods.wants_frames() {
|
||||
let snapshot = self.mod_snapshot();
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
}
|
||||
self.mods.mods.frame(elapsed.as_secs_f32());
|
||||
self.show_mod_output();
|
||||
}
|
||||
@@ -430,6 +512,10 @@ impl Game<'_> {
|
||||
self.open_mod_screen(owner, false);
|
||||
continue;
|
||||
}
|
||||
Output::Shake { strength, seconds } => {
|
||||
self.mods.shake.add(strength, seconds);
|
||||
continue;
|
||||
}
|
||||
Output::CloseScreen { owner } => {
|
||||
if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) {
|
||||
self.close_mod_screen(false);
|
||||
@@ -438,10 +524,16 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
// Not through system_message: a mod must not hear its own lines.
|
||||
// Written by this client, so the Chat option doesn't hide it.
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui
|
||||
.chat
|
||||
.add(&self.gui.atlas, line, None, self.ticks, focused);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
horizon_client::chat::ChatSource::Client,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
}
|
||||
// A mod may set or clear its settings page in any call, not only
|
||||
// while loading: its row's Settings button follows.
|
||||
@@ -470,39 +562,118 @@ impl Game<'_> {
|
||||
.collect();
|
||||
extras_hud::draw_lines(batch, &self.gui.atlas, corners, corner, &lines);
|
||||
}
|
||||
// Capability `render`'s corner panels, placed the same way.
|
||||
for panel in self.mods.mods.corner_panels() {
|
||||
let corner = match panel.corner {
|
||||
horizon_mods::RenderCorner::TopLeft => Corner::TopLeft,
|
||||
horizon_mods::RenderCorner::TopRight => Corner::TopRight,
|
||||
horizon_mods::RenderCorner::BottomLeft => Corner::BottomLeft,
|
||||
horizon_mods::RenderCorner::BottomRight => Corner::BottomRight,
|
||||
};
|
||||
if self.debug && corner == Corner::TopLeft {
|
||||
continue;
|
||||
}
|
||||
let at = corners.place(corner, panel.width, panel.height);
|
||||
self.draw_shapes(batch, &panel.shapes, at);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `ui` screen's title and widgets as the mod last pushed them; None
|
||||
/// when the surface is gone (the mod stopped or closed it).
|
||||
fn mod_ui_view(&self) -> Option<(String, Vec<UiWidget>)> {
|
||||
let ms = self.mods.screen.as_ref()?;
|
||||
/// when the surface is gone (the mod stopped or closed it). Borrows
|
||||
/// `mods` only, so `self.mods.screen` may stay borrowed for the scrolls.
|
||||
fn mod_ui_view<'a>(mods: &'a Mods, ms: &ModScreen) -> Option<(&'a str, &'a [UiWidget])> {
|
||||
if ms.settings {
|
||||
let widgets = self.mods.mods.settings_widgets(ms.owner);
|
||||
if widgets.is_empty() {
|
||||
let widgets = mods.settings_widgets(ms.owner);
|
||||
if widgets.is_empty() && mods.keys_of(ms.owner).is_empty() {
|
||||
return None;
|
||||
}
|
||||
let name = self
|
||||
.mods
|
||||
.mods
|
||||
let name = mods
|
||||
.entries()
|
||||
.get(ms.owner)
|
||||
.and_then(|e| e.manifest.as_ref())
|
||||
.map_or("Mod", |m| m.name.as_str());
|
||||
Some((name.to_string(), widgets.to_vec()))
|
||||
Some((name, widgets))
|
||||
} else {
|
||||
let s: &UiScreen = self.mods.mods.screen(ms.owner)?;
|
||||
Some((s.title.clone(), s.widgets.clone()))
|
||||
let s: &UiScreen = mods.screen(ms.owner)?;
|
||||
Some((s.title.as_str(), s.widgets.as_slice()))
|
||||
}
|
||||
}
|
||||
/// The keys a settings page lists first: the mod's own (`input`).
|
||||
fn mod_page_keys<'a>(mods: &'a Mods, ms: &ModScreen) -> &'a [ModKey] {
|
||||
if ms.settings {
|
||||
mods.keys_of(ms.owner)
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
/// A key row's label as Key Binds shows one, "Zoom: Z", marked
|
||||
/// "> Zoom: Z <" in yellow while it waits for the new key.
|
||||
fn mod_key_label(k: &ModKey, waiting: bool) -> Text {
|
||||
let value = format!("{}: {}", k.label, keybinds::display_name(&k.key).plain());
|
||||
if waiting {
|
||||
Text::literal(format!("> {value} <")).with_color(text::YELLOW)
|
||||
} else {
|
||||
Text::literal(value)
|
||||
}
|
||||
}
|
||||
/// The player bound the key row that waited to `key`: the mod's key
|
||||
/// follows it now and at the next start (`horizon-mods.txt`).
|
||||
fn bind_mod_key(&mut self, key: String) {
|
||||
let Some(ms) = self.mods.screen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = ms.binding.take() else {
|
||||
return;
|
||||
};
|
||||
let owner = ms.owner;
|
||||
if !self.mods.mods.rebind(owner, &name, &key) {
|
||||
return;
|
||||
}
|
||||
let Some(id) = self
|
||||
.mods
|
||||
.mods
|
||||
.entries()
|
||||
.get(owner)
|
||||
.and_then(|e| e.manifest.as_ref())
|
||||
.map(|m| m.id.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.settings
|
||||
.mods
|
||||
.keys
|
||||
.entry(id)
|
||||
.or_default()
|
||||
.insert(name, key);
|
||||
self.settings.save(&Self::options_path());
|
||||
}
|
||||
/// A key while a mod screen is up: true when a key row waited for
|
||||
/// it (Key Binds: Escape binds none).
|
||||
pub(super) fn mod_screen_key(&mut self, key: KeyCode) -> bool {
|
||||
if self
|
||||
.mods
|
||||
.screen
|
||||
.as_ref()
|
||||
.is_none_or(|ms| ms.binding.is_none())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let value = match key {
|
||||
KeyCode::Escape => None,
|
||||
k => keybinds::key_name(k),
|
||||
};
|
||||
self.bind_mod_key(value.unwrap_or("key.keyboard.unknown").to_owned());
|
||||
true
|
||||
}
|
||||
/// The layout the mod screen's widgets get at `h`: (widget, top,
|
||||
/// height), the column being `COLUMN` wide down the middle.
|
||||
fn mod_ui_layout(&self, h: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> {
|
||||
fn mod_ui_layout(h: f32, top: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> {
|
||||
let mut out = Vec::with_capacity(widgets.len());
|
||||
let mut y = MOD_UI_TOP;
|
||||
let mut y = top;
|
||||
for w in widgets {
|
||||
let wh = match w {
|
||||
UiWidget::Text(_) => 12.0,
|
||||
UiWidget::Item(_) => 20.0,
|
||||
UiWidget::Item(_) | UiWidget::Button(_) => 20.0,
|
||||
UiWidget::List(l) => (l.rows.len().min(6) as f32 * 12.0) + 8.0,
|
||||
};
|
||||
// Nothing may cover Done; a crowded mod loses what won't fit.
|
||||
@@ -522,7 +693,8 @@ impl Game<'_> {
|
||||
}
|
||||
if settings {
|
||||
self.mods.mods.ui_settings_opened(owner);
|
||||
if self.mods.mods.settings_widgets(owner).is_empty() {
|
||||
let keys = self.mods.mods.keys_of(owner);
|
||||
if self.mods.mods.settings_widgets(owner).is_empty() && keys.is_empty() {
|
||||
self.show_mod_output();
|
||||
return;
|
||||
}
|
||||
@@ -532,6 +704,7 @@ impl Game<'_> {
|
||||
settings,
|
||||
under: self.screen,
|
||||
scrolls: Vec::new(),
|
||||
binding: None,
|
||||
});
|
||||
self.screen = Screen::Mod;
|
||||
if self.player.is_some() {
|
||||
@@ -561,13 +734,19 @@ impl Game<'_> {
|
||||
/// The mod's screen or settings page: its title over widgets stacked
|
||||
/// down the middle, a Done button, Esc to leave (vanilla's menus).
|
||||
pub(super) fn draw_mod_screen(&mut self, batch: &mut HudBatch) {
|
||||
if self.mods.screen.is_none() {
|
||||
let Some(ms) = self.mods.screen.as_mut() else {
|
||||
// The screen was dropped while it was up: back to the world,
|
||||
// mouse held again like any return to Playing.
|
||||
self.screen = Screen::Playing;
|
||||
if self.player.is_some() {
|
||||
self.capture(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((title, widgets)) = self.mod_ui_view() else {
|
||||
self.mods.screen = None;
|
||||
self.screen = Screen::Playing;
|
||||
};
|
||||
let Some((title, widgets)) = Self::mod_ui_view(&self.mods.mods, ms) else {
|
||||
// The mod's surface is gone (it stopped or cleared it): leave
|
||||
// the screen like Done did, without `closed` — the mod ended it.
|
||||
self.close_mod_screen(false);
|
||||
return;
|
||||
};
|
||||
// Item icons draw in a second pass: `draw_item` needs `&mut self`,
|
||||
@@ -587,10 +766,45 @@ impl Game<'_> {
|
||||
);
|
||||
let cx = (batch.width / 2.0).floor();
|
||||
let x = cx - MOD_UI_COLUMN / 2.0;
|
||||
let layout = self.mod_ui_layout(batch.height, &widgets);
|
||||
// A settings page starts with the mod's keys, as Key Binds rows.
|
||||
let keys = Self::mod_page_keys(&self.mods.mods, ms);
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
let wy = MOD_UI_TOP + i as f32 * MOD_UI_KEY_ROW;
|
||||
let hover = ui::hovered(self.cursor, x, wy, MOD_UI_COLUMN, 20.0);
|
||||
let waiting = ms.binding.as_deref() == Some(k.name.as_str());
|
||||
let label = Self::mod_key_label(k, waiting);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
wy,
|
||||
MOD_UI_COLUMN,
|
||||
20.0,
|
||||
&label,
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
let top = MOD_UI_TOP + keys.len() as f32 * MOD_UI_KEY_ROW;
|
||||
let layout = Self::mod_ui_layout(batch.height, top, widgets);
|
||||
let mut list_i = 0usize;
|
||||
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
|
||||
match w {
|
||||
UiWidget::Button(b) => {
|
||||
let hover = ui::hovered(self.cursor, x, wy, MOD_UI_COLUMN, 20.0);
|
||||
let label = Text::literal(b.label.clone());
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
wy,
|
||||
MOD_UI_COLUMN,
|
||||
20.0,
|
||||
&label,
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
UiWidget::Text(t) => {
|
||||
let color = if t.color == 0 {
|
||||
u32::MAX
|
||||
@@ -628,11 +842,8 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
UiWidget::List(l) => {
|
||||
let scroll = {
|
||||
let ms = self.mods.screen.as_mut().expect("checked");
|
||||
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
|
||||
ms.scrolls[list_i]
|
||||
};
|
||||
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
|
||||
let scroll = ms.scrolls[list_i];
|
||||
list_i += 1;
|
||||
let rows_top = wy + 4.0;
|
||||
let rows_bottom = wy + wh - 4.0;
|
||||
@@ -695,7 +906,20 @@ impl Game<'_> {
|
||||
}
|
||||
/// A click on the mod screen: Done leaves it, a `list` row is the mod's
|
||||
/// `ui-events::select`.
|
||||
pub(super) fn mod_screen_click(&mut self) {
|
||||
pub(super) fn mod_screen_click(&mut self, button: MouseButton) {
|
||||
// Key Binds: a mouse button pressed while waiting binds it.
|
||||
if self
|
||||
.mods
|
||||
.screen
|
||||
.as_ref()
|
||||
.is_some_and(|ms| ms.binding.is_some())
|
||||
{
|
||||
self.bind_mod_key(keybinds::mouse_name(button));
|
||||
return;
|
||||
}
|
||||
if button != MouseButton::Left {
|
||||
return;
|
||||
}
|
||||
let (w, h) = self.gui_size();
|
||||
let cursor = self.cursor;
|
||||
if ui::hovered(cursor, (w / 2.0).floor() - 100.0, h - 27.0, 200.0, 20.0) {
|
||||
@@ -703,53 +927,80 @@ impl Game<'_> {
|
||||
self.close_mod_screen(true);
|
||||
return;
|
||||
}
|
||||
let Some((_, widgets)) = self.mod_ui_view() else {
|
||||
let Some(ms) = self.mods.screen.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some((_, widgets)) = Self::mod_ui_view(&self.mods.mods, ms) else {
|
||||
return;
|
||||
};
|
||||
let owner = ms.owner;
|
||||
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
|
||||
let layout = self.mod_ui_layout(h, &widgets);
|
||||
// A key row waits for the key to bind.
|
||||
let keys = Self::mod_page_keys(&self.mods.mods, ms);
|
||||
let picked = (0..keys.len()).find(|&i| {
|
||||
let wy = MOD_UI_TOP + i as f32 * MOD_UI_KEY_ROW;
|
||||
ui::hovered(cursor, x, wy, MOD_UI_COLUMN, 20.0)
|
||||
});
|
||||
if let Some(i) = picked {
|
||||
let name = keys[i].name.clone();
|
||||
self.audio.play_button_click();
|
||||
if let Some(ms) = self.mods.screen.as_mut() {
|
||||
ms.binding = Some(name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let top = MOD_UI_TOP + keys.len() as f32 * MOD_UI_KEY_ROW;
|
||||
let layout = Self::mod_ui_layout(h, top, widgets);
|
||||
let mut list_i = 0usize;
|
||||
// The `ui_select` call needs `&mut`: find the pick first.
|
||||
let mut hit = None;
|
||||
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
|
||||
// A button is `select(id, 0)`.
|
||||
if let UiWidget::Button(b) = w
|
||||
&& ui::hovered(cursor, x, wy, MOD_UI_COLUMN, 20.0)
|
||||
{
|
||||
self.audio.play_button_click();
|
||||
let owner = self.mods.screen.as_ref().map_or(0, |ms| ms.owner);
|
||||
self.mods.mods.ui_select(owner, b.id, 0);
|
||||
self.show_mod_output();
|
||||
return;
|
||||
}
|
||||
let UiWidget::List(l) = w else { continue };
|
||||
let scroll = self
|
||||
.mods
|
||||
.screen
|
||||
.as_ref()
|
||||
.and_then(|ms| ms.scrolls.get(list_i).copied())
|
||||
.unwrap_or_default();
|
||||
let scroll = ms.scrolls.get(list_i).copied().unwrap_or_default();
|
||||
list_i += 1;
|
||||
let row = ((cursor.1 - (wy + 4.0) + scroll.amount) / 12.0) as i64;
|
||||
if ui::hovered(cursor, x + 1.0, wy + 1.0, MOD_UI_COLUMN - 7.0, wh - 2.0)
|
||||
&& row >= 0
|
||||
&& (row as usize) < l.rows.len()
|
||||
{
|
||||
self.audio.play_button_click();
|
||||
self.mods.mods.ui_select(
|
||||
self.mods.screen.as_ref().map_or(0, |ms| ms.owner),
|
||||
l.id,
|
||||
row as u32,
|
||||
);
|
||||
self.show_mod_output();
|
||||
return;
|
||||
hit = Some((l.id, row as u32));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some((id, row)) = hit {
|
||||
self.audio.play_button_click();
|
||||
self.mods.mods.ui_select(owner, id, row);
|
||||
self.show_mod_output();
|
||||
}
|
||||
}
|
||||
/// The wheel over a `list` scrolls it.
|
||||
pub(super) fn mod_screen_scroll(&mut self, delta: f32) {
|
||||
let (w, h) = self.gui_size();
|
||||
let cursor = self.cursor;
|
||||
let Some((_, widgets)) = self.mod_ui_view() else {
|
||||
let Some(ms) = self.mods.screen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some((_, widgets)) = Self::mod_ui_view(&self.mods.mods, ms) else {
|
||||
return;
|
||||
};
|
||||
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
|
||||
let layout = self.mod_ui_layout(h, &widgets);
|
||||
let top =
|
||||
MOD_UI_TOP + Self::mod_page_keys(&self.mods.mods, ms).len() as f32 * MOD_UI_KEY_ROW;
|
||||
let layout = Self::mod_ui_layout(h, top, widgets);
|
||||
let mut list_i = 0usize;
|
||||
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
|
||||
let UiWidget::List(l) = w else { continue };
|
||||
let over = ui::hovered(cursor, x, wy, MOD_UI_COLUMN, wh);
|
||||
let Some(ms) = self.mods.screen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
|
||||
if over {
|
||||
let content = l.rows.len() as f32 * 12.0;
|
||||
@@ -765,3 +1016,6 @@ impl Game<'_> {
|
||||
const MOD_UI_TOP: f32 = 40.0;
|
||||
#[cfg(feature = "mods")]
|
||||
const MOD_UI_COLUMN: f32 = 200.0;
|
||||
/// A key row's height with its gap, as the widgets below it space out.
|
||||
#[cfg(feature = "mods")]
|
||||
const MOD_UI_KEY_ROW: f32 = 26.0;
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Extras that are official mods now (`horizon_client::moved_extras`): on
|
||||
//! the first start after the update, a player who had one on gets the mod
|
||||
//! installed from Horizon's registry, turned on, with their settings, and
|
||||
//! one line in the chat saying so. The download runs on its own thread; if
|
||||
//! it fails (offline), the game tries again at the next start.
|
||||
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl super::Game<'_> {
|
||||
pub(super) fn start_moved_extras(&mut self) {}
|
||||
pub(super) fn poll_moved_extras(&mut self) {}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
mod with_mods {
|
||||
use super::super::Game;
|
||||
use horizon_client::{
|
||||
moved_extras::{self, Moved},
|
||||
text::{self, Text},
|
||||
};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
type Done = Vec<(&'static Moved, Result<(), String>)>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(in crate::game) struct MovedState {
|
||||
/// The installs under way.
|
||||
running: Option<Receiver<Done>>,
|
||||
/// Lines for the player, shown once a world is open.
|
||||
notices: Vec<Text>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// At start: remembers the moved extras that were off, and installs
|
||||
/// the ones that were on, in the background.
|
||||
pub(in crate::game) fn start_moved_extras(&mut self) {
|
||||
let off = moved_extras::nothing_to_do(&self.settings.extras);
|
||||
let pending = moved_extras::pending(&self.settings.extras);
|
||||
if off.is_empty() && pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !off.is_empty() {
|
||||
let e = &mut self.settings.extras;
|
||||
e.moved.extend(off.iter().map(|m| m.id.to_owned()));
|
||||
self.settings.save(&Self::options_path());
|
||||
}
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let extras = self.settings.extras.clone();
|
||||
let dir = Self::options_path()
|
||||
.parent()
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.unwrap_or_default();
|
||||
let (tx, rx) = channel();
|
||||
self.mods.moved.running = Some(rx);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Moved extras".into())
|
||||
.spawn(move || {
|
||||
let done = match moved_extras::fetch_index() {
|
||||
Ok(index) => pending
|
||||
.into_iter()
|
||||
.map(|m| (m, moved_extras::install(&index, m, &extras, &dir)))
|
||||
.collect(),
|
||||
Err(e) => pending.into_iter().map(|m| (m, Err(e.clone()))).collect(),
|
||||
};
|
||||
let _ = tx.send(done);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
self.mods.moved.running = None;
|
||||
}
|
||||
}
|
||||
/// Once a frame: the installs that finished turn their mods on;
|
||||
/// their lines wait for a world.
|
||||
pub(in crate::game) fn poll_moved_extras(&mut self) {
|
||||
if let Some(rx) = &self.mods.moved.running
|
||||
&& let Ok(done) = rx.try_recv()
|
||||
{
|
||||
self.mods.moved.running = None;
|
||||
let mut installed = false;
|
||||
for (m, result) in done {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.settings.extras.moved.push(m.id.to_owned());
|
||||
self.settings.mods.enabled.insert(m.id.to_owned());
|
||||
// The install wrote the key to horizon-mods.txt;
|
||||
// the settings save below must keep it.
|
||||
let keys = self.settings.mods.keys.entry(m.id.to_owned()).or_default();
|
||||
for (name, key) in m.keys(&self.settings.extras) {
|
||||
keys.entry(name.to_owned()).or_insert(key);
|
||||
}
|
||||
self.settings.mods.keys.retain(|_, k| !k.is_empty());
|
||||
self.mods.moved.notices.push(Text::literal(m.notice()));
|
||||
installed = true;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: {} not installed, next start retries: {e}", m.id);
|
||||
self.mods.moved.notices.push(
|
||||
Text::literal(format!(
|
||||
"{} moved to the mod browser; it couldn't be installed for you yet, Horizon tries again at the next start",
|
||||
m.name
|
||||
))
|
||||
.with_color(text::GRAY),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if installed {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.start_mods();
|
||||
}
|
||||
}
|
||||
if !self.mods.moved.notices.is_empty() && self.player.is_some() && self.title.is_none()
|
||||
{
|
||||
for line in std::mem::take(&mut self.mods.moved.notices) {
|
||||
self.system_message(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) use with_mods::MovedState;
|
||||
@@ -240,6 +240,42 @@ impl Game<'_> {
|
||||
discrete: false,
|
||||
});
|
||||
}
|
||||
// ItemFrameRenderer.shouldShowName/getNameTag: only the targeted
|
||||
// frame's custom-named item, with the HUD visible (also glow frames).
|
||||
if !self.ui.hide_gui
|
||||
&& let Some((i, distance)) = self.frame_hit()
|
||||
&& self.before_block(distance)
|
||||
&& self.mob_hit().is_none_or(|(_, d)| d > distance)
|
||||
&& let Some(name) = self.item_frames[i]
|
||||
.item
|
||||
.as_ref()
|
||||
.and_then(|item| item.custom_name())
|
||||
{
|
||||
let frame = &self.item_frames[i];
|
||||
let (lo, hi) = frame.bounds();
|
||||
let center = glam::DVec3::from_array(std::array::from_fn(|a| (lo[a] + hi[a]) / 2.0));
|
||||
if center.distance_squared(camera) < 64.0 * 64.0 {
|
||||
// NAME_TAG is at the entity height (0.5), then the name
|
||||
// submitter lifts it 0.5. ItemFrameRenderer offsets rendering
|
||||
// 0.3 toward its facing and -0.25 vertically.
|
||||
let step = frame.step();
|
||||
tags.push(NameTag {
|
||||
at: center
|
||||
+ glam::DVec3::new(
|
||||
f64::from(step[0] as f32 * 0.3),
|
||||
0.75,
|
||||
f64::from(step[2] as f32 * 0.3),
|
||||
),
|
||||
lines: vec![(Text::literal(name).resolve_legacy(), 0.0)],
|
||||
light: self.world.light(
|
||||
center.x.floor() as i32,
|
||||
center.y.floor() as i32,
|
||||
center.z.floor() as i32,
|
||||
),
|
||||
discrete: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
tags
|
||||
}
|
||||
/// `SubmitNodeCollection.submitNameTag`: a discrete name is drawn once,
|
||||
@@ -257,12 +293,16 @@ impl Game<'_> {
|
||||
const FADED: u32 = 0x7F;
|
||||
for tag in self.name_tags(camera, alpha) {
|
||||
let o = tag.at - camera;
|
||||
// `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its
|
||||
// background, blends.
|
||||
if tag.discrete {
|
||||
self.mark_translucent_entity(out.len(), "minecraft:name_tag");
|
||||
self.name_tag_quads(o, &tag.lines, FADED, true, 0, vertex_light(tag.light), out);
|
||||
continue;
|
||||
}
|
||||
// LightCoordsUtil.lightCoordsWithEmission(lightCoords, 2).
|
||||
let lit = (tag.light >> 4).max(2) << 4 | (tag.light & 15).max(2);
|
||||
self.mark_entity(out.len(), "minecraft:name_tag");
|
||||
self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, vertex_light(lit), out);
|
||||
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
|
||||
self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Horizon's own shader packs in the game (docs/shaders.md), for development until the Shader
|
||||
//! Packs page exists: `HORIZON_SHADER_PACK=<name>` naming one in `shaderpacks/` (a folder with
|
||||
//! `horizon-shaders.toml`, or a `.hshader`) runs it once it passes `horizon_shader_api::check`.
|
||||
//! The pack is looked at twice a second: a saved file is checked and the pack rebuilt, and a
|
||||
//! pack that fails is refused with its problems in the chat while the last good one keeps
|
||||
//! drawing.
|
||||
|
||||
use std::{
|
||||
hash::{Hash, Hasher},
|
||||
path::Path,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use horizon_client::text::Text;
|
||||
use horizon_shader_api::{Pack, check};
|
||||
|
||||
use super::Game;
|
||||
|
||||
const LOOK_EVERY: Duration = Duration::from_millis(500);
|
||||
const RED: u32 = 0xFF5555;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct NativeShaders {
|
||||
/// The pack's files when it was last read (`fingerprint`).
|
||||
stamp: Option<u64>,
|
||||
looked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Builds the pack at `path` when its files changed since it was last read.
|
||||
pub(super) fn tick_native_shader_pack(&mut self, path: &Path) {
|
||||
let now = Instant::now();
|
||||
let state = &mut self.native_shaders;
|
||||
if state.looked.is_some_and(|t| now - t < LOOK_EVERY) {
|
||||
return;
|
||||
}
|
||||
state.looked = Some(now);
|
||||
let stamp = fingerprint(path);
|
||||
if stamp == state.stamp {
|
||||
return;
|
||||
}
|
||||
let reload = state.stamp.is_some();
|
||||
state.stamp = stamp;
|
||||
let Some(renderer) = &mut self.renderer else {
|
||||
return;
|
||||
};
|
||||
let result = Pack::read(path).map_err(|e| vec![e]).and_then(|pack| {
|
||||
let report = check(&pack)
|
||||
.map_err(|p| p.iter().map(ToString::to_string).collect::<Vec<String>>())?;
|
||||
let constants = pack
|
||||
.manifest
|
||||
.options
|
||||
.iter()
|
||||
.map(|o| (o.id.clone(), o.kind.default_constant()))
|
||||
.collect();
|
||||
let labels = renderer
|
||||
.set_native_shader_pack(Some((report, constants)))
|
||||
.map_err(|e| vec![e])?;
|
||||
Ok((pack.manifest, labels))
|
||||
});
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
match result {
|
||||
Ok((m, labels)) => {
|
||||
log::info!(
|
||||
"shader pack {} {}: {} in {} ms",
|
||||
m.id,
|
||||
m.version,
|
||||
labels.join(", "),
|
||||
now.elapsed().as_millis()
|
||||
);
|
||||
if reload {
|
||||
self.system_message(Text::literal(format!("Shader pack {} reloaded", m.name)));
|
||||
}
|
||||
}
|
||||
Err(problems) => {
|
||||
log::warn!("shader pack {name} refused: {}", problems.join("; "));
|
||||
self.system_message(Text::colored(format!("Shader pack {name} refused:"), RED));
|
||||
for p in problems {
|
||||
self.system_message(Text::colored(p, RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hash of the pack's file names, sizes and modification times (`None` when unreadable).
|
||||
fn fingerprint(path: &Path) -> Option<u64> {
|
||||
fn walk(path: &Path, hasher: &mut std::hash::DefaultHasher) -> Option<()> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
path.hash(hasher);
|
||||
if meta.is_dir() {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(path)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
entries.sort();
|
||||
for entry in entries {
|
||||
walk(&entry, hasher)?;
|
||||
}
|
||||
} else {
|
||||
meta.len().hash(hasher);
|
||||
meta.modified().ok()?.hash(hasher);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
let mut hasher = std::hash::DefaultHasher::new();
|
||||
walk(path, &mut hasher)?;
|
||||
Some(hasher.finish())
|
||||
}
|
||||
@@ -270,6 +270,18 @@ impl Game<'_> {
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
self.portals.client_inside = true;
|
||||
}
|
||||
/// The same on a server's client: `Entity.canUsePortal` (alive, not a
|
||||
/// passenger), then only the client's `setAsInsidePortal`, which drives
|
||||
/// the confusion effect (`client_portal_effect`).
|
||||
pub(super) fn client_inside_nether_portal(&mut self) {
|
||||
let alive = self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| !p.health.dead_or_dying());
|
||||
if alive && self.riding.is_none() {
|
||||
self.portals.client_inside = true;
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.handlePortal` (not while changing dimension: there is no
|
||||
/// player then). Players wait `players_nether_portal_default_delay`
|
||||
/// ticks, or the creative delay with invulnerable abilities.
|
||||
|
||||
@@ -57,14 +57,7 @@ impl Game<'_> {
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or("options-set KEY VALUE: missing the value")?;
|
||||
let mut file = self.settings.to_file();
|
||||
file.set_text(key, value)?;
|
||||
let mut settings = horizon_client::settings::Settings::from_file(file);
|
||||
// Horizon's own files are not in options.txt.
|
||||
std::mem::swap(&mut settings.horizon, &mut self.settings.horizon);
|
||||
std::mem::swap(&mut settings.controller, &mut self.settings.controller);
|
||||
std::mem::swap(&mut settings.extras, &mut self.settings.extras);
|
||||
self.settings = settings;
|
||||
self.settings = self.settings.with_option(key, value)?;
|
||||
// As the options screen applies a change.
|
||||
self.handle_option_events(vec![Event::Changed])?;
|
||||
match *key {
|
||||
|
||||
@@ -142,6 +142,8 @@ impl Game<'_> {
|
||||
}
|
||||
let server = &self.server_packs.active;
|
||||
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
|
||||
let root =
|
||||
super::mods::content_root(&root, &self.settings.mods.enabled, &self.options.dev_mods);
|
||||
self.reload_resources(&root)
|
||||
}
|
||||
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
|
||||
@@ -192,6 +194,10 @@ impl Game<'_> {
|
||||
let context = pack.context.clone();
|
||||
self.pack = pack;
|
||||
self.gui = gui;
|
||||
// The atlas's sprites moved: the particle engine re-reads every rect
|
||||
// and the packs' sprite sets, or every particle draws a stale region.
|
||||
self.particles
|
||||
.reload(root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
// Villager hats come from the pack's texture metadata (shared data, swapped in place).
|
||||
*self
|
||||
|
||||
@@ -412,7 +412,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// Whether the player's own model shows in the world view.
|
||||
fn player_model_shown(&self) -> bool {
|
||||
!self.camera_type.first_person()
|
||||
// A shader pack's shadow pass draws the player in first person too (shadowPlayer).
|
||||
(!self.camera_type.first_person() || self.pack_shadow_casters.1)
|
||||
&& self.sleeping.is_none()
|
||||
&& !self.spectator()
|
||||
&& self.potions.effects.get("minecraft:invisibility").is_none()
|
||||
|
||||
@@ -58,6 +58,12 @@ fn remote_text(tag: &Tag) -> horizon_client::text::Text {
|
||||
comp_text(&tag_to_json(tag))
|
||||
}
|
||||
|
||||
/// `ClientboundOpenScreenPacket.title` as a container screen draws it: the
|
||||
/// component's styles, and the `§` codes a literal holds (`StringDecomposer`).
|
||||
fn menu_title(tag: &Tag) -> horizon_client::text::Text {
|
||||
remote_text(tag).resolve_legacy()
|
||||
}
|
||||
|
||||
/// A section's sky and block light layers from the wire.
|
||||
type LightLayers = (Option<Vec<u8>>, Option<Vec<u8>>);
|
||||
|
||||
@@ -123,6 +129,9 @@ pub(super) struct Remote {
|
||||
loaded_sent: bool,
|
||||
/// The view distance last sent in ClientInformation.
|
||||
sent_view: i8,
|
||||
/// The Chat option's visibility last sent in ClientInformation
|
||||
/// (`Options.broadcastOptions` sends it again when it changes).
|
||||
sent_chat_visibility: i32,
|
||||
/// BlockStatePredictionHandler: blocks changed ahead of the server (a predicted break),
|
||||
/// with the sequence of the last action that changed them and the state the server
|
||||
/// last said they have. Until the server acknowledges that sequence its block updates
|
||||
@@ -130,8 +139,8 @@ pub(super) struct Remote {
|
||||
predictions: HashMap<[i32; 3], (i32, i32)>,
|
||||
/// Open container: (window id, menu name).
|
||||
pub(super) menu: Option<(i32, String)>,
|
||||
/// The open menu's title as the server named it.
|
||||
pub(super) menu_title: Option<String>,
|
||||
/// The open menu's title as the server named it, with its styles.
|
||||
pub(super) menu_title: Option<horizon_client::text::Text>,
|
||||
state_id: i32,
|
||||
use_target: Option<[i32; 3]>,
|
||||
entities: HashMap<i32, Tracked>,
|
||||
@@ -234,6 +243,22 @@ fn number_format(f: &play::NumberFormat) -> horizon_client::scoreboard::NumberFo
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A `Holder<SoundEvent>` off the wire: an inline event's location, or
|
||||
/// the name of a registered one.
|
||||
pub(super) fn remote_sound_name(
|
||||
&self,
|
||||
sound: &(i32, Option<(String, Option<f32>)>),
|
||||
) -> Option<String> {
|
||||
match &sound.1 {
|
||||
Some((location, _)) => Some(location.clone()),
|
||||
None => self.remote.as_ref().and_then(|r| {
|
||||
r.builtin
|
||||
.get("minecraft:sound_event")?
|
||||
.name(sound.0)
|
||||
.map(str::to_owned)
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// Leave the title for a server (ConnectScreen): an empty world fed by the connection.
|
||||
pub(super) fn connect_server(&mut self, address: &str, name: &str) -> Result<()> {
|
||||
self.connect_server_with_cookies(address, name, None)
|
||||
@@ -406,6 +431,7 @@ impl Game<'_> {
|
||||
abilities: None,
|
||||
loaded_sent: false,
|
||||
sent_view: view,
|
||||
sent_chat_visibility: self.settings.chat_visibility as i32,
|
||||
predictions: HashMap::new(),
|
||||
menu: None,
|
||||
menu_title: None,
|
||||
@@ -473,6 +499,7 @@ impl Game<'_> {
|
||||
r.session.disconnect();
|
||||
}
|
||||
self.server_packs_disconnect();
|
||||
self.close_dialogs();
|
||||
// Single player's server thread saves the world before the title shows.
|
||||
let single_player = match self.integrated.take() {
|
||||
Some(server) => {
|
||||
@@ -614,14 +641,23 @@ impl Game<'_> {
|
||||
)
|
||||
}
|
||||
/// A line in the connection's chat, with its styles, click and hover
|
||||
/// events (ChatComponent.addMessage).
|
||||
pub(super) fn chat_line(&mut self, content: horizon_client::text::Text) {
|
||||
/// events (ChatComponent.addMessage). The lines the Chat option hides
|
||||
/// are dropped there (its `visibleMessageFilter`).
|
||||
pub(super) fn chat_line(
|
||||
&mut self,
|
||||
content: horizon_client::text::Text,
|
||||
source: horizon_client::chat::ChatSource,
|
||||
) {
|
||||
if !self.ui.chat.shows(source) {
|
||||
return;
|
||||
}
|
||||
let text = content.plain();
|
||||
log::info!("[chat] {text}");
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
content,
|
||||
None,
|
||||
source,
|
||||
self.ticks,
|
||||
self.screen == Screen::Chat,
|
||||
);
|
||||
@@ -684,6 +720,23 @@ impl Game<'_> {
|
||||
pub(super) fn remote_backlog(&self) -> usize {
|
||||
self.remote.as_ref().map_or(0, |r| r.backlog.len())
|
||||
}
|
||||
/// A headless command oracle has a real connection but no movement tick.
|
||||
/// Acknowledge loaded terrain without predicting or sending movement.
|
||||
pub(super) fn poll_stationary_client(&mut self) -> Result<()> {
|
||||
self.poll_remote()?;
|
||||
let loaded = self.player.as_ref().is_some_and(|p| {
|
||||
self.streaming.is_lit(
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
)
|
||||
});
|
||||
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
|
||||
self.send(S::PlayerLoaded(Empty));
|
||||
self.remote.as_mut().unwrap().loaded_sent = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process what the network thread received (once per frame, like the
|
||||
/// client's packet processor), in order. Chunk packets get
|
||||
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
|
||||
@@ -720,6 +773,8 @@ impl Game<'_> {
|
||||
} => {
|
||||
self.set_registries(registries);
|
||||
self.extras_joined();
|
||||
// handleLogin: the level loading screen replaces any dialog.
|
||||
self.close_dialogs();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.joined = true;
|
||||
r.profile = uuid;
|
||||
@@ -748,6 +803,8 @@ impl Game<'_> {
|
||||
}
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
|
||||
Event::ClearDialog => self.clear_dialog(),
|
||||
Event::DecodeError(e) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.decode_errors += 1;
|
||||
@@ -1386,13 +1443,29 @@ impl Game<'_> {
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(p) = self.creatures.projectiles.iter_mut().find(|p| {
|
||||
p.id == id as u32
|
||||
&& matches!(
|
||||
p.kind,
|
||||
horizon_client::projectiles::ProjectileKind::LargeFireball { .. }
|
||||
)
|
||||
}) {
|
||||
if path.is_some() {
|
||||
p.position = Vec3::new(position[0], position[1], position[2]);
|
||||
}
|
||||
if let Some((yaw, pitch)) = rotation {
|
||||
p.yaw = yaw;
|
||||
p.pitch = pitch;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
p.0 = position;
|
||||
}
|
||||
// ItemEntity and FallingBlockEntity keep Entity's NO_OP
|
||||
// InterpolationHandler: moveOrInterpolateTo puts them there at
|
||||
// once, and their own tick moves them on between the server's
|
||||
// updates (`tick_remote_entities`).
|
||||
// ItemEntity, FallingBlockEntity and PrimedTnt keep Entity's
|
||||
// NO_OP InterpolationHandler: moveOrInterpolateTo puts them there
|
||||
// at once, and their own tick moves them on between the server's
|
||||
// updates (`tick_remote_entities`, `tick_remote_projectiles`).
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.position = at;
|
||||
@@ -1403,6 +1476,11 @@ impl Game<'_> {
|
||||
f.position = at;
|
||||
return;
|
||||
}
|
||||
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
|
||||
t.position = at;
|
||||
t.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
// AbstractInterpolationHandler.interpolateTo: without a rotation,
|
||||
// the one it was heading for.
|
||||
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
|
||||
@@ -1596,9 +1674,10 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.sent_view = view;
|
||||
}
|
||||
self.send(S::ClientInformation(
|
||||
horizon_protocol::packets::configuration::ClientInformation::new(view),
|
||||
));
|
||||
let mut info =
|
||||
horizon_protocol::packets::configuration::ClientInformation::new(view);
|
||||
info.chat_visibility = self.settings.chat_visibility as i32;
|
||||
self.send(S::ClientInformation(info));
|
||||
}
|
||||
C::Respawn(p) => {
|
||||
// A new player, without effects: the server sends them again.
|
||||
@@ -1710,20 +1789,14 @@ impl Game<'_> {
|
||||
// ClientLevel.levelEvent: LevelEventHandler, as this client's own
|
||||
// level plays them (particles and sounds: a spawner's burst, bone
|
||||
// meal, waxing, a block broken by someone else).
|
||||
// ClientPacketListener.handleLevelEvent: a global one is heard
|
||||
// from anywhere (ClientLevel.globalLevelEvent).
|
||||
C::LevelEvent(p) if p.global => self.global_level_event_sound(p.kind, p.pos),
|
||||
C::LevelEvent(p) => self.level_event_at(p.kind, p.pos, p.data),
|
||||
C::LevelParticles(p) => self.remote_level_particles(&p),
|
||||
C::Explode(p) => self.remote_explode(&p),
|
||||
C::Sound(p) => {
|
||||
let name = match &p.sound.1 {
|
||||
Some((location, _)) => Some(location.clone()),
|
||||
None => self.remote.as_ref().and_then(|r| {
|
||||
r.builtin
|
||||
.get("minecraft:sound_event")?
|
||||
.name(p.sound.0)
|
||||
.map(str::to_owned)
|
||||
}),
|
||||
};
|
||||
if let Some(name) = name {
|
||||
if let Some(name) = self.remote_sound_name(&p.sound) {
|
||||
let at = [
|
||||
f64::from(p.x) / 8.0,
|
||||
f64::from(p.y) / 8.0,
|
||||
@@ -1906,10 +1979,14 @@ impl Game<'_> {
|
||||
}
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player
|
||||
// too, remote_mob_data's apply reads index 8 above.
|
||||
let other = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&p.id) && r.entity_id != p.id);
|
||||
let mannequin = self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == p.id as u32 && &*m.kind.id == "minecraft:mannequin");
|
||||
let other = mannequin
|
||||
|| self.remote.as_ref().is_some_and(|r| {
|
||||
r.level_players.contains_key(&p.id) && r.entity_id != p.id
|
||||
});
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): another
|
||||
// player lies down in their bed (drawn lying in it), or gets up.
|
||||
if other {
|
||||
@@ -1960,11 +2037,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// Entity.DATA_SHARED_FLAGS_ID's crouching bit: another
|
||||
// player's crouch (their model and Entity.isDiscrete's name tag).
|
||||
if self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&p.id))
|
||||
// player's or a mannequin's crouch (their model and
|
||||
// Entity.isDiscrete's name tag).
|
||||
if (mannequin
|
||||
|| self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&p.id)))
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
{
|
||||
for (index, _, value) in &p.values {
|
||||
@@ -2367,6 +2446,16 @@ impl Game<'_> {
|
||||
}
|
||||
self.merge_remote_tree();
|
||||
}
|
||||
C::Cooldown(p) => {
|
||||
// ClientPacketListener.handleItemCooldown: the server starts
|
||||
// (or confirms) a group's cooldown; 0 ends it.
|
||||
let group: Arc<str> = Arc::from(p.group.as_str());
|
||||
if p.duration == 0 {
|
||||
self.cooldowns.remove(&group);
|
||||
} else {
|
||||
self.cooldowns.add(group, p.duration);
|
||||
}
|
||||
}
|
||||
C::SystemChat(p) => {
|
||||
// The component as sent, translated in the client's own
|
||||
// language. ChatListener.handleOverlay for the action bar.
|
||||
@@ -2374,7 +2463,7 @@ impl Game<'_> {
|
||||
if p.overlay {
|
||||
self.titles.set_overlay(text, false);
|
||||
} else {
|
||||
self.chat_line(text);
|
||||
self.chat_line(text, horizon_client::chat::ChatSource::Server);
|
||||
}
|
||||
}
|
||||
C::PlayerChat(p) => {
|
||||
@@ -2384,12 +2473,12 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map_or_else(|| serde_json::Value::from(p.content.as_str()), tag_to_json);
|
||||
let line = self.format_chat(&p.chat_type, content);
|
||||
self.chat_line(line);
|
||||
self.chat_line(line, horizon_client::chat::ChatSource::Player);
|
||||
}
|
||||
C::DisguisedChat(p) => {
|
||||
let content = horizon_client::scoreboard::tag_to_json(&p.message);
|
||||
let line = self.format_chat(&p.chat_type, content);
|
||||
self.chat_line(line);
|
||||
self.chat_line(line, horizon_client::chat::ChatSource::Player);
|
||||
}
|
||||
C::PlayerInfoUpdate(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -2604,6 +2693,9 @@ impl Game<'_> {
|
||||
C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)),
|
||||
C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)),
|
||||
C::SetTitlesAnimation(p) => self.titles.set_times(p.fade_in, p.stay, p.fade_out),
|
||||
// ClientCommonPacketListenerImpl.handleShowDialog / handleClearDialog.
|
||||
C::ShowDialog(p) => self.show_play_dialog(&p.dialog),
|
||||
C::ClearDialog(_) => self.clear_dialog(),
|
||||
C::ClearTitles(p) => {
|
||||
self.titles.clear();
|
||||
if p.value {
|
||||
@@ -2781,6 +2873,53 @@ impl Game<'_> {
|
||||
{
|
||||
mob.skin = Some(skin);
|
||||
}
|
||||
// ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a
|
||||
// mannequin's DATA_PROFILE `textures` property resolves like a
|
||||
// player's, never required to be signed.
|
||||
let mannequins: Vec<(u32, horizon_client::skins::Textures)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some())
|
||||
.map(|m| {
|
||||
let t = m
|
||||
.profile
|
||||
.as_ref()
|
||||
.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
|
||||
let textures = t.map_or_else(Default::default, |t| {
|
||||
let signature = if t.signature.is_some() {
|
||||
horizon_client::skins::Signature::Signed
|
||||
} else {
|
||||
horizon_client::skins::Signature::Unsigned
|
||||
};
|
||||
horizon_client::skins::unpack(
|
||||
&t.value,
|
||||
signature,
|
||||
&horizon_client::skins::TEXTURE_URIS,
|
||||
)
|
||||
});
|
||||
(m.id, textures)
|
||||
})
|
||||
.collect();
|
||||
let mut resolved: Vec<(u32, Option<String>)> = Vec::new();
|
||||
for (id, textures) in mannequins {
|
||||
let skin = {
|
||||
let mut downloads = self.skin_downloads.borrow_mut();
|
||||
horizon_client::skins::lookup(&textures, false, |kind, url| {
|
||||
downloads.state(kind, url)
|
||||
})
|
||||
};
|
||||
resolved.push((id, skin));
|
||||
}
|
||||
for (id, skin) in resolved {
|
||||
let skin = skin.filter(|s| !s.starts_with("skins/") || self.show_skin(s));
|
||||
if let Some(mob) = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id && &*m.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
mob.skin = skin;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Puts a downloaded skin in an atlas skin slot: a free one, else the
|
||||
/// least recently shown.
|
||||
@@ -2902,14 +3041,23 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
|
||||
/// entities whose own tick moves them on a client: dropped items and
|
||||
/// falling blocks.
|
||||
/// entities whose own tick moves them on a client: dropped items, falling
|
||||
/// blocks and primed TNT.
|
||||
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.velocity = v;
|
||||
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.velocity = v;
|
||||
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
|
||||
t.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter_mut()
|
||||
.find(|p| p.id == id as u32)
|
||||
{
|
||||
p.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
@@ -3022,16 +3170,19 @@ impl Game<'_> {
|
||||
fn tick_remote_level(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
self.track_experience_display();
|
||||
// Options.broadcastOptions: a changed render distance is sent again.
|
||||
// Options.broadcastOptions: a changed render distance or Chat option
|
||||
// is sent again.
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
let visibility = self.settings.chat_visibility as i32;
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
&& r.joined
|
||||
&& r.sent_view != view
|
||||
&& (r.sent_view != view || r.sent_chat_visibility != visibility)
|
||||
{
|
||||
r.sent_view = view;
|
||||
self.send(S::ClientInformation(
|
||||
horizon_protocol::packets::configuration::ClientInformation::new(view),
|
||||
));
|
||||
r.sent_chat_visibility = visibility;
|
||||
let mut info = horizon_protocol::packets::configuration::ClientInformation::new(view);
|
||||
info.chat_visibility = visibility;
|
||||
self.send(S::ClientInformation(info));
|
||||
}
|
||||
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
|
||||
// the time only while the level runs normally.
|
||||
@@ -3192,6 +3343,12 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
|
||||
}
|
||||
// NetherPortalBlock.entityInside on the client's own level:
|
||||
// the portal overlay and its trigger sound (the server moves
|
||||
// the player).
|
||||
horizon_client::motion::MotionEvent::InsidePortal { .. } => {
|
||||
self.client_inside_nether_portal();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -3394,6 +3551,10 @@ impl Game<'_> {
|
||||
m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32;
|
||||
*steps -= 1;
|
||||
}
|
||||
// Chicken.aiStep: the wings follow the server's on-ground flag.
|
||||
if m.species() == horizon_client::mobs::Species::Chicken {
|
||||
horizon_client::mobs::farm::chicken_flap(m);
|
||||
}
|
||||
// LivingEntity.updatingUsingItem: the ticks the item has been
|
||||
// used before this one. Another player's use particles; a mob's
|
||||
// drawn bow only needs the count (its pull follows it).
|
||||
@@ -4113,8 +4274,12 @@ impl Game<'_> {
|
||||
let Some(info) = self.res.items().get(&held.item).cloned() else {
|
||||
return false;
|
||||
};
|
||||
// MultiPlayerGameMode.useItem runs the use here too: food starts
|
||||
// being eaten (Consumable.startConsuming), a book and quill opens.
|
||||
// MultiPlayerGameMode.useItem runs the use here too (inside
|
||||
// startPrediction): the `use_cooldown` component starts its entry, and
|
||||
// the server's ClientboundCooldownPacket confirms it.
|
||||
self.apply_use_cooldown(&held);
|
||||
// Food starts being eaten (Consumable.startConsuming), a book and
|
||||
// quill opens.
|
||||
self.with_hand(hand, |g| g.start_eating(&info));
|
||||
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
|
||||
return true;
|
||||
@@ -4453,10 +4618,12 @@ impl Game<'_> {
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
/// ClientPacketListener.sendChat / sendCommand (unsigned).
|
||||
/// ClientPacketListener.sendChat / sendCommand (unsigned). The Chat
|
||||
/// option's Hidden and System levels keep chat out and Hidden keeps the
|
||||
/// commands out (ChatAbilities).
|
||||
pub(super) fn send_chat(&mut self, message: &str) {
|
||||
let message = message.trim();
|
||||
if message.is_empty() {
|
||||
if message.is_empty() || !self.chat_abilities().allows(message) {
|
||||
return;
|
||||
}
|
||||
if let Some(command) = message.strip_prefix('/') {
|
||||
@@ -4667,6 +4834,34 @@ impl Game<'_> {
|
||||
carried,
|
||||
}));
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.slotClicked` outside the window: the server knows nothing of
|
||||
/// a stack picked from the creative screen, so the carried stack (an item tab's right click:
|
||||
/// one of it) is dropped through `handleCreativeModeItemDrop`, a SetCreativeModeSlot to slot
|
||||
/// -1, and the hand swings as `Player.drop` predicts.
|
||||
pub(super) fn remote_creative_drop(&mut self, right: bool) {
|
||||
let one = right && !self.creative_inventory_open();
|
||||
let Some(carried) = self.carried.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let thrown = if one && carried.count > 1 {
|
||||
carried.count -= 1;
|
||||
ItemStack {
|
||||
count: 1,
|
||||
..carried.clone()
|
||||
}
|
||||
} else {
|
||||
self.carried.take().expect("carried")
|
||||
};
|
||||
if !self.creative {
|
||||
return;
|
||||
}
|
||||
self.swing_arm();
|
||||
let item = self.to_network(&Some(thrown));
|
||||
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot: -1,
|
||||
item,
|
||||
}));
|
||||
}
|
||||
pub(super) fn remote_close_container(&mut self) {
|
||||
let id = self
|
||||
.remote
|
||||
@@ -4771,14 +4966,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(kind) = Self::remote_station_kind(&menu) {
|
||||
let title = plain(
|
||||
&p.title,
|
||||
&self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let title = menu_title(&p.title);
|
||||
let pos = target.map_or([0, 0, 0], |t| [t.0, t.1, t.2]);
|
||||
self.remote_open_station(p.container, menu, kind, title, pos);
|
||||
return;
|
||||
@@ -4837,20 +5025,16 @@ impl Game<'_> {
|
||||
self.send(S::ContainerClose(play::ContainerClose {
|
||||
container: p.container,
|
||||
}));
|
||||
self.chat_line(horizon_client::text::Text::literal(format!(
|
||||
"(Horizon cannot show {menu} screens yet)"
|
||||
)));
|
||||
self.chat_line(
|
||||
horizon_client::text::Text::literal(format!(
|
||||
"(Horizon cannot show {menu} screens yet)"
|
||||
)),
|
||||
horizon_client::chat::ChatSource::Client,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let title = plain(
|
||||
&p.title,
|
||||
&self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let title = menu_title(&p.title);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.menu = Some((p.container, menu.clone()));
|
||||
r.menu_title = Some(title);
|
||||
@@ -4894,7 +5078,7 @@ impl Game<'_> {
|
||||
Some(self.remote.as_ref()?.menu.as_ref()?.1.as_str())
|
||||
}
|
||||
/// The title the server gave the menu it opened.
|
||||
pub(super) fn remote_menu_title(&self) -> Option<String> {
|
||||
pub(super) fn remote_menu_title(&self) -> Option<horizon_client::text::Text> {
|
||||
self.remote.as_ref()?.menu_title.clone()
|
||||
}
|
||||
/// A language change reaches the server-sent components too: their titles
|
||||
@@ -4907,7 +5091,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// A server's chest-like menu (`MenuType.GENERIC_9xN`): its rows and title.
|
||||
pub(super) fn remote_chest_menu(&self) -> Option<(usize, String)> {
|
||||
pub(super) fn remote_chest_menu(&self) -> Option<(usize, horizon_client::text::Text)> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let rows = r
|
||||
.menu
|
||||
@@ -4916,7 +5100,12 @@ impl Game<'_> {
|
||||
.strip_prefix("minecraft:generic_9x")?
|
||||
.parse()
|
||||
.ok()?;
|
||||
Some((rows, r.menu_title.clone().unwrap_or_default()))
|
||||
Some((
|
||||
rows,
|
||||
r.menu_title
|
||||
.clone()
|
||||
.unwrap_or_else(horizon_client::text::Text::empty),
|
||||
))
|
||||
}
|
||||
/// ServerboundPlayerCommandPacket STOP_SLEEPING.
|
||||
pub(super) fn remote_stop_sleeping(&mut self) {
|
||||
|
||||
@@ -75,6 +75,11 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
// Zombie's three): what VillagerProfessionLayer draws.
|
||||
(Species::Villager, 19, Value::Ids(data))
|
||||
| (Species::ZombieVillager, 20, Value::Ids(data)) => villager_data(m, data, builtin),
|
||||
// Mannequin.DATA_PROFILE (after Avatar's two): whose skin it
|
||||
// wears; update_remote_skins resolves it like a tab player's.
|
||||
(Species::Mannequin, 17, Value::Profile(profile)) => {
|
||||
m.profile = Some(profile.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -295,4 +300,22 @@ mod tests {
|
||||
apply(&mut e, &[(17, s::BYTE, Value::Byte(2))]);
|
||||
assert_eq!(e.extra.variant2, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mannequin_exists_and_takes_its_profile() {
|
||||
let Some(mut m) = mob("minecraft:mannequin") else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(&*m.kind.id, "minecraft:mannequin");
|
||||
let profile = horizon_protocol::entity_data::Profile {
|
||||
name: Some("Gommunity".into()),
|
||||
uuid: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
apply(
|
||||
&mut m,
|
||||
&[(17, s::RESOLVABLE_PROFILE, Value::Profile(profile.clone()))],
|
||||
);
|
||||
assert_eq!(m.profile, Some(profile));
|
||||
}
|
||||
}
|
||||
@@ -220,10 +220,21 @@ impl Game<'_> {
|
||||
|
||||
/// One tick of interpolation, as `tick_remote_objects`.
|
||||
pub(super) fn tick_remote_projectiles(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
for t in &mut self.tnts {
|
||||
t.previous = t.position;
|
||||
t.position = step(t.id, t.position);
|
||||
}
|
||||
// PrimedTnt.tick runs on a client too: it falls, bounces and burns its
|
||||
// fuse down (the swell and flash follow it) between the server's
|
||||
// updates (every 10 ticks), and a client discards it when the fuse
|
||||
// runs out; only the server explodes it.
|
||||
let (world, collision, assets, facts) = (
|
||||
&self.world,
|
||||
self.collision,
|
||||
&self.pack.assets,
|
||||
self.res.facts(),
|
||||
);
|
||||
self.tnts.retain_mut(|t| {
|
||||
// A fluid lookup that fails leaves it where its move put it.
|
||||
let _ = t.tick(world, collision, assets, facts);
|
||||
t.fuse > 0
|
||||
});
|
||||
// FallingBlockEntity.tick runs on a client too: the block falls by
|
||||
// itself between the server's updates (every 20 ticks), then waits
|
||||
// where it lands for the server to place it and remove it.
|
||||
@@ -238,7 +249,25 @@ impl Game<'_> {
|
||||
for p in &mut self.creatures.projectiles {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
p.position = step(p.id, p.position);
|
||||
if matches!(p.kind, ProjectileKind::LargeFireball { .. }) {
|
||||
// AbstractHurtingProjectile.tick advances on the client between
|
||||
// the tracker updates (ten ticks apart for a ghast's fireball).
|
||||
let at = p.position;
|
||||
let water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
))
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
p.apply_hurting_inertia(water);
|
||||
p.position = p.position.add_vector(p.velocity);
|
||||
} else {
|
||||
p.position = step(p.id, p.position);
|
||||
}
|
||||
}
|
||||
for t in &mut self.tridents {
|
||||
let id = t.id;
|
||||
|
||||
@@ -18,7 +18,7 @@ impl Game<'_> {
|
||||
container: i32,
|
||||
menu: String,
|
||||
kind: Kind,
|
||||
title: String,
|
||||
title: horizon_client::text::Text,
|
||||
pos: [i32; 3],
|
||||
) {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
|
||||
@@ -63,6 +63,12 @@ struct Steps {
|
||||
on_ground: bool,
|
||||
in_water: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct PlayerEvents {
|
||||
steps: Option<Steps>,
|
||||
last_health: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SculkState {
|
||||
/// The player's warning level toward a warden.
|
||||
@@ -86,6 +92,15 @@ pub(super) struct SculkState {
|
||||
}
|
||||
|
||||
impl SculkState {
|
||||
pub(super) fn swap_player(&mut self, other: &mut PlayerEvents) {
|
||||
let steps = self.steps.remove(&Who::Player);
|
||||
if let Some(guest) = other.steps.take() {
|
||||
self.steps.insert(Who::Player, guest);
|
||||
}
|
||||
other.steps = steps;
|
||||
std::mem::swap(&mut self.last_health, &mut other.last_health);
|
||||
}
|
||||
|
||||
/// Exchange the level-held parts (its sculk block entities and the
|
||||
/// chunks scanned for them) with another level's (game/levels.rs).
|
||||
pub(super) fn swap_level(&mut self, other: &mut Self) {
|
||||
@@ -718,17 +733,24 @@ impl Game<'_> {
|
||||
})
|
||||
};
|
||||
// The ticking ones in the index's order, with whether the chunks
|
||||
// around them tick too.
|
||||
// around them tick too. Most sensors and catalysts wait for a
|
||||
// vibration or a death: their tick would change nothing, so they
|
||||
// are passed over here (a vibration scheduled during these ticks is
|
||||
// chosen on the next tick at the earliest, as in vanilla).
|
||||
let entities = &self.world.block_entities;
|
||||
let due: Vec<((i32, i32, i32), bool)> = self
|
||||
.sculk
|
||||
.index
|
||||
.iter()
|
||||
.filter_map(|&key| {
|
||||
let (cx, cz) = (key.0 >> 4, key.2 >> 4);
|
||||
ticks(cx, cz).then(|| {
|
||||
let adjacent = (-1..=1).all(|dx| (-1..=1).all(|dz| ticks(cx + dx, cz + dz)));
|
||||
(key, adjacent)
|
||||
})
|
||||
if !ticks(cx, cz)
|
||||
|| matches!(entities.get(&key), Some(BlockEntity::Sculk(e)) if e.idle())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let adjacent = (-1..=1).all(|dx| (-1..=1).all(|dz| ticks(cx + dx, cz + dz)));
|
||||
Some((key, adjacent))
|
||||
})
|
||||
.collect();
|
||||
for (key, adjacent) in due {
|
||||
@@ -1491,6 +1513,12 @@ impl Game<'_> {
|
||||
/// Events from entities moving, getting hurt and dying, derived from what
|
||||
/// changed this tick.
|
||||
fn tick_entity_events(&mut self) {
|
||||
self.tick_player_sculk_events();
|
||||
self.tick_mob_sculk_events();
|
||||
}
|
||||
|
||||
/// Entity.move emits on the authoritative server too, for each ServerPlayer.
|
||||
pub(super) fn tick_player_sculk_events(&mut self) {
|
||||
// The player.
|
||||
let player = self.player.as_ref().map(|p| {
|
||||
(
|
||||
@@ -1518,7 +1546,9 @@ impl Game<'_> {
|
||||
}
|
||||
self.sculk.last_health = Some(health);
|
||||
}
|
||||
// Mobs.
|
||||
}
|
||||
|
||||
fn tick_mob_sculk_events(&mut self) {
|
||||
let snapshot: Vec<(u32, [f64; 3], bool, bool, Species, Option<i64>)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
|
||||
@@ -156,6 +156,10 @@ const CLIENT_SOUNDS: &[&str] = &[
|
||||
impl Game<'_> {
|
||||
/// Run every scenario of `spec` ("SCENARIOS,OUTPUT") and write the results.
|
||||
pub(super) fn sculk_fixture(&mut self, spec: &str) -> Result<()> {
|
||||
assert!(
|
||||
!self.options.execution_role.is_client(),
|
||||
"fixture requires a server Game"
|
||||
);
|
||||
let (input, output) = spec
|
||||
.split_once(',')
|
||||
.ok_or("HORIZON_SCULK_FIXTURE=SCENARIOS,OUTPUT")?;
|
||||
|
||||
@@ -170,7 +170,7 @@ impl Game<'_> {
|
||||
/// The player moves to `target`'s level (opened if nobody was there)
|
||||
/// and is told it is there, at the approximate exit, while it loads. The
|
||||
/// level whose tick is running is put back in place afterwards.
|
||||
fn start_server_trip(
|
||||
pub(super) fn start_server_trip(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
//! Shader packs, for development until the Shader Packs page exists: `HORIZON_SHADER_PACK=<name>`
|
||||
//! runs `shaderpacks/<name>` (a folder or a zip beside `options.txt`) over the world. The
|
||||
//! runs `shaderpacks/<name>` (a folder or a zip beside `options.txt`) over the world; Horizon's
|
||||
//! own packs go to `game/native_shaders.rs`. The
|
||||
//! programs of the current dimension's folder are loaded when the dimension changes (spec 1.2).
|
||||
|
||||
use horizon_client::camera::CameraFluid;
|
||||
use horizon_client::gfx::pack_uniforms::PackInputs;
|
||||
use horizon_shaders::{Macros, Pack, Properties, dimension_folder, load_post};
|
||||
use horizon_shaders::{BlockIds, Macros, Pack, Properties, dimension_folder, load_post};
|
||||
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::gfx::pack_draw::EntitySpan;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The ids a pack gives entities and block entities (4.1, 5.3), for the spans of the entity
|
||||
/// geometry.
|
||||
pub(super) struct PackIds {
|
||||
entities: HashMap<String, i32>,
|
||||
blocks: BlockIds,
|
||||
/// `blockEntityId` by block state, filled as states are drawn.
|
||||
block_states: RefCell<HashMap<u32, i32>>,
|
||||
/// The id of a name the file lacks: 65535, or 0 without the file.
|
||||
missing_entity: i32,
|
||||
missing_block: i32,
|
||||
/// `item.properties` (4.1) and the id of a held item the file lacks, or of an empty hand:
|
||||
/// −1, or 0 without the file (5.3).
|
||||
items: HashMap<String, i32>,
|
||||
missing_item: i32,
|
||||
/// `oldHandLight` (3.2): the first held item and light are those of the brighter hand.
|
||||
old_hand_light: bool,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Loads the pack for the current dimension when it changed, and returns this frame's
|
||||
@@ -20,15 +42,18 @@ impl Game<'_> {
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
};
|
||||
if let Some(renderer) = &mut self.renderer
|
||||
let path = std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("shaderpacks")
|
||||
.join(&name);
|
||||
if horizon_shader_api::package::is_pack(&path) {
|
||||
// One of Horizon's own packs (game/native_shaders.rs).
|
||||
self.tick_native_shader_pack(&path);
|
||||
} else if let Some(renderer) = &mut self.renderer
|
||||
&& renderer.shader_pack_dimension() != Some(dimension)
|
||||
{
|
||||
let name = name.to_string_lossy();
|
||||
let started = std::time::Instant::now();
|
||||
let path = std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("shaderpacks")
|
||||
.join(&*name);
|
||||
match Pack::open(&path) {
|
||||
Ok(pack) => {
|
||||
let macros = Macros::standard();
|
||||
@@ -42,8 +67,49 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
let folder = dimension_folder(&pack, dimension);
|
||||
let post = load_post(&pack, &folder, &properties, ¯os, None);
|
||||
if let Some((programs, notes)) = renderer.set_shader_pack(dimension, Some(post))
|
||||
let inputs = renderer.pack_inputs();
|
||||
let mut post = load_post(&pack, &folder, &properties, ¯os, &inputs);
|
||||
let biomes = self.res.biomes();
|
||||
let names: Vec<&str> = biomes.list.iter().map(|b| b.name.as_str()).collect();
|
||||
post.custom.set_biomes(&names);
|
||||
let text = pack.read_text("block.properties").unwrap_or_default();
|
||||
let ids = BlockIds::parse(&Properties::parse(&text, ¯os));
|
||||
for note in &ids.notes {
|
||||
log::warn!("shader pack {name}: block.properties: {note}");
|
||||
}
|
||||
let text = pack.read_text("entity.properties");
|
||||
let entities = BlockIds::parse_kind(
|
||||
&Properties::parse(text.as_deref().unwrap_or_default(), ¯os),
|
||||
"entity",
|
||||
);
|
||||
let text = pack.read_text("item.properties");
|
||||
let items = BlockIds::parse_kind(
|
||||
&Properties::parse(text.as_deref().unwrap_or_default(), ¯os),
|
||||
"item",
|
||||
);
|
||||
self.pack_ids = Some(PackIds {
|
||||
items: items.plain(),
|
||||
missing_item: if text.is_some() { -1 } else { 0 },
|
||||
// OQ-21: on unless the pack turns it off.
|
||||
old_hand_light: properties
|
||||
.get("oldHandLight")
|
||||
.is_none_or(|v| v.trim() != "false"),
|
||||
entities: entities.plain(),
|
||||
missing_entity: if text.is_some() { 65535 } else { 0 },
|
||||
missing_block: if ids.is_empty() { 0 } else { 65535 },
|
||||
blocks: ids.clone(),
|
||||
block_states: RefCell::default(),
|
||||
});
|
||||
let started_ids = std::time::Instant::now();
|
||||
let table = block_id_table(&self.pack, &ids);
|
||||
log::info!(
|
||||
"shader pack {name}: mc_Entity for {} of {} block sprites in {} ms",
|
||||
table.chunks(2).filter(|p| p[0] != -1).count(),
|
||||
table.len() / 2,
|
||||
started_ids.elapsed().as_millis()
|
||||
);
|
||||
if let Some((programs, notes)) =
|
||||
renderer.set_shader_pack(dimension, Some(post), &table)
|
||||
{
|
||||
for note in notes {
|
||||
log::warn!("shader pack {name}: {note}");
|
||||
@@ -57,14 +123,22 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.pack_ids = None;
|
||||
log::warn!("shader pack {name}: {e}");
|
||||
renderer.set_shader_pack(dimension, None);
|
||||
renderer.set_shader_pack(dimension, None, &[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let lerp = |(old, new): (f32, f32)| old + (new - old) * partial;
|
||||
let at = camera.floor().as_ivec3();
|
||||
let light = self.world.light(at.x, at.y, at.z);
|
||||
let biome = self
|
||||
.world
|
||||
.chunks
|
||||
.get(&(at.x >> 4, at.z >> 4))
|
||||
.map(|c| c.biome(at.x, at.y, at.z));
|
||||
let (held_items, held_light) = self.held_for_pack();
|
||||
let info = biome.map(|b| self.res.biomes().get(b));
|
||||
PackInputs {
|
||||
day_time: self.world.day_time,
|
||||
rain: lerp(self.world.weather.rain_level),
|
||||
@@ -79,6 +153,217 @@ impl Game<'_> {
|
||||
eye_light: [i32::from(light & 15), i32::from(light >> 4)],
|
||||
hide_gui: self.ui.hide_gui,
|
||||
brightness: self.settings.gamma,
|
||||
biome: biome.map_or(-1, i32::from),
|
||||
biome_category: info.map_or(0, |b| horizon_shaders::biome_category(&b.name)),
|
||||
biome_precipitation: info.map_or(0, |b| {
|
||||
horizon_shaders::biome_precipitation(b.precipitation, b.temperature)
|
||||
}),
|
||||
temperature: info.map_or(0.0, |b| b.temperature),
|
||||
rainfall: info.map_or(0.0, |b| b.downfall),
|
||||
held_items,
|
||||
held_light,
|
||||
}
|
||||
}
|
||||
|
||||
/// `heldItemId(2)` and `heldBlockLightValue(2)`: main hand, then off hand (5.2, 5.3).
|
||||
fn held_for_pack(&mut self) -> ([i32; 2], [i32; 2]) {
|
||||
if self.pack_ids.is_none() {
|
||||
return ([0; 2], [0; 2]);
|
||||
}
|
||||
if self.luminance.is_none() {
|
||||
self.luminance = Some(horizon_client::dynamic_light::Luminance::new(
|
||||
self.res.facts(),
|
||||
self.pack.assets.registry,
|
||||
));
|
||||
}
|
||||
let (Some(ids), Some(lum)) = (&self.pack_ids, &self.luminance) else {
|
||||
return ([0; 2], [0; 2]);
|
||||
};
|
||||
let hands = [self.selected, super::shield::OFFHAND_SLOT].map(|slot| {
|
||||
let item = self.stack_in(slot).map(|s| &*s.item);
|
||||
let id = item.and_then(|i| ids.items.get(i).copied());
|
||||
(
|
||||
id.unwrap_or(ids.missing_item),
|
||||
item.map_or(0, |i| i32::from(lum.of(i))),
|
||||
)
|
||||
});
|
||||
let first = if ids.old_hand_light && hands[1].1 > hands[0].1 {
|
||||
hands[1]
|
||||
} else {
|
||||
hands[0]
|
||||
};
|
||||
([first.0, hands[1].0], [first.1, hands[1].1])
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Starts a span of the entity geometry at vertex `start` for entity type `name`
|
||||
/// (`minecraft:zombie`; "" for one the pack cannot name). Nothing without a pack.
|
||||
pub(super) fn mark_entity(&self, start: usize, name: &str) {
|
||||
self.mark_entity_part(start, name, false);
|
||||
}
|
||||
|
||||
/// Starts a span for entity geometry that blends (name tags, shadows).
|
||||
pub(super) fn mark_translucent_entity(&self, start: usize, name: &str) {
|
||||
self.mark_entity_part(start, name, true);
|
||||
}
|
||||
|
||||
fn mark_entity_part(&self, start: usize, name: &str, translucent: bool) {
|
||||
if let Some(ids) = &self.pack_ids {
|
||||
let id = ids.entities.get(name).copied();
|
||||
self.push_span(start, "ENTITIES", id.unwrap_or(ids.missing_entity));
|
||||
if let Some(last) = self.entity_spans.borrow_mut().last_mut() {
|
||||
last.translucent = translucent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a span for the block entity of block state `state`.
|
||||
pub(super) fn mark_block_entity(&self, start: usize, state: u32) {
|
||||
let Some(ids) = &self.pack_ids else { return };
|
||||
let id = *ids
|
||||
.block_states
|
||||
.borrow_mut()
|
||||
.entry(state)
|
||||
.or_insert_with(|| {
|
||||
let registry = self.pack.assets.registry;
|
||||
registry
|
||||
.state(horizon_world::registry::BlockStateId(state))
|
||||
.and_then(|s| {
|
||||
ids.blocks
|
||||
.id(&s.name, |k| s.properties.get(k).map(String::as_str))
|
||||
})
|
||||
.unwrap_or(ids.missing_block)
|
||||
});
|
||||
self.push_span(start, "BLOCK_ENTITIES", id);
|
||||
let emission = self
|
||||
.res
|
||||
.facts()
|
||||
.get(horizon_world::registry::BlockStateId(state))
|
||||
.emission;
|
||||
if let Some(last) = self.entity_spans.borrow_mut().last_mut() {
|
||||
last.emissive = emission > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the span of the local player's model; `hidden` in first person, where only a
|
||||
/// pack's shadow pass draws it.
|
||||
pub(super) fn mark_player(&self, start: usize, hidden: bool) {
|
||||
self.mark_entity(start, "minecraft:player");
|
||||
if self.pack_ids.is_some()
|
||||
&& let Some(last) = self.entity_spans.borrow_mut().last_mut()
|
||||
{
|
||||
last.player = true;
|
||||
last.hidden = hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks vertices `start..end` (one entity) as hidden from the camera, for an entity
|
||||
/// Horizon's culling would drop but a pack's shadow pass still draws (`EntitySpan::hide`).
|
||||
pub(super) fn hide_spans_from(&self, start: usize, end: usize) -> bool {
|
||||
EntitySpan::hide(&mut self.entity_spans.borrow_mut(), start, end)
|
||||
}
|
||||
|
||||
/// Starts a span of geometry that is not an entity (`PARTICLES`, `DESTROY`...).
|
||||
pub(super) fn mark_stage(&self, start: usize, stage: &str) {
|
||||
if self.pack_ids.is_some() {
|
||||
self.push_span(start, stage, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_span(&self, start: usize, stage: &str, id: i32) {
|
||||
let mut spans = self.entity_spans.borrow_mut();
|
||||
let span = EntitySpan {
|
||||
start: start as u32,
|
||||
stage: horizon_shaders::render_stage(stage),
|
||||
id,
|
||||
..Default::default()
|
||||
};
|
||||
// A span that drew nothing gives way to the next.
|
||||
match spans.last_mut() {
|
||||
Some(last) if last.start == span.start => *last = span,
|
||||
_ => spans.push(span),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `mc_Entity` (x, y) of each block sprite layer, as (id, fluid) pairs: terrain vertices carry
|
||||
/// their sprite layer, not their block. A layer takes the id of the block named like its sprite
|
||||
/// (`block/oak_leaves` for `oak_leaves`), else the id most blocks drawing it share (ties: the
|
||||
/// lowest); -1 when no block drawing it has one. y is 1 for the fluid sprites, -1 otherwise.
|
||||
fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec<i32> {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let layers = pack
|
||||
.render
|
||||
.sprite_layers
|
||||
.values()
|
||||
.max()
|
||||
.map_or(0, |l| l + 1) as usize;
|
||||
let mut table = vec![-1; layers * 2];
|
||||
if ids.is_empty() {
|
||||
return table;
|
||||
}
|
||||
let registry = pack.assets.registry;
|
||||
let catalog = &pack.render.model_catalog;
|
||||
let sprite = |name: &str| pack.render.sprite_layers.get(name).copied();
|
||||
// (layer, id) -> (a block named like the sprite, the blocks).
|
||||
let mut votes: BTreeMap<(u32, i32), (bool, BTreeSet<&str>)> = BTreeMap::new();
|
||||
for i in 0..registry.len() {
|
||||
let state_id = horizon_world::registry::BlockStateId(i as u32);
|
||||
let Some(state) = registry.state(state_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(id) = ids.id(&state.name, |k| state.properties.get(k).map(String::as_str)) else {
|
||||
continue;
|
||||
};
|
||||
let block = state.name.strip_prefix("minecraft:").unwrap_or(&state.name);
|
||||
let mut vote = |layer: u32| {
|
||||
let named = pack
|
||||
.layer_names
|
||||
.get(&layer)
|
||||
.and_then(|n| n.strip_prefix("block/"))
|
||||
== Some(block);
|
||||
let v = votes.entry((layer, id)).or_default();
|
||||
v.0 |= named;
|
||||
v.1.insert(&state.name);
|
||||
};
|
||||
// Fluids draw from their own sprites.
|
||||
let fluid = match block {
|
||||
"water" => ["water_still", "water_flow"],
|
||||
"lava" => ["lava_still", "lava_flow"],
|
||||
_ => ["", ""],
|
||||
};
|
||||
for f in fluid.iter().filter(|f| !f.is_empty()) {
|
||||
if let Some(l) = sprite(&format!("minecraft:block/{f}")) {
|
||||
vote(l);
|
||||
}
|
||||
}
|
||||
let origin = horizon_core::position::BlockPos::new(0, 0, 0);
|
||||
for (_, model) in catalog.collect(state_id, origin).unwrap_or_default() {
|
||||
for face in &model.faces {
|
||||
vote(face.layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut best: BTreeMap<u32, (bool, usize, i32)> = BTreeMap::new();
|
||||
for ((layer, id), (named, blocks)) in votes {
|
||||
let candidate = (named, blocks.len(), -id);
|
||||
let b = best.entry(layer).or_insert(candidate);
|
||||
if candidate > *b {
|
||||
*b = candidate;
|
||||
}
|
||||
}
|
||||
for (layer, (_, _, id)) in best {
|
||||
if let Some(slot) = table.get_mut(layer as usize * 2) {
|
||||
*slot = -id;
|
||||
}
|
||||
}
|
||||
for f in ["water_still", "water_flow", "lava_still", "lava_flow"] {
|
||||
if let Some(l) = sprite(&format!("minecraft:block/{f}"))
|
||||
&& let Some(slot) = table.get_mut(l as usize * 2 + 1)
|
||||
{
|
||||
*slot = 1;
|
||||
}
|
||||
}
|
||||
table
|
||||
}
|
||||
@@ -272,8 +272,6 @@ impl Loading {
|
||||
}
|
||||
pub(super) fn apply_menu_settings(&mut self) {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
self.settings
|
||||
.clamp_gui_scale([renderer.config.width as i32, renderer.config.height as i32]);
|
||||
renderer.gamma = self.settings.gamma;
|
||||
renderer.set_present_mode(self.settings.vsync, self.settings.horizon.present_mode);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
stations::{self, AnvilResult, EnchantOffers},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
@@ -1160,15 +1161,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let default = self.translate("container.brewing", "Brewing Stand");
|
||||
let title = self
|
||||
.station
|
||||
.as_ref()
|
||||
.and_then(|s| self.world.block_names.get(&(s.pos[0], s.pos[1], s.pos[2])))
|
||||
.cloned()
|
||||
.or_else(|| self.remote_menu_title())
|
||||
.unwrap_or(default);
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(
|
||||
let title = self.remote_menu_title().unwrap_or_else(|| {
|
||||
let named = self
|
||||
.station
|
||||
.as_ref()
|
||||
.and_then(|s| self.world.block_names.get(&(s.pos[0], s.pos[1], s.pos[2])));
|
||||
Text::literal(named.cloned().unwrap_or(default)).resolve_legacy()
|
||||
});
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&title,
|
||||
ox + (176.0 - tw) / 2.0,
|
||||
@@ -1195,7 +1196,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
} else if let Some(kind) = self.station.as_ref().map(|s| s.kind) {
|
||||
let title = self.translate(kind.title_key(), "");
|
||||
// A server's menu has the title it was given.
|
||||
let title = self
|
||||
.remote_menu_title()
|
||||
.unwrap_or_else(|| Text::literal(self.translate(kind.title_key(), "")));
|
||||
{
|
||||
let atlas = &self.gui.atlas;
|
||||
Self::part(
|
||||
@@ -1207,7 +1211,7 @@ impl Game<'_> {
|
||||
(ox, oy),
|
||||
);
|
||||
let (tx, ty) = kind.title_pos();
|
||||
batch.text(atlas, &title, ox + tx, oy + ty, GREY, false);
|
||||
batch.text_styled(atlas, &title, ox + tx, oy + ty, GREY, false);
|
||||
batch.text(atlas, &inventory_label, ox + 8.0, oy + 72.0, GREY, false);
|
||||
}
|
||||
if kind == Kind::Cartography {
|
||||
|
||||
@@ -158,7 +158,8 @@ const COLUMNS: [(&str, &str); 6] = [
|
||||
#[derive(Default)]
|
||||
pub(super) struct StatsUi {
|
||||
tab: usize,
|
||||
scroll: f32,
|
||||
/// The list's scrolling: the wheel and the held scrollbar.
|
||||
scroll: horizon_client::ui::Scroll,
|
||||
sort: Option<(usize, i32)>,
|
||||
}
|
||||
/// `MenuTabBar` geometry: three tabs over at most 400 pixels.
|
||||
@@ -182,6 +183,15 @@ impl Game<'_> {
|
||||
self.stats_ui = StatsUi::default();
|
||||
self.screen = Screen::Stats;
|
||||
}
|
||||
/// The list's scrollbar column (right of the 280 pixel list) and the
|
||||
/// pixels it scrolls.
|
||||
fn stats_bar(w: f32) -> f32 {
|
||||
((w - 280.0) / 2.0).floor() + 280.0 + 6.0
|
||||
}
|
||||
fn stats_content(&self) -> f32 {
|
||||
let (row_h, rows) = self.stats_rows();
|
||||
row_h * rows as f32 + 4.0
|
||||
}
|
||||
fn stats_rows(&self) -> (f32, usize) {
|
||||
match self.stats_ui.tab {
|
||||
0 => (14.0, CUSTOM.len()),
|
||||
@@ -260,10 +270,9 @@ impl Game<'_> {
|
||||
// The list.
|
||||
let (row_h, rows) = self.stats_rows();
|
||||
let content = row_h * rows as f32 + 4.0;
|
||||
let max_scroll = (content - (bottom - top)).max(0.0);
|
||||
self.stats_ui.scroll = self.stats_ui.scroll.clamp(0.0, max_scroll);
|
||||
self.stats_ui.scroll.clamp(top, bottom, content);
|
||||
let left = ((w - 280.0) / 2.0).floor();
|
||||
let scroll = self.stats_ui.scroll;
|
||||
let scroll = self.stats_ui.scroll.amount;
|
||||
let row_y = |i: usize| top + 4.0 + i as f32 * row_h - scroll;
|
||||
let visible = |y: f32| y >= top && y + row_h <= bottom;
|
||||
match self.stats_ui.tab {
|
||||
@@ -408,11 +417,11 @@ impl Game<'_> {
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
left + 280.0 + 6.0,
|
||||
Self::stats_bar(w),
|
||||
top,
|
||||
bottom,
|
||||
content,
|
||||
self.stats_ui.scroll,
|
||||
self.stats_ui.scroll.amount,
|
||||
);
|
||||
self.draw_stats_footer(batch, w, h);
|
||||
}
|
||||
@@ -433,6 +442,10 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
pub(super) fn stats_click(&mut self, size: [u32; 2]) {
|
||||
// A press on the scrollbar holds it and clicks nothing else.
|
||||
if self.stats_press_scrollbar(size) {
|
||||
return;
|
||||
}
|
||||
let b = HudBatch::new(size);
|
||||
let (w, h) = (b.width, b.height);
|
||||
let (x0, tw) = tabs(w);
|
||||
@@ -441,7 +454,7 @@ impl Game<'_> {
|
||||
&& self.stats_ui.tab != i
|
||||
{
|
||||
self.stats_ui.tab = i;
|
||||
self.stats_ui.scroll = 0.0;
|
||||
self.stats_ui.scroll.amount = 0.0;
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
return;
|
||||
@@ -461,7 +474,7 @@ impl Game<'_> {
|
||||
}
|
||||
if self.stats_ui.tab == 1 {
|
||||
let left = ((w - 280.0) / 2.0).floor();
|
||||
let y = TAB_H + 4.0 - self.stats_ui.scroll;
|
||||
let y = TAB_H + 4.0 - self.stats_ui.scroll.amount;
|
||||
for c in 0..COLUMNS.len() {
|
||||
let x = left + 75.0 + 40.0 * c as f32 - 18.0;
|
||||
if ui::hovered(self.cursor, x, y + 1.0, 18.0, 18.0) {
|
||||
@@ -479,6 +492,35 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn stats_scroll(&mut self, amount: f32) {
|
||||
let (row_h, _) = self.stats_rows();
|
||||
self.stats_ui.scroll -= amount * row_h / 2.0;
|
||||
let h = self.gui_size().1;
|
||||
let content = self.stats_content();
|
||||
self.stats_ui
|
||||
.scroll
|
||||
.wheel(amount, row_h / 2.0, TAB_H, h - FOOTER, content);
|
||||
}
|
||||
/// A press on the statistics list's scrollbar holds it
|
||||
/// (`AbstractSelectionList.updateScrolling`).
|
||||
pub(super) fn stats_press_scrollbar(&mut self, size: [u32; 2]) -> bool {
|
||||
let b = HudBatch::new(size);
|
||||
let (w, h) = (b.width, b.height);
|
||||
let content = self.stats_content();
|
||||
self.stats_ui
|
||||
.scroll
|
||||
.press(self.cursor, Self::stats_bar(w), TAB_H, h - FOOTER, content)
|
||||
}
|
||||
/// The pointer moved while the scrollbar is held.
|
||||
pub(super) fn stats_drag(&mut self) {
|
||||
if !self.stats_ui.scroll.held() {
|
||||
return;
|
||||
}
|
||||
let h = self.gui_size().1;
|
||||
let content = self.stats_content();
|
||||
self.stats_ui
|
||||
.scroll
|
||||
.drag(self.cursor.1, TAB_H, h - FOOTER, content);
|
||||
}
|
||||
/// The button came up: the scrollbar lets go.
|
||||
pub(super) fn stats_release(&mut self) {
|
||||
self.stats_ui.scroll.release();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! kept in the dropped item's `container` component) and ender chests (the
|
||||
//! player's `PlayerEnderChestContainer`, saved as `EnderItems`).
|
||||
use super::Game;
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack};
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, text::Text};
|
||||
|
||||
pub(super) fn is_shulker_box(name: &str) -> bool {
|
||||
name.ends_with("shulker_box")
|
||||
@@ -233,7 +233,7 @@ impl Game<'_> {
|
||||
generic.then(|| self.container_size() / 9)
|
||||
}
|
||||
/// The title of the open chest-like menu.
|
||||
fn chest_title(&self, rows: usize) -> String {
|
||||
fn chest_title(&self, rows: usize) -> Text {
|
||||
if let Some((_, title)) = self.remote_chest_menu() {
|
||||
return title;
|
||||
}
|
||||
@@ -246,7 +246,7 @@ impl Game<'_> {
|
||||
_ if rows > 3 => ("container.chestDouble", "Large Chest"),
|
||||
_ => ("container.chest", "Chest"),
|
||||
};
|
||||
self.container_title(self.translate(key, fallback))
|
||||
Text::literal(self.container_title(self.translate(key, fallback))).resolve_legacy()
|
||||
}
|
||||
/// `ContainerScreen` / `ShulkerBoxScreen`: the background with its
|
||||
/// container rows, the title and the inventory label.
|
||||
@@ -294,7 +294,7 @@ impl Game<'_> {
|
||||
r.u1 = r.u0 + 176.0 * du;
|
||||
batch.rect_uv(ox, oy + top, 176.0, 96.0, r, u32::MAX);
|
||||
}
|
||||
batch.text(
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&self.chest_title(rows),
|
||||
ox + 8.0,
|
||||
|
||||
@@ -33,7 +33,7 @@ impl PrimedTnt {
|
||||
),
|
||||
)
|
||||
}
|
||||
fn tick(
|
||||
pub(super) fn tick(
|
||||
&mut self,
|
||||
world: &horizon_client::world::World,
|
||||
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
|
||||
|
||||
@@ -52,10 +52,6 @@ fn triggers(name: &str) -> bool {
|
||||
impl Game<'_> {
|
||||
/// `WindChargeItem.use`.
|
||||
pub(super) fn throw_wind_charge(&mut self) -> Result<bool> {
|
||||
if self.creatures.wind_cooldown > 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
self.creatures.wind_cooldown = 10;
|
||||
let (Some(eye), Some(p)) = (self.eye_position(), self.player.as_ref()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
@@ -246,6 +246,9 @@ pub(super) fn make_streaming(
|
||||
let inner = Arc::new(VanillaTerrain::for_world(&options.resources.join("worldgen"), pack.assets.registry, res.biomes(), seed, dim.id(), structures).map_err(|e| {
|
||||
format!("{e}\nPrepare the worldgen bundle with the launcher, or: horizon-prepare --prepare")
|
||||
})?);
|
||||
if dim == Dim::Overworld && structures {
|
||||
inner.search_rings_behind();
|
||||
}
|
||||
locator = Some(inner.clone());
|
||||
Streaming::new(
|
||||
Arc::new(Persistent {
|
||||
|
||||
@@ -67,10 +67,18 @@ mod end_sky;
|
||||
mod flat;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod native_post;
|
||||
mod occlusion;
|
||||
mod pack_atlas;
|
||||
pub mod pack_draw;
|
||||
mod pack_entities;
|
||||
mod pack_hand;
|
||||
mod pack_post;
|
||||
mod pack_shadow;
|
||||
mod pack_sky;
|
||||
mod pack_timing;
|
||||
pub mod pack_uniforms;
|
||||
mod pack_weather;
|
||||
mod page_runs;
|
||||
pub mod panorama;
|
||||
mod resident;
|
||||
@@ -86,6 +94,19 @@ const COMPLETE_COLUMN: u32 = (1 << (crate::chunk::HEIGHT / 16)) - 1;
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
/// The world pass again after occlusion culling's test: it loads both
|
||||
/// targets (phase 2 draws over phase 1, reading the test's results).
|
||||
/// LevelRenderer: no sky inside lava or powder snow (sky fog ends within 2 blocks).
|
||||
fn sky_open(frame: &Frame) -> bool {
|
||||
match frame.fog {
|
||||
Some(f) => f.sky_end > 2.0,
|
||||
None => !frame.underwater,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Overworld-style sky (disc, sunset, stars, void) draws this frame.
|
||||
fn sky_visible(frame: &Frame) -> bool {
|
||||
!frame.title && frame.skybox && sky_open(frame)
|
||||
}
|
||||
|
||||
fn world_pass_after_culling<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
@@ -232,6 +253,8 @@ pub struct Frame<'a> {
|
||||
pub nether_lighting: bool,
|
||||
/// Camera-relative entity geometry textured from the shared atlas.
|
||||
pub entities: &'a [Vertex],
|
||||
/// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`).
|
||||
pub entity_spans: &'a [pack_draw::EntitySpan],
|
||||
pub weather: &'a [crate::weather::Vertex],
|
||||
pub weather_time: crate::weather::Time,
|
||||
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
|
||||
@@ -449,6 +472,8 @@ pub struct Renderer {
|
||||
/// Per-frame scratch, kept to avoid allocating every frame.
|
||||
visible: Vec<resident::Visible>,
|
||||
visible_lods: Vec<lod::Visible>,
|
||||
/// The sections a shader pack's shadow camera sees (`pack_post::ShadowView`).
|
||||
shadow_visible: Vec<resident::Visible>,
|
||||
translucent_draws: Vec<(u64, usize, [u32; 5], (u32, Option<u32>))>,
|
||||
origins: Vec<[f32; 4]>,
|
||||
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
|
||||
@@ -463,6 +488,8 @@ pub struct Renderer {
|
||||
/// Each section's solid draws, to group them by arena page.
|
||||
solid_runs: page_runs::PageRuns,
|
||||
phase2_runs: page_runs::PageRuns,
|
||||
/// The shadow camera's draws by page, in bands of distance from the light.
|
||||
shadow_runs: page_runs::PageRuns,
|
||||
/// Block-entity blocks per meshed section (world position, state id).
|
||||
block_entities: HashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
/// Changes whenever `block_entities` does (unique across renderers).
|
||||
@@ -473,8 +500,20 @@ pub struct Renderer {
|
||||
world_post: world_post::Post,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
native_post: Option<native_post::NativePost>,
|
||||
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
|
||||
pass_timing: bool,
|
||||
/// The entity vertices Horizon draws itself while a shader pack draws the others.
|
||||
pack_entity_rest: Vec<Vertex>,
|
||||
/// Set 1 of the cloud pipeline, for a shader pack's `gbuffers_clouds`.
|
||||
cloud_bindings: wgpu::BindGroupLayout,
|
||||
/// Rain and snow handed to a shader pack's `gbuffers_weather`, once a pack draws them.
|
||||
pack_weather: Option<pack_weather::PackWeather>,
|
||||
/// The block sprites as one atlas, while a shader pack is loaded.
|
||||
pack_atlas: Option<pack_atlas::PackAtlas>,
|
||||
/// The pack's terrain program reads flat draws' vertices (`pack_terrain_inputs`).
|
||||
pack_terrain_flat: bool,
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
@@ -842,12 +881,17 @@ async fn open_device(
|
||||
continue;
|
||||
}
|
||||
// Mappable primary buffers: section pages that no longer fit in
|
||||
// video memory go to system memory.
|
||||
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
|
||||
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
|
||||
// stand-in neither clamps nor drops NaN.
|
||||
let features = timing::GpuTimer::features(&adapter)
|
||||
| cull::Cull::features(&adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS));
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
|
||||
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
|
||||
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
|
||||
let driver = format!("{} {}", info.driver, info.driver_info);
|
||||
startup_step(format_args!(
|
||||
"requesting the device (driver {}) with features [{features}] and the adapter's \
|
||||
@@ -1484,7 +1528,10 @@ impl Renderer {
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
// COPY_SRC: a shader pack's weather copies the rain and snow sprites out.
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
@@ -2149,13 +2196,16 @@ impl Renderer {
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let flat_wanted = flat::wanted(&adapter, &device);
|
||||
// Readable as storage where flat draws run: a shader pack's terrain program reads them.
|
||||
let arena = arena::Arena::new(
|
||||
&device,
|
||||
std::mem::size_of::<TerrainVertex>() as u64,
|
||||
1 << 20,
|
||||
flat_wanted,
|
||||
);
|
||||
// Sections as one index list per frame where multi-draw is emulated.
|
||||
let flat = flat::wanted(&adapter, &device).then(|| {
|
||||
let flat = flat_wanted.then(|| {
|
||||
let buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
@@ -2349,6 +2399,7 @@ impl Renderer {
|
||||
multi_draw,
|
||||
visible: Vec::new(),
|
||||
visible_lods: Vec::new(),
|
||||
shadow_visible: Vec::new(),
|
||||
translucent_draws: Vec::new(),
|
||||
origins: Vec::new(),
|
||||
draws: Vec::new(),
|
||||
@@ -2357,12 +2408,19 @@ impl Renderer {
|
||||
phase2_pages: Vec::new(),
|
||||
solid_runs: page_runs::PageRuns::default(),
|
||||
phase2_runs: page_runs::PageRuns::default(),
|
||||
shadow_runs: page_runs::PageRuns::default(),
|
||||
block_entities: HashMap::new(),
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
world_post,
|
||||
pack_post: None,
|
||||
native_post: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
pack_entity_rest: Vec::new(),
|
||||
cloud_bindings,
|
||||
pack_weather: None,
|
||||
pack_atlas: None,
|
||||
pack_terrain_flat: false,
|
||||
hud,
|
||||
menu_blur: None,
|
||||
info,
|
||||
@@ -2548,6 +2606,18 @@ impl Renderer {
|
||||
pub fn section_count(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// What a shader pack's shadow pass draws of the entities this frame: (entities or block
|
||||
/// entities other than the player, so those Horizon's culling hides from the camera are
|
||||
/// kept for it; the local player, so its model is built in first person too). (false,
|
||||
/// false) without a pack or its shadow pass.
|
||||
pub fn pack_shadow_casters(&self) -> (bool, bool) {
|
||||
let Some((_, Some(post))) = &self.pack_post else {
|
||||
return (false, false);
|
||||
};
|
||||
let c = post.shadow_casters();
|
||||
((c.entities && !c.only_player) || c.blocks, c.entities)
|
||||
}
|
||||
|
||||
pub fn has_section(&self, key: SectionKey) -> bool {
|
||||
self.sections.contains(key)
|
||||
}
|
||||
@@ -2793,24 +2863,114 @@ impl Renderer {
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
self.world_post.antialias = antialias;
|
||||
}
|
||||
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`.
|
||||
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
|
||||
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) pair.
|
||||
/// Returns the programs that run and the notes of loading them.
|
||||
pub fn set_shader_pack(
|
||||
&mut self,
|
||||
dimension: i32,
|
||||
pack: Option<horizon_shaders::Post>,
|
||||
block_ids: &[i32],
|
||||
) -> Option<(Vec<String>, Vec<String>)> {
|
||||
if pack.is_none() {
|
||||
self.pack_atlas = None;
|
||||
}
|
||||
let instance = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let vertex = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &TERRAIN_ATTRIBUTES,
|
||||
};
|
||||
let instances = wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance,
|
||||
};
|
||||
let buffers = [Some(vertex), Some(instances)];
|
||||
let flat = self.pack_terrain_flat;
|
||||
let post = pack.map(|post| {
|
||||
let world = pack_post::World {
|
||||
atlas: self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size())),
|
||||
lightmap: self.lightmap_view.clone(),
|
||||
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
|
||||
buffers: if flat { &buffers[..1] } else { &buffers },
|
||||
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
ids: None,
|
||||
cull: true,
|
||||
}),
|
||||
block_ids,
|
||||
celestial: self
|
||||
.hud
|
||||
.atlas_texture
|
||||
.as_ref()
|
||||
.map(|t| t.create_view(&Default::default())),
|
||||
clouds: Some(&self.cloud_bindings),
|
||||
weather: true,
|
||||
hand: true,
|
||||
};
|
||||
pack_post::PackPost::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
post,
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
world,
|
||||
)
|
||||
});
|
||||
if post.is_none() {
|
||||
self.pack_atlas = None;
|
||||
} else if self.pack_atlas.is_none() {
|
||||
let report = post.as_ref().map(|p| {
|
||||
let programs = p.programs().into_iter().map(str::to_owned).collect();
|
||||
(programs, p.notes.clone())
|
||||
});
|
||||
self.pack_post = Some((dimension, post));
|
||||
if self.pack_post.as_ref().is_some_and(|p| p.1.is_some()) {
|
||||
self.native_post = None;
|
||||
}
|
||||
report
|
||||
}
|
||||
/// Runs the post passes of one of Horizon's own shader packs over the world from now on
|
||||
/// (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
|
||||
/// passes' labels; on an error the passes that ran keep running.
|
||||
pub fn set_native_shader_pack(
|
||||
&mut self,
|
||||
pack: Option<(horizon_shader_api::Report, Vec<(String, f64)>)>,
|
||||
) -> std::result::Result<Vec<String>, String> {
|
||||
let stages = pack.map(|(report, constants)| {
|
||||
let post: Vec<_> = report
|
||||
.stages
|
||||
.into_iter()
|
||||
.filter(|s| s.kind == horizon_shader_api::StageKind::Post)
|
||||
.collect();
|
||||
(post, constants)
|
||||
});
|
||||
let Some((post, constants)) = stages.filter(|(post, _)| !post.is_empty()) else {
|
||||
self.native_post = None;
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
let post = native_post::NativePost::new(&self.device, post, &constants, format)?;
|
||||
let labels = post.labels().into_iter().map(str::to_owned).collect();
|
||||
self.pack_post = None;
|
||||
self.pack_atlas = None;
|
||||
self.native_post = Some(post);
|
||||
Ok(labels)
|
||||
}
|
||||
/// What a shader pack's gbuffers programs read from Horizon's geometry: the sky's, and
|
||||
/// terrain's in the pack's block atlas (made here when missing; none when it does not fit).
|
||||
pub fn pack_inputs(&mut self) -> horizon_shaders::GameInputs {
|
||||
horizon_shaders::GameInputs {
|
||||
terrain: self.pack_terrain_inputs(),
|
||||
sky: Some(pack_sky::inputs()),
|
||||
celestial: Some(pack_sky::celestial_inputs()),
|
||||
entities: Some(pack_entities::inputs()),
|
||||
clouds: Some(pack_entities::cloud_inputs()),
|
||||
weather: Some(pack_weather::inputs()),
|
||||
hand: Some(pack_hand::inputs()),
|
||||
}
|
||||
}
|
||||
|
||||
fn pack_terrain_inputs(&mut self) -> Option<horizon_shaders::GbuffersInputs> {
|
||||
if self.pack_atlas.is_none() {
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
let layers = self
|
||||
.sprite_textures
|
||||
@@ -2827,12 +2987,41 @@ impl Renderer {
|
||||
);
|
||||
self.queue.submit([encoder.finish()]);
|
||||
}
|
||||
let report = post.as_ref().map(|p| {
|
||||
let programs = p.programs().into_iter().map(str::to_owned).collect();
|
||||
(programs, p.notes.clone())
|
||||
});
|
||||
self.pack_post = Some((dimension, post));
|
||||
report
|
||||
// Flat draws whenever the device has them (`render` keeps them on with a pack).
|
||||
self.pack_terrain_flat = self.flat.is_some() && self.flat_on;
|
||||
Some(
|
||||
self.pack_atlas
|
||||
.as_ref()?
|
||||
.terrain_inputs(self.pack_terrain_flat),
|
||||
)
|
||||
}
|
||||
/// Times the shader pack's passes on the GPU while `on` (the F3 overlay is open), or always
|
||||
/// with `HORIZON_SHADER_PACK_TIMING`.
|
||||
pub fn set_pass_timing(&mut self, on: bool) {
|
||||
self.pass_timing = on || pack_timing::PassTiming::logging();
|
||||
}
|
||||
/// The running shader pack's timer, of either kind.
|
||||
fn pass_timer<'a>(
|
||||
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
|
||||
native: &'a mut Option<native_post::NativePost>,
|
||||
) -> Option<&'a mut Option<pack_timing::PassTiming>> {
|
||||
match (pack, native) {
|
||||
(Some((_, Some(post))), _) => Some(&mut post.timing),
|
||||
(_, Some(post)) => Some(&mut post.timing),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Each pass of the running shader pack with its median GPU time in milliseconds, while
|
||||
/// they are timed (`set_pass_timing`).
|
||||
pub fn shader_pass_times(&self) -> Vec<(String, f32)> {
|
||||
let timer = match (&self.pack_post, &self.native_post) {
|
||||
(Some((_, Some(post))), _) => post.timing.as_ref(),
|
||||
(_, Some(post)) => post.timing.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
timer
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// The dimension the shader pack was last set for.
|
||||
pub fn shader_pack_dimension(&self) -> Option<i32> {
|
||||
@@ -3208,8 +3397,43 @@ impl Renderer {
|
||||
);
|
||||
visible_lods.sort_unstable_by_key(|v| (v.distance, v.key));
|
||||
}
|
||||
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
|
||||
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul.
|
||||
let shadow_view = self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.filter(|_| !frame.title)
|
||||
.and_then(|p| p.shadow_view(frame));
|
||||
let mut shadow_visible = std::mem::take(&mut self.shadow_visible);
|
||||
shadow_visible.clear();
|
||||
if let Some(v) = &shadow_view {
|
||||
let radius = (frame.detail_distance / 16.0) as i32;
|
||||
let radius = v
|
||||
.distance
|
||||
.map_or(radius, |d| radius.min((d / 16.0).ceil() as i32));
|
||||
let light = Frustum {
|
||||
planes: frustum(v.cull),
|
||||
};
|
||||
crate::spans::time("shadow visibility", || {
|
||||
self.sections.visible(
|
||||
camera,
|
||||
radius,
|
||||
&light,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut shadow_visible,
|
||||
)
|
||||
});
|
||||
// Nearest the light first, so the depth test rejects what lies under it.
|
||||
let light = v.light;
|
||||
shadow_visible.sort_unstable_by(|a, b| {
|
||||
let height = |s: &resident::Visible| glam::Vec3::from(s.origin).dot(light);
|
||||
height(b).total_cmp(&height(a))
|
||||
});
|
||||
}
|
||||
self.drawn_sections = visible.len() + visible_lods.len();
|
||||
let instances = self.drawn_sections;
|
||||
let instances = self.drawn_sections + 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 {
|
||||
@@ -3226,7 +3450,8 @@ impl Renderer {
|
||||
visible
|
||||
.iter()
|
||||
.map(|v| section_instance(v.origin))
|
||||
.chain(visible_lods.iter().map(|v| lod::instance(v.origin))),
|
||||
.chain(visible_lods.iter().map(|v| lod::instance(v.origin)))
|
||||
.chain(shadow_visible.iter().map(|v| section_instance(v.origin))),
|
||||
);
|
||||
if !self.origins.is_empty() {
|
||||
self.uploads.buffer(
|
||||
@@ -3243,11 +3468,23 @@ impl Renderer {
|
||||
// Occlusion culling leaves the sections its last result did not see
|
||||
// to phase 2, after the others, with instance counts of 0. Whether
|
||||
// the frame has draws enough goes by the last one's.
|
||||
// A shader pack drawing terrain draws all of it: occlusion culling reads the depth of
|
||||
// Horizon's own terrain draw.
|
||||
let pack_terrain = !frame.title
|
||||
&& self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.is_some_and(|p| p.has_terrain());
|
||||
let mut cull = self
|
||||
.cull
|
||||
.as_mut()
|
||||
.filter(|c| {
|
||||
self.cull_on && !frame.title && self.multi_draw && visible.len() >= c.min_sections
|
||||
self.cull_on
|
||||
&& !frame.title
|
||||
&& !pack_terrain
|
||||
&& self.multi_draw
|
||||
&& visible.len() >= c.min_sections
|
||||
})
|
||||
.and_then(|cull| cull.begin(&self.device).then_some(cull));
|
||||
self.phase2_draws.clear();
|
||||
@@ -3418,9 +3655,87 @@ impl Renderer {
|
||||
self.translucency_cursor += budget;
|
||||
let translucent_draws = self.draws.len() as u32 - solid_draws;
|
||||
self.section_draws = self.draws.len();
|
||||
// The shadow camera's sections after the camera's: quads that can face the light, then
|
||||
// translucent ones (in their last sorted order; depth decides there).
|
||||
let shadow_first = self.draws.len() as u32;
|
||||
let mut shadow_translucent = Vec::new();
|
||||
self.shadow_runs.clear();
|
||||
if let Some(view) = &shadow_view {
|
||||
let base = self.drawn_sections;
|
||||
let toward = view.light * 1.0e5;
|
||||
// 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);
|
||||
let top = shadow_visible.first().map_or(0.0, height);
|
||||
for (k, v) in shadow_visible.iter().enumerate() {
|
||||
let s = self.sections.get_mut(v.slot);
|
||||
let instance = (base + k) as u32;
|
||||
let depth = f64::from(top - height(v)).max(0.0);
|
||||
let distance = (depth * depth).to_bits();
|
||||
// A section the camera never saw has no sorted indices yet: sorted once now.
|
||||
if let Some((index_page, index_start)) = s.sorted_indices
|
||||
&& !s.sort.is_sorted()
|
||||
{
|
||||
let origin = [
|
||||
v.key.0 * 16,
|
||||
crate::chunk::MIN_Y + v.key.1 * 16,
|
||||
v.key.2 * 16,
|
||||
];
|
||||
if let Some(indices) =
|
||||
s.sort
|
||||
.update(camera.to_array(), origin, &mut self.translucent_sorter)
|
||||
{
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
self.sorted_indices.buffer(index_page),
|
||||
index_start * 4,
|
||||
bytemuck::cast_slice(indices),
|
||||
);
|
||||
}
|
||||
}
|
||||
let before = self.draws.len();
|
||||
facing_runs(&s.sides, Some(toward), v.origin, |first, quads| {
|
||||
let start = s.start as u32 + first * 4;
|
||||
self.draws.push([quads * 6, 1, 0, start, instance]);
|
||||
self.draw_pages.push((s.page, None));
|
||||
});
|
||||
let len = self.draws.len() - before;
|
||||
self.shadow_runs.push(distance, s.page, before, len, [0; 3]);
|
||||
if let Some((index_page, index_start)) = s.sorted_indices {
|
||||
let start = s.start + u64::from(s.solid) * 4;
|
||||
shadow_translucent.push((
|
||||
distance,
|
||||
[
|
||||
s.translucent * 6,
|
||||
1,
|
||||
index_start as u32,
|
||||
start as u32,
|
||||
instance,
|
||||
],
|
||||
(s.page, Some(index_page)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let shadow_solid = self.draws.len() as u32 - shadow_first;
|
||||
self.shadow_runs
|
||||
.regroup(&mut self.draws, &mut self.draw_pages, shadow_first as usize);
|
||||
self.shadow_runs.clear();
|
||||
for (distance, draw, pages) in shadow_translucent {
|
||||
self.shadow_runs
|
||||
.push(distance, pages.0, self.draws.len(), 1, [0; 3]);
|
||||
self.draws.push(draw);
|
||||
self.draw_pages.push(pages);
|
||||
}
|
||||
self.shadow_runs.regroup(
|
||||
&mut self.draws,
|
||||
&mut self.draw_pages,
|
||||
(shadow_first + shadow_solid) as usize,
|
||||
);
|
||||
let shadow_translucent = self.draws.len() as u32 - shadow_first - shadow_solid;
|
||||
// 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.draws.len())
|
||||
(0..self.section_draws)
|
||||
.filter(|&i| {
|
||||
i == 0
|
||||
|| i == phase1_draws as usize
|
||||
@@ -3429,7 +3744,7 @@ impl Renderer {
|
||||
})
|
||||
.count()
|
||||
} else {
|
||||
self.draws.len()
|
||||
self.section_draws
|
||||
};
|
||||
let debug_depth = frame.debug_lines.len() as u32;
|
||||
let debug_top = frame.debug_lines_on_top.len() as u32;
|
||||
@@ -3472,6 +3787,22 @@ impl Renderer {
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
|
||||
}
|
||||
// While a shader pack draws entities, the runs of `entity_spans` its programs draw are
|
||||
// left out of Horizon's own entity draw.
|
||||
let mut entities = frame.entities;
|
||||
if !frame.title
|
||||
&& let Some((_, Some(post))) = &mut self.pack_post
|
||||
&& post.has_entities()
|
||||
{
|
||||
post.prepare_entities(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
frame.entities,
|
||||
frame.entity_spans,
|
||||
&mut self.pack_entity_rest,
|
||||
);
|
||||
entities = &self.pack_entity_rest;
|
||||
}
|
||||
let [
|
||||
entity_quads,
|
||||
crumbling_quads,
|
||||
@@ -3479,12 +3810,9 @@ impl Renderer {
|
||||
water_mask_quads,
|
||||
shadow_quads,
|
||||
translucent_particle_quads,
|
||||
] = self.entity_batch.update(
|
||||
frame.entities,
|
||||
frame.additive,
|
||||
frame.hand,
|
||||
frame.see_through,
|
||||
);
|
||||
] = self
|
||||
.entity_batch
|
||||
.update(entities, frame.additive, frame.hand, frame.see_through);
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads = entity_quads
|
||||
+ crumbling_quads
|
||||
@@ -3653,7 +3981,9 @@ impl Renderer {
|
||||
Some(flat)
|
||||
if self.flat_on
|
||||
&& !self.draws.is_empty()
|
||||
&& self.drawn_sections >= flat.min_sections =>
|
||||
&& (self.drawn_sections >= flat.min_sections
|
||||
|| pack_terrain
|
||||
|| shadow_view.is_some()) =>
|
||||
{
|
||||
let sections = &self.sections;
|
||||
let lods = &self.lods;
|
||||
@@ -3668,9 +3998,11 @@ impl Renderer {
|
||||
&self.draw_pages,
|
||||
phase1_draws as usize,
|
||||
solid_draws as usize,
|
||||
[shadow_first, shadow_first + shadow_solid].map(|d| d as usize),
|
||||
phase2,
|
||||
visible
|
||||
.iter()
|
||||
.chain(&shadow_visible)
|
||||
.filter_map(|v| {
|
||||
let id = sections.get(v.slot).flat_id?;
|
||||
Some((id, section_instance(v.origin)))
|
||||
@@ -3686,7 +4018,7 @@ impl Renderer {
|
||||
_ => false,
|
||||
};
|
||||
if flat_frame {
|
||||
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.calls.len());
|
||||
self.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();
|
||||
@@ -3713,10 +4045,61 @@ impl Renderer {
|
||||
}
|
||||
// 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());
|
||||
// One of Horizon's own packs reads the same world target.
|
||||
let native_post = !frame.title && self.native_post.is_some();
|
||||
if let Some(timer) = Self::pass_timer(&mut self.pack_post, &mut self.native_post)
|
||||
&& timer.is_some() != self.pass_timing
|
||||
{
|
||||
*timer = self
|
||||
.pass_timing
|
||||
.then(|| pack_timing::PassTiming::new(&self.device, &self.queue))
|
||||
.flatten();
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
// The pack's program reads the vertices this frame's draws bind.
|
||||
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
|
||||
// The shadow camera's lists, drawn by the pack's shadow programs.
|
||||
let pack_draws_shadow = shadow_view.is_some() && flat_frame == self.pack_terrain_flat;
|
||||
let pack_draws_sky = pack_post
|
||||
&& self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.is_some_and(|p| p.has_sky());
|
||||
let pack_draws_celestial = pack_post
|
||||
&& self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.is_some_and(|p| p.has_celestial());
|
||||
let pack_draws_water = pack_draws_terrain
|
||||
&& self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.is_some_and(|p| p.has_water());
|
||||
let pack_draws_clouds = pack_post
|
||||
&& self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.is_some_and(|p| p.has_clouds());
|
||||
let pack_draws_hand = pack_post
|
||||
&& self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.is_some_and(|p| p.has_hand());
|
||||
let pack_draws_weather = pack_post
|
||||
&& self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.is_some_and(|p| p.has_weather());
|
||||
let scaled = self.world_post.prepare(
|
||||
&self.device,
|
||||
[self.config.width, self.config.height],
|
||||
pack_post,
|
||||
pack_reads_world,
|
||||
);
|
||||
let (world_view, world_depth) = match &scaled {
|
||||
Some((color, depth)) => (color, depth),
|
||||
@@ -3755,7 +4138,7 @@ impl Renderer {
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
// Phase 2 of occlusion culling draws over it; a shader pack reads it.
|
||||
store: if cull.is_some() || pack_post {
|
||||
store: if cull.is_some() || pack_reads_world {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
@@ -3766,13 +4149,9 @@ impl Renderer {
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
// LevelRenderer: no sky inside lava or powder snow (sky fog ends within 2 blocks).
|
||||
let sky_visible = match frame.fog {
|
||||
Some(f) => f.sky_end > 2.0,
|
||||
None => !frame.underwater,
|
||||
};
|
||||
let sky_visible = sky_open(frame);
|
||||
if frame.title || (sky_visible && (frame.skybox || frame.end_sky)) {
|
||||
if !frame.title && frame.skybox {
|
||||
if !frame.title && frame.skybox && !pack_draws_sky {
|
||||
pass.set_pipeline(&self.sky);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
@@ -3786,22 +4165,29 @@ impl Renderer {
|
||||
if frame.end_sky {
|
||||
self.end_sky.draw(&mut pass);
|
||||
}
|
||||
if celestial_vertices > 0 {
|
||||
if celestial_vertices > 0 && !pack_draws_celestial {
|
||||
pass.set_vertex_buffer(0, self.celestial_buffer.slice(..));
|
||||
pass.draw_indexed(0..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !frame.title && frame.stars.is_some() {
|
||||
if !frame.title && frame.stars.is_some() && !pack_draws_sky {
|
||||
self.stars.draw(&mut pass);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !frame.title && frame.dark_disc {
|
||||
if !frame.title && frame.dark_disc && !pack_draws_sky {
|
||||
pass.set_pipeline(&self.dark_disc);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
match self.flat.as_ref().filter(|_| flat_frame) {
|
||||
match self
|
||||
.flat
|
||||
.as_ref()
|
||||
.filter(|_| flat_frame && !pack_draws_terrain)
|
||||
{
|
||||
_ if pack_draws_terrain => {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(&flat.solid);
|
||||
let vertices = |p| self.arena.buffer(p);
|
||||
@@ -3818,7 +4204,7 @@ impl Renderer {
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_post,
|
||||
pack_reads_world,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_pipeline(&flat.solid);
|
||||
@@ -3847,7 +4233,7 @@ impl Renderer {
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_post,
|
||||
pack_reads_world,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_pipeline(&self.solid);
|
||||
@@ -3875,7 +4261,9 @@ impl Renderer {
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
self.weather.draw(&mut pass, &self.entity_group);
|
||||
if !pack_draws_weather {
|
||||
self.weather.draw(&mut pass, &self.entity_group);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
if crumbling_quads > 0 {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
@@ -3937,9 +4325,11 @@ impl Renderer {
|
||||
pass.draw(0..4, debug_depth..debug_depth + debug_top);
|
||||
}
|
||||
match self.flat.as_ref().filter(|_| flat_frame) {
|
||||
// The pack draws translucent terrain after its deferred programs.
|
||||
_ if pack_draws_water => {}
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(&flat.translucent);
|
||||
flat.draw(&mut pass, &flat.calls[flat.solid_calls..], |p| {
|
||||
flat.draw(&mut pass, flat.translucent_calls(), |p| {
|
||||
self.arena.buffer(p)
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -4004,7 +4394,7 @@ impl Renderer {
|
||||
0..1,
|
||||
);
|
||||
}
|
||||
if cloud_quads > 0 {
|
||||
if cloud_quads > 0 && !pack_draws_clouds {
|
||||
pass.set_pipeline(&self.cloud_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_bind_group(1, &self.cloud_group, &[]);
|
||||
@@ -4025,9 +4415,79 @@ impl Renderer {
|
||||
}
|
||||
if let Some((color, depth)) = &scaled
|
||||
&& pack_post
|
||||
&& let Some((_, Some(post))) = &mut self.pack_post
|
||||
&& let Some((dimension, Some(mut post))) = self.pack_post.take()
|
||||
{
|
||||
let size = self.world_post.size().unwrap_or([1, 1]);
|
||||
let weather = match &self.hud.atlas_texture {
|
||||
Some(atlas) if pack_draws_weather => self
|
||||
.pack_weather
|
||||
.get_or_insert_with(|| pack_weather::PackWeather::new(&self.device))
|
||||
.prepare(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&mut self.uploads,
|
||||
atlas,
|
||||
frame.weather,
|
||||
frame.weather_time,
|
||||
&self.indices,
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
post.set_weather(weather);
|
||||
let flat = self.flat.as_ref().filter(|_| flat_frame);
|
||||
let pages: Vec<(u32, wgpu::Buffer)> = match flat {
|
||||
Some(_) if pack_draws_terrain || pack_draws_shadow => self
|
||||
.arena
|
||||
.page_buffers()
|
||||
.map(|(p, b)| (p, b.clone()))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut terrain =
|
||||
|rp: &mut wgpu::RenderPass<'_>,
|
||||
list: pack_post::TerrainList,
|
||||
page: &dyn Fn(&mut wgpu::RenderPass<'_>, u32)| {
|
||||
use pack_post::TerrainList as L;
|
||||
let shadow = matches!(list, L::ShadowSolid | L::ShadowTranslucent);
|
||||
if !(if shadow {
|
||||
pack_draws_shadow
|
||||
} else {
|
||||
pack_draws_terrain
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
match flat {
|
||||
Some(flat) => {
|
||||
let [shadow, shadow_translucent] = flat.shadow_calls;
|
||||
let calls = match list {
|
||||
L::Solid => &flat.calls[..flat.solid_calls],
|
||||
L::Translucent => flat.translucent_calls(),
|
||||
L::ShadowSolid => &flat.calls[shadow..shadow_translucent],
|
||||
L::ShadowTranslucent => &flat.calls[shadow_translucent..],
|
||||
};
|
||||
flat.draw_with(rp, calls, |p| self.arena.buffer(p), page);
|
||||
}
|
||||
None => {
|
||||
rp.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
rp.set_vertex_buffer(1, self.instances.slice(..));
|
||||
let (first, count) = match list {
|
||||
L::Solid => (0, solid_draws),
|
||||
L::Translucent => (solid_draws, translucent_draws),
|
||||
L::ShadowSolid => (shadow_first, shadow_solid),
|
||||
L::ShadowTranslucent => {
|
||||
(shadow_first + shadow_solid, shadow_translucent)
|
||||
}
|
||||
};
|
||||
self.draw_sections(rp, &self.indirect, 0, first, count);
|
||||
}
|
||||
}
|
||||
};
|
||||
post.set_clouds(pack_draws_clouds.then(|| pack_post::Clouds {
|
||||
vertices: self.cloud_buffer.clone(),
|
||||
group: self.cloud_group.clone(),
|
||||
indices: self.indices.clone(),
|
||||
quads: cloud_quads,
|
||||
}));
|
||||
post.run(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
@@ -4039,11 +4499,28 @@ impl Renderer {
|
||||
&frame.pack,
|
||||
size,
|
||||
[self.config.width, self.config.height],
|
||||
&pages,
|
||||
&mut terrain,
|
||||
);
|
||||
self.pack_post = Some((dimension, Some(post)));
|
||||
} else if let Some((color, depth)) = &scaled
|
||||
&& native_post
|
||||
&& let Some(post) = &mut self.native_post
|
||||
{
|
||||
let size = self.world_post.size().unwrap_or([1, 1]);
|
||||
post.run(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
(color, depth),
|
||||
&view_target,
|
||||
frame,
|
||||
size,
|
||||
);
|
||||
} else if scaled.is_some() {
|
||||
self.world_post.finish(&mut encoder, &view_target);
|
||||
}
|
||||
if hand_quads > 0 {
|
||||
if hand_quads > 0 && !pack_draws_hand {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
resolve_target: None,
|
||||
@@ -4211,6 +4688,9 @@ impl Renderer {
|
||||
timer.submitted(&self.queue);
|
||||
timer.collect(&self.device, &mut self.gpu_times);
|
||||
}
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native_post) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((path, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
@@ -4248,6 +4728,7 @@ impl Renderer {
|
||||
let _ = self.started;
|
||||
self.visible = visible;
|
||||
self.visible_lods = visible_lods;
|
||||
self.shadow_visible = shadow_visible;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,15 @@ pub struct Arena {
|
||||
}
|
||||
|
||||
impl Arena {
|
||||
pub fn new(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
|
||||
Self::with_usage(device, stride, capacity, wgpu::BufferUsages::VERTEX)
|
||||
/// Vertices; `storage` also lets shaders read them as a storage buffer (a shader pack's
|
||||
/// terrain program reads the other corners of a vertex's quad for its normal).
|
||||
pub fn new(device: &wgpu::Device, stride: u64, capacity: u64, storage: bool) -> Self {
|
||||
let usage = if storage {
|
||||
wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE
|
||||
} else {
|
||||
wgpu::BufferUsages::VERTEX
|
||||
};
|
||||
Self::with_usage(device, stride, capacity, usage)
|
||||
}
|
||||
/// Sorted translucent indices; `storage` when compute passes read them.
|
||||
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
|
||||
@@ -148,6 +155,11 @@ impl Arena {
|
||||
pub fn has_page(&self, page: u32) -> bool {
|
||||
self.pages.get(page as usize).is_some_and(Option::is_some)
|
||||
}
|
||||
/// Each live page's buffer, by page.
|
||||
pub fn page_buffers(&self) -> impl Iterator<Item = (u32, &wgpu::Buffer)> {
|
||||
let pages = self.pages.iter().enumerate();
|
||||
pages.filter_map(|(i, p)| Some((i as u32, &p.as_ref()?.buffer)))
|
||||
}
|
||||
pub fn buffer(&self, page: u32) -> &wgpu::Buffer {
|
||||
&self.pages[page as usize]
|
||||
.as_ref()
|
||||
@@ -352,7 +364,7 @@ mod tests {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut arena = Arena::new(&device, 4, GRAIN);
|
||||
let mut arena = Arena::new(&device, 4, GRAIN, false);
|
||||
arena.page_limit = 2 * GRAIN;
|
||||
assert_eq!(arena.bytes(), 0);
|
||||
let (first, a) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
@@ -395,7 +407,7 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
for mut arena in [
|
||||
Arena::new(&device, 28, GRAIN),
|
||||
Arena::new(&device, 28, GRAIN, false),
|
||||
Arena::indices(&device, GRAIN, false),
|
||||
] {
|
||||
alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
|
||||
@@ -40,6 +40,8 @@ enum Source {
|
||||
struct Plan {
|
||||
phase1_calls: usize,
|
||||
solid_calls: usize,
|
||||
/// Where the shadow camera's solid and translucent calls begin.
|
||||
shadow_calls: [usize; 2],
|
||||
total: u64,
|
||||
}
|
||||
const QUADS: u32 = u32::MAX;
|
||||
@@ -73,7 +75,8 @@ pub struct Flat {
|
||||
phase2_call_data: Vec<[u32; 5]>,
|
||||
/// The copy of phase 2's draws, and its ranges.
|
||||
phase2_group: Option<(wgpu::BindGroup, u32)>,
|
||||
draw_layout: wgpu::BindGroupLayout,
|
||||
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
|
||||
pub(super) draw_layout: wgpu::BindGroupLayout,
|
||||
blocks_per_page: u64,
|
||||
pages: Vec<Option<PageTable>>,
|
||||
free_ids: Vec<u32>,
|
||||
@@ -96,6 +99,10 @@ pub struct Flat {
|
||||
pub calls: Vec<Call>,
|
||||
pub phase1_calls: usize,
|
||||
pub solid_calls: usize,
|
||||
/// Where a shader pack's shadow camera's solid and translucent calls
|
||||
/// begin (`calls.len()` without them): the camera's translucent calls
|
||||
/// end at the first.
|
||||
pub shadow_calls: [usize; 2],
|
||||
/// Frames with fewer sections in view keep the per-section draws.
|
||||
pub min_sections: usize,
|
||||
range_align: u64,
|
||||
@@ -163,12 +170,16 @@ fn buffer(
|
||||
/// draw calls, one per run of draws in the same vertex page, phase 1's solid
|
||||
/// ones, phase 2's (draws `phase1_draws..solid_draws`) and translucent ones
|
||||
/// apart. A phase 2 range names its draw in phase 2 and its call in phase 2
|
||||
/// instead of a destination: the call's room holds all of its draws.
|
||||
/// instead of a destination: the call's room holds all of its draws. Draws
|
||||
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
|
||||
/// pack's shadow camera's, with calls of their own.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn plan(
|
||||
draws: &[[u32; 5]],
|
||||
pages: &[(u32, Option<u32>)],
|
||||
phase1_draws: usize,
|
||||
solid_draws: usize,
|
||||
shadow: [usize; 2],
|
||||
align: u64,
|
||||
calls: &mut Vec<Call>,
|
||||
ranges: &mut Vec<Range>,
|
||||
@@ -178,6 +189,7 @@ fn plan(
|
||||
ranges.clear();
|
||||
segments.clear();
|
||||
let (mut phase1_calls, mut solid_calls) = (None, None);
|
||||
let mut shadow_calls = [None; 2];
|
||||
let mut total = 0u64;
|
||||
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
|
||||
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
|
||||
@@ -187,10 +199,14 @@ fn plan(
|
||||
if i == solid_draws {
|
||||
solid_calls = Some(calls.len());
|
||||
}
|
||||
match calls.last_mut() {
|
||||
Some(last) if last.page == page && i != phase1_draws && i != solid_draws => {
|
||||
last.count += d[0]
|
||||
for (k, &from) in shadow.iter().enumerate() {
|
||||
if i == from {
|
||||
shadow_calls[k].get_or_insert(calls.len());
|
||||
}
|
||||
}
|
||||
let starts = i == phase1_draws || i == solid_draws || shadow.contains(&i);
|
||||
match calls.last_mut() {
|
||||
Some(last) if last.page == page && !starts => last.count += d[0],
|
||||
_ => calls.push(Call {
|
||||
page,
|
||||
first: total as u32,
|
||||
@@ -226,6 +242,7 @@ fn plan(
|
||||
Plan {
|
||||
phase1_calls: phase1_calls.unwrap_or(calls.len()).min(solid_calls),
|
||||
solid_calls,
|
||||
shadow_calls: shadow_calls.map(|c| c.unwrap_or(calls.len())),
|
||||
total,
|
||||
}
|
||||
}
|
||||
@@ -339,6 +356,7 @@ impl Flat {
|
||||
calls: Vec::new(),
|
||||
phase1_calls: 0,
|
||||
solid_calls: 0,
|
||||
shadow_calls: [0; 2],
|
||||
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
|
||||
0
|
||||
} else {
|
||||
@@ -411,6 +429,7 @@ impl Flat {
|
||||
///
|
||||
/// When culling, the solid draws from `phase1_draws` are phase 2's, which
|
||||
/// `phase2` holds as the test leaves them: `flatten_phase2` copies them.
|
||||
/// Draws from `shadow` on are a shader pack's shadow camera's (`plan`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
@@ -421,6 +440,7 @@ impl Flat {
|
||||
pages: &[(u32, Option<u32>)],
|
||||
phase1_draws: usize,
|
||||
solid_draws: usize,
|
||||
shadow: [usize; 2],
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
@@ -463,12 +483,14 @@ impl Flat {
|
||||
let Plan {
|
||||
phase1_calls,
|
||||
solid_calls,
|
||||
shadow_calls,
|
||||
total,
|
||||
} = plan(
|
||||
draws,
|
||||
pages,
|
||||
phase1_draws,
|
||||
solid_draws,
|
||||
shadow,
|
||||
self.range_align,
|
||||
&mut self.calls,
|
||||
&mut self.range_data,
|
||||
@@ -476,10 +498,12 @@ impl Flat {
|
||||
);
|
||||
self.phase1_calls = phase1_calls;
|
||||
self.solid_calls = solid_calls;
|
||||
self.shadow_calls = shadow_calls;
|
||||
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
|
||||
self.calls.clear();
|
||||
self.phase1_calls = 0;
|
||||
self.solid_calls = 0;
|
||||
self.shadow_calls = [0; 2];
|
||||
return false;
|
||||
}
|
||||
if self.range_data.len() as u64 > self.range_capacity {
|
||||
@@ -643,6 +667,11 @@ impl Flat {
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
/// The camera's translucent calls.
|
||||
pub fn translucent_calls(&self) -> &[Call] {
|
||||
&self.calls[self.solid_calls..self.shadow_calls[0]]
|
||||
}
|
||||
|
||||
/// Draws `calls` with a flat pipeline set and `vertices` giving each
|
||||
/// page's vertex buffer.
|
||||
pub fn draw<'a>(
|
||||
@@ -650,10 +679,22 @@ impl Flat {
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
) {
|
||||
self.draw_with(pass, calls, vertices, |_, _| {});
|
||||
}
|
||||
/// `draw`, calling `page` before each run of calls from one arena page (a shader pack binds
|
||||
/// that page's vertices for reading).
|
||||
pub fn draw_with<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for call in calls {
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
page(pass, call.page);
|
||||
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
@@ -722,11 +763,13 @@ mod tests {
|
||||
phase1_calls,
|
||||
solid_calls,
|
||||
total,
|
||||
..
|
||||
} = plan(
|
||||
&draws,
|
||||
&pages,
|
||||
solid,
|
||||
solid,
|
||||
[draws.len(); 2],
|
||||
4,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
@@ -761,6 +804,35 @@ mod tests {
|
||||
assert_eq!(ranges.len(), 12);
|
||||
}
|
||||
|
||||
/// A shader pack's shadow draws follow the camera's, in calls of their
|
||||
/// own even from the same page: solid ones, then translucent ones.
|
||||
#[test]
|
||||
fn plan_gives_the_shadow_lists_calls_of_their_own() {
|
||||
let (mut draws, mut pages, solid) = sample();
|
||||
let camera = draws.len();
|
||||
draws.extend([[6, 1, 0, 40, 6], [6 * 2, 1, 0, 64, 7], [6, 1, 6, 80, 7]]);
|
||||
pages.extend([(0, None), (0, None), (0, Some(0))]);
|
||||
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let plan = plan(
|
||||
&draws,
|
||||
&pages,
|
||||
solid,
|
||||
solid,
|
||||
[camera, camera + 2],
|
||||
4,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
);
|
||||
assert_eq!((plan.solid_calls, plan.shadow_calls), (3, [4, 5]));
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
assert_eq!(
|
||||
&calls[3..],
|
||||
[call(0, 60, 18), call(0, 78, 18), call(0, 96, 6)]
|
||||
);
|
||||
assert_eq!(plan.total, 6 * 17);
|
||||
}
|
||||
|
||||
/// Phase 2's draws (here the third and fourth) get calls of their own,
|
||||
/// each with room for all of its draws, and ranges naming their draw
|
||||
/// and call in phase 2.
|
||||
@@ -773,6 +845,7 @@ mod tests {
|
||||
&pages,
|
||||
2,
|
||||
solid,
|
||||
[draws.len(); 2],
|
||||
4,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
@@ -783,6 +856,7 @@ mod tests {
|
||||
Plan {
|
||||
phase1_calls: 1,
|
||||
solid_calls: 3,
|
||||
shadow_calls: [4, 4],
|
||||
total: 6 * 13
|
||||
}
|
||||
);
|
||||
@@ -886,6 +960,7 @@ mod tests {
|
||||
&pages,
|
||||
phase1,
|
||||
solid,
|
||||
[draws.len(); 2],
|
||||
align,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
//! The post stage of Horizon's own shader packs (docs/shaders.md): each `[[post]]` pass is a
|
||||
//! full-screen pipeline built from its checked naga module (`horizon_shader_api::check`), with
|
||||
//! the pack's options as pipeline constants. A pass reads bind group 3: the frame, the world's
|
||||
//! colour and depth, the previous pass's output and two samplers. Passes before the last write
|
||||
//! half-float targets of the world's size, so values over 1 carry to the next; the last draws
|
||||
//! onto the screen.
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_shader_api::{Frame as FrameInputs, check::CheckedStage, stages::GROUP};
|
||||
|
||||
use super::Frame;
|
||||
|
||||
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
/// The world's colour and depth views a set of bind groups was made for.
|
||||
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
|
||||
|
||||
pub struct NativePost {
|
||||
passes: Vec<(String, wgpu::RenderPipeline)>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
frame: wgpu::Buffer,
|
||||
linear: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
/// Two targets of the world's size, written in turn (none with a single pass).
|
||||
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
|
||||
/// One bind group per pass, for these world views.
|
||||
groups: Option<(WorldViews, Vec<wgpu::BindGroup>)>,
|
||||
start: std::time::Instant,
|
||||
frames: u32,
|
||||
/// The screen's colour format, the world target's too.
|
||||
format: wgpu::TextureFormat,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<super::pack_timing::PassTiming>,
|
||||
}
|
||||
|
||||
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl NativePost {
|
||||
/// Pipelines for `stages` (the pack's post passes, in order) with the option values
|
||||
/// `constants`, drawing onto a screen of `format`. Errors name the pass wgpu refused.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
stages: Vec<CheckedStage>,
|
||||
constants: &[(String, f64)],
|
||||
format: wgpu::TextureFormat,
|
||||
) -> Result<Self, String> {
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
texture_entry(1, float),
|
||||
texture_entry(2, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(3, float),
|
||||
sampler_entry(4),
|
||||
sampler_entry(5),
|
||||
],
|
||||
});
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
groups[GROUP as usize] = Some(&layout);
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
bind_group_layouts: &groups,
|
||||
..Default::default()
|
||||
});
|
||||
let count = stages.len();
|
||||
let mut passes = Vec::with_capacity(count);
|
||||
for (i, stage) in stages.into_iter().enumerate() {
|
||||
let present: Vec<(&str, f64)> = constants
|
||||
.iter()
|
||||
.filter(|(id, _)| {
|
||||
stage
|
||||
.module
|
||||
.overrides
|
||||
.iter()
|
||||
.any(|(_, o)| o.name.as_ref() == Some(id))
|
||||
})
|
||||
.map(|(id, v)| (id.as_str(), *v))
|
||||
.collect();
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.label),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(stage.module)),
|
||||
});
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants: &present,
|
||||
..Default::default()
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&stage.label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_vs_post"),
|
||||
compilation_options: options.clone(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_fs_post"),
|
||||
compilation_options: options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: if i + 1 == count { format } else { INTERMEDIATE },
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(format!("{}: {e}", stage.file));
|
||||
}
|
||||
passes.push((stage.label, pipeline));
|
||||
}
|
||||
let frame = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Native shader pack frame"),
|
||||
size: size_of::<FrameInputs>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let sampler = |label, filter| {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some(label),
|
||||
mag_filter: filter,
|
||||
min_filter: filter,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
Ok(Self {
|
||||
passes,
|
||||
layout,
|
||||
frame,
|
||||
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
|
||||
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
|
||||
targets: None,
|
||||
groups: None,
|
||||
start: std::time::Instant::now(),
|
||||
frames: 0,
|
||||
format,
|
||||
timing: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The passes' labels, in order.
|
||||
pub fn labels(&self) -> Vec<&str> {
|
||||
self.passes.iter().map(|(l, _)| l.as_str()).collect()
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
size: [u32; 2],
|
||||
) {
|
||||
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
|
||||
let target = |label| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: INTERMEDIATE,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
self.targets = Some((
|
||||
size,
|
||||
[
|
||||
target("Native shader pack A"),
|
||||
target("Native shader pack B"),
|
||||
],
|
||||
));
|
||||
self.groups = None;
|
||||
}
|
||||
if self
|
||||
.groups
|
||||
.as_ref()
|
||||
.is_some_and(|((color, depth), _)| color == world.0 && depth == world.1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let groups = (0..self.passes.len())
|
||||
.map(|i| {
|
||||
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
|
||||
let previous = match (&self.targets, i) {
|
||||
(Some((_, t)), 1..) => &t[(i - 1) % 2],
|
||||
_ => world.0,
|
||||
};
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.frame.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(world.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(world.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(previous),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
self.groups = Some(((world.0.clone(), world.1.clone()), groups));
|
||||
}
|
||||
|
||||
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
|
||||
/// `screen`.
|
||||
pub fn run(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
screen: &wgpu::TextureView,
|
||||
frame: &Frame,
|
||||
size: [u32; 2],
|
||||
) {
|
||||
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
|
||||
let inputs = frame_inputs(frame, size, seconds, self.frames);
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
self.draw(device, queue, encoder, world, screen, &inputs, size);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
screen: &wgpu::TextureView,
|
||||
inputs: &FrameInputs,
|
||||
size: [u32; 2],
|
||||
) {
|
||||
self.prepare(device, world, size);
|
||||
queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(inputs));
|
||||
if let Some(t) = &mut self.timing {
|
||||
t.begin(device, queue);
|
||||
t.mark(device, encoder, (world.0, self.format));
|
||||
}
|
||||
let Some((_, groups)) = &self.groups else {
|
||||
return;
|
||||
};
|
||||
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
|
||||
let view = match &self.targets {
|
||||
Some((_, t)) if i + 1 < self.passes.len() => &t[i % 2],
|
||||
_ => screen,
|
||||
};
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &attachments,
|
||||
timestamp_writes: self.timing.as_mut().and_then(|t| t.writes(label)),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
|
||||
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
let projection =
|
||||
super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob;
|
||||
// A celestial body's direction at `angle` degrees (SkyRenderer: Y -90°, then X by the
|
||||
// angle, of straight up): overhead at 0, rising in the east (+x).
|
||||
let body = |angle: f32| {
|
||||
let a = angle.to_radians();
|
||||
Vec3::new(-a.sin(), a.cos(), 0.0)
|
||||
};
|
||||
let (fog_color, fog_start, fog_end) = match &frame.fog {
|
||||
Some(f) => (
|
||||
[f.color[0], f.color[1], f.color[2]],
|
||||
f.environmental_start.min(f.render_distance_start),
|
||||
f.environmental_end.min(f.render_distance_end),
|
||||
),
|
||||
None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance),
|
||||
};
|
||||
let pack = &frame.pack;
|
||||
let level = |l: i32| l as f32 / 15.0;
|
||||
let m = |m: Mat4| m.to_cols_array_2d();
|
||||
FrameInputs {
|
||||
view: m(view),
|
||||
projection: m(projection),
|
||||
view_inverse: m(view.inverse()),
|
||||
projection_inverse: m(projection.inverse()),
|
||||
camera_position: frame.camera.as_vec3().to_array(),
|
||||
time: seconds,
|
||||
sun_direction: body(frame.sky.sun_angle).to_array(),
|
||||
day_time: pack.day_time.rem_euclid(24000) as f32 / 24000.0,
|
||||
moon_direction: body(frame.sky.moon_angle).to_array(),
|
||||
game_time: frame.shader_game_time,
|
||||
sky_color: frame.sky.sky_color,
|
||||
rain: pack.rain,
|
||||
fog_color,
|
||||
thunder: pack.thunder,
|
||||
resolution: [size[0] as f32, size[1] as f32],
|
||||
eye_light: [level(pack.eye_light[0]), level(pack.eye_light[1])],
|
||||
fog_start,
|
||||
fog_end,
|
||||
render_distance: frame.distance,
|
||||
far: frame.depth_far,
|
||||
night_vision: frame.night_vision,
|
||||
blindness: pack.blindness,
|
||||
darkness: frame.darkness.1.clamp(0.0, 1.0),
|
||||
brightness: pack.brightness,
|
||||
eye_in: pack.eye_in as u32,
|
||||
dimension: if frame.end_sky {
|
||||
1
|
||||
} else if frame.nether_lighting {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
frame_index: index,
|
||||
hide_gui: u32::from(pack.hide_gui),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_shader_api::{MANIFEST, Pack, check};
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
fn texture(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
usage: wgpu::TextureUsages,
|
||||
) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 4,
|
||||
height: 4,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
}
|
||||
|
||||
/// Two passes: the first doubles the world's colour (over 1, so the half-float target
|
||||
/// must keep it) times an option, the second halves it and writes the depth as alpha.
|
||||
#[test]
|
||||
fn passes_chain_through_half_float_targets_with_options_and_depth() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
|
||||
[[post]]\nfile = \"a.wgsl\"\n[[post]]\nfile = \"b.wgsl\"\n\
|
||||
[[options]]\nid = \"gain\"\nname = \"Gain\"\ntype = \"float\"\ndefault = 0.0\nmin = 0.0\nmax = 2.0\n";
|
||||
let a = "override gain: f32;\nfn post(input: PostInput) -> vec4<f32> { return vec4<f32>(input.color.rgb * 2.0 * gain, 1.0); }\n";
|
||||
let b = "fn post(input: PostInput) -> vec4<f32> { return vec4<f32>(input.color.rgb * 0.5, input.depth); }\n";
|
||||
let files = [(MANIFEST, manifest), ("a.wgsl", a), ("b.wgsl", b)]
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let mut post =
|
||||
NativePost::new(&device, report.stages, &[("gain".into(), 1.0)], format).unwrap();
|
||||
assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]);
|
||||
|
||||
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = texture(&device, format, usage).create_view(&Default::default());
|
||||
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
|
||||
.create_view(&Default::default());
|
||||
let screen = texture(
|
||||
&device,
|
||||
format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: 0.8,
|
||||
g: 0.4,
|
||||
b: 0.2,
|
||||
a: 1.0,
|
||||
}),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.5),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let view = screen.create_view(&Default::default());
|
||||
post.draw(
|
||||
&device,
|
||||
&queue,
|
||||
&mut encoder,
|
||||
(&color, &depth),
|
||||
&view,
|
||||
&FrameInputs::default(),
|
||||
[4, 4],
|
||||
);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
screen.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
|
||||
rows_per_image: Some(4),
|
||||
},
|
||||
},
|
||||
screen.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
|
||||
let near = |got: u8, want: u8| got.abs_diff(want) <= 1;
|
||||
assert!(
|
||||
near(pixel[0], 204) && near(pixel[1], 102) && near(pixel[2], 51) && near(pixel[3], 128),
|
||||
"{pixel:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
//! layer is copied into a grid, row by row from the top left, and an animated layer again
|
||||
//! whenever its frame changes.
|
||||
|
||||
#[expect(dead_code, reason = "the gbuffers pass binds it (next slice)")]
|
||||
pub(super) struct PackAtlas {
|
||||
texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
@@ -64,10 +63,6 @@ impl PackAtlas {
|
||||
}
|
||||
|
||||
/// The atlas's size in pixels (`atlasSize`).
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "the gbuffers pass reads it (next slice)")
|
||||
)]
|
||||
pub fn size(&self) -> [u32; 2] {
|
||||
[self.texture.width(), self.texture.height()]
|
||||
}
|
||||
@@ -118,6 +113,112 @@ impl PackAtlas {
|
||||
}
|
||||
}
|
||||
|
||||
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
|
||||
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
|
||||
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
|
||||
vec3 hz_p0 = uintBitsToFloat(uvec3(hz_vertices[hz_q], hz_vertices[hz_q + 1u], hz_vertices[hz_q + 2u]));\n \
|
||||
vec3 hz_p1 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 6u], hz_vertices[hz_q + 7u], hz_vertices[hz_q + 8u]));\n \
|
||||
vec3 hz_p2 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 12u], hz_vertices[hz_q + 13u], hz_vertices[hz_q + 14u]));\n \
|
||||
uvec3 hz_w = uvec3(hz_vertices[hz_q + 3u], hz_vertices[hz_q + 9u], hz_vertices[hz_q + 15u]);\n \
|
||||
vec2 hz_t0 = vec2(hz_w.x & 65535u, hz_w.x >> 16u);\n \
|
||||
vec2 hz_d1 = vec2(hz_w.y & 65535u, hz_w.y >> 16u) - hz_t0;\n \
|
||||
vec2 hz_d2 = vec2(hz_w.z & 65535u, hz_w.z >> 16u) - hz_t0;\n \
|
||||
vec3 hz_e1 = hz_p1 - hz_p0;\n \
|
||||
vec3 hz_e2 = hz_p2 - hz_p0;\n \
|
||||
vec3 hz_n = cross(hz_e1, hz_e2);\n \
|
||||
if (dot(hz_n, hz_n) > 1e-12) {\n \
|
||||
hz_Normal = normalize(hz_n);\n \
|
||||
float hz_r = hz_d1.x * hz_d2.y - hz_d2.x * hz_d1.y;\n \
|
||||
vec3 hz_t = (hz_e1 * hz_d2.y - hz_e2 * hz_d1.y) * sign(hz_r);\n \
|
||||
vec3 hz_b = (hz_e2 * hz_d1.x - hz_e1 * hz_d2.x) * sign(hz_r);\n \
|
||||
if (dot(hz_t, hz_t) > 1e-12) {\n \
|
||||
hz_t = normalize(hz_t - hz_Normal * dot(hz_Normal, hz_t));\n \
|
||||
hz_tangent = vec4(hz_t, dot(cross(hz_t, hz_Normal), hz_b) < 0.0 ? -1.0 : 1.0);\n \
|
||||
}\n \
|
||||
}\n";
|
||||
|
||||
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.
|
||||
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
|
||||
let mut declarations = String::from(
|
||||
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
layout(location = 4) in uvec2 hz_v_light;\n",
|
||||
);
|
||||
let origin = if flat {
|
||||
declarations.push_str(
|
||||
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
|
||||
);
|
||||
// `mc_Entity` of each sprite layer: id, then 1 for fluids or -1 (the game's table),
|
||||
// and the arena page's vertices (`TerrainVertex`, six words each).
|
||||
declarations.push_str(
|
||||
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
|
||||
);
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
|
||||
} else {
|
||||
declarations.push_str("layout(location = 5) in vec4 hz_v_origin;\n");
|
||||
"hz_v_origin"
|
||||
};
|
||||
let [width, height] = self.size();
|
||||
let scale = [
|
||||
self.sprite as f32 / width as f32,
|
||||
self.sprite as f32 / height as f32,
|
||||
];
|
||||
let columns = self.columns;
|
||||
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
|
||||
let body = format!(
|
||||
" vec4 hz_origin = {origin};\n \
|
||||
uint hz_layer = hz_v_layer & 32767u;\n \
|
||||
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
|
||||
hz_scale = vec2({:?}, {:?});\n \
|
||||
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
|
||||
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_origin.w, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
|
||||
hz_Color = hz_v_color;\n \
|
||||
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
|
||||
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
|
||||
scale[0], scale[1],
|
||||
);
|
||||
let mut body = body;
|
||||
if flat {
|
||||
body.push_str(FACE);
|
||||
}
|
||||
declarations.push_str("vec2 hz_cell; vec2 hz_scale; vec4 hz_tangent;\n");
|
||||
let entity = if flat {
|
||||
"vec4(float(hz_block_ids[2u * hz_layer]), float(hz_block_ids[2u * hz_layer + 1u]), 0.0, 0.0)"
|
||||
} else {
|
||||
"vec4(-1.0, -1.0, 0.0, 0.0)"
|
||||
};
|
||||
let attributes = [
|
||||
("mc_Entity", entity),
|
||||
(
|
||||
"mc_midTexCoord",
|
||||
"vec4((hz_cell + 0.5) * hz_scale, 0.0, 1.0)",
|
||||
),
|
||||
("at_tangent", "hz_tangent"),
|
||||
("vaPosition", "hz_Vertex.xyz"),
|
||||
("vaUV0", "hz_MultiTexCoord0.xy"),
|
||||
("vaUV2", "hz_MultiTexCoord1.xy"),
|
||||
("vaColor", "hz_Color"),
|
||||
("vaNormal", "hz_Normal"),
|
||||
];
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
body,
|
||||
attributes: attributes
|
||||
.iter()
|
||||
.map(|&(n, v)| (n.to_owned(), v.to_owned()))
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! The uniforms a shader pack's gbuffers programs read per draw (`HzDraw` at set 2, the
|
||||
//! translator's `DRAW_UNIFORMS`): one slot per render stage of a uniform buffer, picked with a
|
||||
//! dynamic offset at each draw.
|
||||
|
||||
use horizon_shaders::{RENDER_STAGES, render_stage};
|
||||
|
||||
/// Where a run of `Frame::entities` begins, and what it is (slice 7): the run lasts until the
|
||||
/// next span. Recorded only while a shader pack draws entities, so Horizon's own frames carry
|
||||
/// none; Horizon itself still draws the whole list at once.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EntitySpan {
|
||||
/// The first vertex.
|
||||
pub start: u32,
|
||||
/// The render stage (5.4: `ENTITIES`, `BLOCK_ENTITIES`, `PARTICLES`, `DESTROY`), which picks
|
||||
/// the program.
|
||||
pub stage: u32,
|
||||
/// `entityId` for entities, `blockEntityId` for block entities (5.3), else 0.
|
||||
pub id: i32,
|
||||
/// An entity part that blends (a name tag, a shadow): `gbuffers_entities_translucent`.
|
||||
pub translucent: bool,
|
||||
/// Hidden from the camera (Horizon's entity culling, the player in first person): only the
|
||||
/// shadow pass draws it.
|
||||
pub hidden: bool,
|
||||
/// The local player (`shadowPlayer`).
|
||||
pub player: bool,
|
||||
/// A block entity whose block emits light (`shadowLightBlockEntities`).
|
||||
pub emissive: bool,
|
||||
}
|
||||
|
||||
impl EntitySpan {
|
||||
/// Marks vertices `start..end` of `spans`' list (one entity) as hidden: the span covering
|
||||
/// `start` is split there, the spans inside are hidden, and the geometry after `end` goes
|
||||
/// on in the last span's kind. False when no span covers `start`.
|
||||
pub fn hide(spans: &mut Vec<EntitySpan>, start: usize, end: usize) -> bool {
|
||||
let Some(i) = spans.iter().rposition(|s| s.start as usize <= start) else {
|
||||
return false;
|
||||
};
|
||||
let after = spans[spans.len() - 1];
|
||||
if spans[i].start as usize != start {
|
||||
let split = EntitySpan {
|
||||
start: start as u32,
|
||||
..spans[i]
|
||||
};
|
||||
spans.insert(i + 1, split);
|
||||
}
|
||||
for s in spans.iter_mut().skip(i) {
|
||||
if s.start as usize >= start {
|
||||
s.hidden = true;
|
||||
}
|
||||
}
|
||||
if end > start {
|
||||
spans.push(EntitySpan {
|
||||
start: end as u32,
|
||||
..after
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, a pad, `entityColor`.
|
||||
const SIZE: u32 = 32;
|
||||
|
||||
pub(super) struct DrawUniforms {
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
stride: u32,
|
||||
}
|
||||
|
||||
impl DrawUniforms {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let stride = SIZE.max(device.limits().min_uniform_buffer_offset_alignment);
|
||||
let mut contents = vec![0u8; stride as usize * RENDER_STAGES.len()];
|
||||
for (i, slot) in contents.chunks_mut(stride as usize).enumerate() {
|
||||
slot[..4].copy_from_slice(&(i as i32).to_le_bytes());
|
||||
}
|
||||
let buffer = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Shader pack draws"),
|
||||
contents: &contents,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
},
|
||||
);
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack draws"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: true,
|
||||
min_binding_size: wgpu::BufferSize::new(u64::from(SIZE)),
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack draws"),
|
||||
layout: &layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &buffer,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(u64::from(SIZE)),
|
||||
}),
|
||||
}],
|
||||
});
|
||||
Self {
|
||||
layout,
|
||||
group,
|
||||
stride,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws that follow read `renderStage` = `MC_RENDER_STAGE_<stage>`, other values zero.
|
||||
pub fn bind(&self, pass: &mut wgpu::RenderPass<'_>, stage: &str) {
|
||||
pass.set_bind_group(2, &self.group, &[self.stride * render_stage(stage)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::EntitySpan;
|
||||
|
||||
fn span(start: u32, id: i32) -> EntitySpan {
|
||||
EntitySpan {
|
||||
start,
|
||||
id,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_splits_the_span_around_one_entity() {
|
||||
// Two cows in one span: the second (vertices 8..16) is hidden.
|
||||
let mut spans = vec![span(0, 7)];
|
||||
assert!(EntitySpan::hide(&mut spans, 8, 16));
|
||||
let hidden = EntitySpan {
|
||||
hidden: true,
|
||||
..span(8, 7)
|
||||
};
|
||||
assert_eq!(spans, [span(0, 7), hidden, span(16, 7)]);
|
||||
// An entity with its own span and a part after it (armour) is hidden whole.
|
||||
let mut spans = vec![span(0, 1), span(4, 2), span(6, 3)];
|
||||
assert!(EntitySpan::hide(&mut spans, 4, 10));
|
||||
assert!(!spans[0].hidden && spans[1].hidden && spans[2].hidden);
|
||||
assert_eq!(spans[3], span(10, 3));
|
||||
assert!(!EntitySpan::hide(&mut Vec::new(), 0, 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
//! Entities, block entities, translucent entity parts and particles drawn by a shader pack's
|
||||
//! `gbuffers_entities`, `gbuffers_block`, `gbuffers_entities_translucent` and
|
||||
//! `gbuffers_particles` (spec 2.3, 5.3, 5.4): the runs of `Frame::entities` the game marked (`EntitySpan`), each its
|
||||
//! own draw with its `entityId` or `blockEntityId` and `entityColor` in a per-draw slot of
|
||||
//! `HzDraw` (set 2). Horizon draws whatever the pack does not.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use horizon_shaders::render_stage;
|
||||
|
||||
use super::Vertex;
|
||||
use super::pack_draw::EntitySpan;
|
||||
|
||||
/// `HzDraw`'s size (pack_draw.rs).
|
||||
const SLOT: u64 = 32;
|
||||
|
||||
const ATTRIBUTES: [wgpu::VertexAttribute; 6] = wgpu::vertex_attr_array![
|
||||
0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4, 4 => Uint8x4, 5 => Snorm8x4
|
||||
];
|
||||
|
||||
/// The entity vertex: Horizon's own (`Vertex`).
|
||||
pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
|
||||
[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRIBUTES,
|
||||
})]
|
||||
}
|
||||
|
||||
/// What `gbuffers_entities` and `gbuffers_block` read from an entity vertex: camera-relative
|
||||
/// position, the shared atlas's UV, colour, light (block, sky in 16ths) and the world-oriented
|
||||
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
|
||||
/// the vertex has none (block entities) and `at_tangent` along the texture's u, as for terrain.
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
|
||||
layout(set = 1, binding = 0) readonly buffer HzEntityVertices { uint hz_entity_vertices[]; };\n\
|
||||
vec4 hz_tangent; vec2 hz_mid;\n"
|
||||
.to_owned();
|
||||
let words = std::mem::size_of::<Vertex>() / 4;
|
||||
let corner = |k: usize, field: &str| {
|
||||
let at = |o: usize| format!("hz_entity_vertices[hz_q + {}u]", k * words + o);
|
||||
match field {
|
||||
"pos" => format!("uintBitsToFloat(uvec3({}, {}, {}))", at(0), at(1), at(2)),
|
||||
_ => format!("uintBitsToFloat(uvec2({}, {}))", at(3), at(4)),
|
||||
}
|
||||
};
|
||||
let body = format!(
|
||||
" uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
|
||||
vec3 hz_e1 = {p1} - {p0};\n \
|
||||
vec3 hz_e2 = {p2} - {p0};\n \
|
||||
vec2 hz_t0 = {t0};\n \
|
||||
vec2 hz_d1 = {t1} - hz_t0;\n \
|
||||
vec2 hz_d2 = {t2} - hz_t0;\n \
|
||||
hz_mid = (hz_t0 + {t2}) * 0.5;\n \
|
||||
vec3 hz_n = cross(hz_e1, hz_e2);\n \
|
||||
hz_Normal = dot(hz_v_normal.xyz, hz_v_normal.xyz) > 0.01 ? normalize(hz_v_normal.xyz)\n \
|
||||
: dot(hz_n, hz_n) > 1e-12 ? normalize(hz_n) : vec3(0.0, 1.0, 0.0);\n \
|
||||
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n \
|
||||
float hz_r = hz_d1.x * hz_d2.y - hz_d2.x * hz_d1.y;\n \
|
||||
vec3 hz_t = (hz_e1 * hz_d2.y - hz_e2 * hz_d1.y) * sign(hz_r);\n \
|
||||
vec3 hz_b = (hz_e2 * hz_d1.x - hz_e1 * hz_d2.x) * sign(hz_r);\n \
|
||||
hz_t -= hz_Normal * dot(hz_Normal, hz_t);\n \
|
||||
if (dot(hz_t, hz_t) > 1e-12) {{\n \
|
||||
hz_t = normalize(hz_t);\n \
|
||||
hz_tangent = vec4(hz_t, dot(cross(hz_t, hz_Normal), hz_b) < 0.0 ? -1.0 : 1.0);\n \
|
||||
}}\n \
|
||||
hz_Vertex = vec4(hz_v_pos, 1.0);\n \
|
||||
hz_MultiTexCoord0 = vec4(hz_v_uv, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
|
||||
hz_Color = hz_v_color;\n",
|
||||
p0 = corner(0, "pos"),
|
||||
p1 = corner(1, "pos"),
|
||||
p2 = corner(2, "pos"),
|
||||
t0 = corner(0, "uv"),
|
||||
t1 = corner(1, "uv"),
|
||||
t2 = corner(2, "uv"),
|
||||
);
|
||||
let attributes = [
|
||||
("at_tangent", "hz_tangent"),
|
||||
("mc_midTexCoord", "vec4(hz_mid, 0.0, 1.0)"),
|
||||
("vaPosition", "hz_Vertex.xyz"),
|
||||
("vaUV0", "hz_v_uv"),
|
||||
("vaUV1", "ivec2(0, 10)"),
|
||||
("vaUV2", "ivec2(hz_MultiTexCoord1.xy)"),
|
||||
("vaColor", "hz_Color"),
|
||||
("vaNormal", "hz_Normal"),
|
||||
];
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
body,
|
||||
attributes: attributes
|
||||
.iter()
|
||||
.map(|&(n, v)| (n.to_owned(), v.to_owned()))
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
// Cutout, as vanilla's entity types mostly are (OQ-23): the loader's test at 0.1.
|
||||
alpha_test: Some(horizon_shaders::AlphaTest {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `gbuffers_clouds` reads from the clouds' mesh (entity vertices, `vs_cloud`): the
|
||||
/// position moved by the clouds' offset, the colour of the face's shade from their palette,
|
||||
/// full sky light. Set 1 holds the clouds' uniform (`clouds::Uniform`).
|
||||
pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
|
||||
layout(set = 1, binding = 0) uniform HzCloud { vec4 hz_cloud_offset; vec4 hz_cloud_colors[4]; };\n"
|
||||
.to_owned();
|
||||
let body = " hz_Vertex = vec4(hz_v_pos + hz_cloud_offset.xyz, 1.0);\n \
|
||||
hz_MultiTexCoord0 = vec4(hz_v_uv, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord1 = vec4(0.0, 240.0, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
|
||||
hz_Color = hz_cloud_colors[min(hz_v_light.x, 3u)];\n \
|
||||
hz_Normal = dot(hz_v_normal.xyz, hz_v_normal.xyz) > 0.01 ? normalize(hz_v_normal.xyz) : vec3(0.0, 1.0, 0.0);\n"
|
||||
.to_owned();
|
||||
let attributes = [
|
||||
("vaPosition", "hz_Vertex.xyz"),
|
||||
("vaUV0", "hz_v_uv"),
|
||||
("vaUV2", "ivec2(0, 240)"),
|
||||
("vaColor", "hz_Color"),
|
||||
("vaNormal", "hz_Normal"),
|
||||
];
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
body,
|
||||
attributes: attributes
|
||||
.iter()
|
||||
.map(|&(n, v)| (n.to_owned(), v.to_owned()))
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `entityColor` for a run whose first quad carries overlay code `overlay`: the hurt red or a
|
||||
/// white flash, its alpha how much of that colour shows over the entity (vanilla mixes the
|
||||
/// overlay texel's colour in by one minus the texel's alpha).
|
||||
pub fn entity_color(overlay: u32) -> [f32; 4] {
|
||||
let [r, g, b, a] = crate::overlay::HURT_TEXEL.map(|v| f32::from(v) / 255.0);
|
||||
let white = |u: u32| {
|
||||
let a = f32::from(crate::overlay::white_alpha(u)) / 255.0;
|
||||
[1.0, 1.0, 1.0, 1.0 - a]
|
||||
};
|
||||
match overlay {
|
||||
1 | 8 => [r, g, b, 1.0 - a],
|
||||
5 => white(15),
|
||||
16..32 => white(overlay - crate::overlay::WHITE),
|
||||
_ => [0.0; 4],
|
||||
}
|
||||
}
|
||||
|
||||
/// One draw: its quads in `PackEntities`' buffer, its program and its slot of `HzDraw`.
|
||||
pub(super) struct Draw {
|
||||
/// The first vertex.
|
||||
pub base: u32,
|
||||
pub quads: u32,
|
||||
/// The kind of program that draws it (`ENTITY`, `BLOCK`, `TRANSLUCENT`, `PARTICLE`).
|
||||
pub program: usize,
|
||||
/// The pack's gbuffers program draws it (else Horizon does).
|
||||
pub pack: bool,
|
||||
/// The pack's shadow program draws it into the shadow map too.
|
||||
pub shadow: bool,
|
||||
pub slot: u32,
|
||||
}
|
||||
|
||||
/// Which entity runs a pack's shadow pass draws (2.4, 3.2).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Casters {
|
||||
/// `shadow_entities` (or `shadow`) draws entities...
|
||||
pub entities: bool,
|
||||
/// ...only the local player (`shadowPlayer` without `shadowEntities`).
|
||||
pub only_player: bool,
|
||||
/// `shadow_block` (or `shadow`) draws block entities...
|
||||
pub blocks: bool,
|
||||
/// ...only those whose block emits light (`shadowLightBlockEntities`).
|
||||
pub only_lit: bool,
|
||||
}
|
||||
|
||||
impl Casters {
|
||||
/// Whether the shadow pass draws `span`'s run, of kind `program`.
|
||||
pub fn cast(&self, span: &EntitySpan, program: usize) -> bool {
|
||||
match program {
|
||||
ENTITY => self.entities && (!self.only_player || span.player),
|
||||
BLOCK => self.blocks && (!self.only_lit || span.emissive),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertices and per-draw uniforms of the runs a pack draws, rebuilt each frame.
|
||||
pub(super) struct PackEntities {
|
||||
vertices: wgpu::Buffer,
|
||||
/// Set 1: the vertices again, for the quads' corners.
|
||||
pub table: wgpu::BindGroupLayout,
|
||||
pub table_group: wgpu::BindGroup,
|
||||
slots: wgpu::Buffer,
|
||||
pub group: wgpu::BindGroup,
|
||||
stride: u64,
|
||||
/// Two triangles per quad, for `quads` quads.
|
||||
pub indices: wgpu::Buffer,
|
||||
quads: u32,
|
||||
scratch: Vec<Vertex>,
|
||||
uniforms: Vec<u8>,
|
||||
pub draws: Vec<Draw>,
|
||||
}
|
||||
|
||||
impl PackEntities {
|
||||
pub fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout) -> Self {
|
||||
let stride = SLOT.max(u64::from(
|
||||
device.limits().min_uniform_buffer_offset_alignment,
|
||||
));
|
||||
let table = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack entity vertices"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let (vertices, table_group) = Self::vertex_buffer(device, &table, 4096);
|
||||
let (slots, group) = Self::slot_buffer(device, layout, 64, stride);
|
||||
Self {
|
||||
vertices,
|
||||
table,
|
||||
table_group,
|
||||
slots,
|
||||
group,
|
||||
stride,
|
||||
indices: quad_indices(device, 1024),
|
||||
quads: 1024,
|
||||
scratch: Vec::new(),
|
||||
uniforms: Vec::new(),
|
||||
draws: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn vertex_buffer(
|
||||
device: &wgpu::Device,
|
||||
table: &wgpu::BindGroupLayout,
|
||||
vertices: u64,
|
||||
) -> (wgpu::Buffer, wgpu::BindGroup) {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack entity vertices"),
|
||||
size: vertices * std::mem::size_of::<Vertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX
|
||||
| wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack entity vertices"),
|
||||
layout: table,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
(buffer, group)
|
||||
}
|
||||
|
||||
fn slot_buffer(
|
||||
device: &wgpu::Device,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
slots: u64,
|
||||
stride: u64,
|
||||
) -> (wgpu::Buffer, wgpu::BindGroup) {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack entity draws"),
|
||||
size: slots * stride,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack entity draws"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &buffer,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(SLOT),
|
||||
}),
|
||||
}],
|
||||
});
|
||||
(buffer, group)
|
||||
}
|
||||
|
||||
pub fn vertices(&self) -> wgpu::BufferSlice<'_> {
|
||||
self.vertices.slice(..)
|
||||
}
|
||||
|
||||
/// The dynamic offset of draw slot `slot`.
|
||||
pub fn offset(&self, slot: u32) -> u32 {
|
||||
(u64::from(slot) * self.stride) as u32
|
||||
}
|
||||
|
||||
/// Splits `entities` between the pack, which draws the runs of `spans` its programs cover
|
||||
/// (`drawn`: whether it has `gbuffers_entities`, `gbuffers_block` or their fallbacks), and
|
||||
/// Horizon, which draws the rest (`rest`). Uploads the pack's part, and the runs its shadow
|
||||
/// programs draw (`shadow`). Runs hidden from the camera only ever cast shadows.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
entities: &[Vertex],
|
||||
spans: &[EntitySpan],
|
||||
drawn: [bool; PROGRAMS],
|
||||
shadow: Casters,
|
||||
rest: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.draws.clear();
|
||||
self.scratch.clear();
|
||||
self.uniforms.clear();
|
||||
rest.clear();
|
||||
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
|
||||
rest.extend_from_slice(&entities[..first.min(entities.len())]);
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
let run = &entities[range];
|
||||
let program = program_of(&span).filter(|_| plain(run));
|
||||
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
|
||||
let casts = program.is_some_and(|p| shadow.cast(&span, p));
|
||||
if !pack && !span.hidden {
|
||||
rest.extend_from_slice(run);
|
||||
}
|
||||
let Some(program) = program.filter(|_| (pack || casts) && !run.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let start = self.scratch.len() as u32;
|
||||
self.scratch.extend_from_slice(run);
|
||||
let (entity_id, block_id) = match program {
|
||||
BLOCK => (0, span.id),
|
||||
_ => (span.id, 0),
|
||||
};
|
||||
let mut bytes = [0u8; SLOT as usize];
|
||||
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
|
||||
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
|
||||
for (i, c) in entity_color(run[0].layer).iter().enumerate() {
|
||||
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
self.uniforms.extend_from_slice(&bytes);
|
||||
self.uniforms
|
||||
.resize(self.uniforms.len() + (self.stride - SLOT) as usize, 0);
|
||||
self.draws.push(Draw {
|
||||
base: start,
|
||||
quads: run.len() as u32 / 4,
|
||||
program,
|
||||
pack,
|
||||
shadow: casts,
|
||||
slot: self.draws.len() as u32,
|
||||
});
|
||||
}
|
||||
if self.draws.is_empty() {
|
||||
return;
|
||||
}
|
||||
let vertex_size = std::mem::size_of::<Vertex>() as u64;
|
||||
let (need_vertices, need_slots) = (self.scratch.len() as u64, self.draws.len() as u64);
|
||||
let have_vertices = self.vertices.size() / vertex_size;
|
||||
let have_slots = self.slots.size() / self.stride;
|
||||
if need_vertices > have_vertices {
|
||||
(self.vertices, self.table_group) =
|
||||
Self::vertex_buffer(device, &self.table, need_vertices.next_power_of_two());
|
||||
}
|
||||
if need_slots > have_slots {
|
||||
(self.slots, self.group) =
|
||||
Self::slot_buffer(device, layout, need_slots.next_power_of_two(), self.stride);
|
||||
}
|
||||
let quads = self.draws.iter().map(|d| d.quads).max().unwrap_or(0);
|
||||
if quads > self.quads {
|
||||
self.quads = quads.next_power_of_two();
|
||||
self.indices = quad_indices(device, self.quads);
|
||||
}
|
||||
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(&self.scratch));
|
||||
queue.write_buffer(&self.slots, 0, &self.uniforms);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two triangles for each of `quads` quads, from vertex 0.
|
||||
pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
|
||||
let indices: Vec<u32> = (0..quads)
|
||||
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q * 4 + i))
|
||||
.collect();
|
||||
wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Shader pack entity indices"),
|
||||
contents: bytemuck::cast_slice(&indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a run holds only geometry a gbuffers program can draw from the shared atlas: no
|
||||
/// celestial body, cloud, beam, End portal or crack (overlay codes Horizon's entity shader
|
||||
/// draws in its own way; entity.wgsl).
|
||||
fn plain(run: &[Vertex]) -> bool {
|
||||
run.iter()
|
||||
.step_by(4)
|
||||
.all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12))
|
||||
}
|
||||
|
||||
/// The programs of entity runs, in the order they draw.
|
||||
pub const ENTITY: usize = 0;
|
||||
pub const BLOCK: usize = 1;
|
||||
pub const TRANSLUCENT: usize = 2;
|
||||
pub const PARTICLE: usize = 3;
|
||||
pub const PROGRAMS: usize = 4;
|
||||
|
||||
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
|
||||
/// `None`: Horizon draws it.
|
||||
#[cfg(test)]
|
||||
fn drawn_by_pack(span: &EntitySpan, drawn: [bool; PROGRAMS]) -> Option<usize> {
|
||||
program_of(span).filter(|&p| drawn[p])
|
||||
}
|
||||
|
||||
/// The kind of program that draws `span`'s run, when a pack may.
|
||||
pub fn program_of(span: &EntitySpan) -> Option<usize> {
|
||||
Some(if span.stage == render_stage("ENTITIES") {
|
||||
if span.translucent {
|
||||
TRANSLUCENT
|
||||
} else {
|
||||
ENTITY
|
||||
}
|
||||
} else if span.stage == render_stage("BLOCK_ENTITIES") {
|
||||
BLOCK
|
||||
} else if span.stage == render_stage("PARTICLES") {
|
||||
PARTICLE
|
||||
} else {
|
||||
return None;
|
||||
})
|
||||
}
|
||||
|
||||
/// Each span with its vertex range: from its start to the next span's (or the end).
|
||||
pub fn runs(
|
||||
len: usize,
|
||||
spans: &[EntitySpan],
|
||||
) -> impl Iterator<Item = (EntitySpan, Range<usize>)> + '_ {
|
||||
spans.iter().enumerate().map(move |(i, s)| {
|
||||
let end = spans.get(i + 1).map_or(len, |n| n.start as usize);
|
||||
let start = (s.start as usize).min(len);
|
||||
(*s, start..end.clamp(start, len))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn runs_split_the_list_between_pack_and_horizon() {
|
||||
let v = |layer| Vertex {
|
||||
layer,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let span = |start, stage: &str, id| EntitySpan {
|
||||
start,
|
||||
stage: render_stage(stage),
|
||||
id,
|
||||
..Default::default()
|
||||
};
|
||||
let spans = [
|
||||
span(2, "ENTITIES", 7),
|
||||
span(5, "PARTICLES", 0),
|
||||
span(6, "BLOCK_ENTITIES", 9),
|
||||
];
|
||||
let ranges: Vec<_> = runs(10, &spans).map(|(_, r)| r).collect();
|
||||
assert_eq!(ranges, [2..5, 5..6, 6..10]);
|
||||
let all = [true; PROGRAMS];
|
||||
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
|
||||
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
|
||||
assert_eq!(drawn_by_pack(&spans[1], [true, true, true, false]), None);
|
||||
assert_eq!(drawn_by_pack(&spans[2], [true, false, true, true]), None);
|
||||
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
|
||||
let shadow = EntitySpan {
|
||||
translucent: true,
|
||||
..spans[0]
|
||||
};
|
||||
assert_eq!(drawn_by_pack(&shadow, all), Some(TRANSLUCENT));
|
||||
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), None);
|
||||
// A beacon beam's quads stay Horizon's.
|
||||
assert!(plain(&[v(0), v(0), v(0), v(0), v(1)]));
|
||||
assert!(!plain(&[v(0), v(0), v(0), v(0), v(6)]));
|
||||
assert_eq!(entity_color(1)[0], 1.0);
|
||||
assert!((entity_color(1)[3] - (1.0 - 178.0 / 255.0)).abs() < 1e-6);
|
||||
assert_eq!(entity_color(0), [0.0; 4]);
|
||||
// The shadow pass: entities, the player alone, block entities, those that emit light.
|
||||
let all = Casters {
|
||||
entities: true,
|
||||
blocks: true,
|
||||
..Default::default()
|
||||
};
|
||||
let player = EntitySpan {
|
||||
player: true,
|
||||
..spans[0]
|
||||
};
|
||||
let lit = EntitySpan {
|
||||
emissive: true,
|
||||
..spans[2]
|
||||
};
|
||||
assert!(all.cast(&spans[0], ENTITY) && all.cast(&spans[2], BLOCK));
|
||||
assert!(!all.cast(&spans[1], PARTICLE));
|
||||
let some = Casters {
|
||||
only_player: true,
|
||||
only_lit: true,
|
||||
..all
|
||||
};
|
||||
assert!(!some.cast(&spans[0], ENTITY) && some.cast(&player, ENTITY));
|
||||
assert!(!some.cast(&spans[2], BLOCK) && some.cast(&lit, BLOCK));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! The first-person hand and held items drawn by a shader pack's `gbuffers_hand` (spec 2.3):
|
||||
//! `Frame::hand`'s entity vertices, read as entities are (`pack_entities::inputs`), with the
|
||||
//! hand's own projection as `gl_ProjectionMatrix` (5.2: `gbufferProjection` stays the world's).
|
||||
|
||||
use glam::Mat4;
|
||||
|
||||
use super::Vertex;
|
||||
|
||||
/// `MC_HAND_DEPTH`'s value (1.5): the hand's depth is scaled by it through its projection, so
|
||||
/// that it sits in front of the world in the shared depth buffer.
|
||||
const HAND_DEPTH: f32 = 0.125;
|
||||
|
||||
/// `HzHand`: the hand's projection and its inverse.
|
||||
const UNIFORM: u64 = 2 * 64;
|
||||
|
||||
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let mut inputs = super::pack_entities::inputs();
|
||||
inputs.declarations.push_str(
|
||||
"layout(set = 1, binding = 1) uniform HzHand { mat4 hz_hand_projection; mat4 hz_hand_projection_inverse; };\n",
|
||||
);
|
||||
inputs.body.push_str(
|
||||
" hz_ProjectionMatrix = hz_hand_projection;\n \
|
||||
hz_ProjectionMatrixInverse = hz_hand_projection_inverse;\n \
|
||||
hz_ModelViewProjectionMatrix = hz_hand_projection * hz_ModelViewMatrix;\n",
|
||||
);
|
||||
inputs
|
||||
}
|
||||
|
||||
/// The hand's projection in GL's conventions: `hud_fov` degrees, the view bob, depth scaled by
|
||||
/// `MC_HAND_DEPTH`.
|
||||
pub fn projection(hud_fov: f32, aspect: f32, far: f32, bob: Mat4) -> Mat4 {
|
||||
Mat4::from_scale(glam::Vec3::new(1.0, 1.0, HAND_DEPTH))
|
||||
* Mat4::perspective_rh_gl(hud_fov.to_radians(), aspect, 0.05, far)
|
||||
* bob
|
||||
}
|
||||
|
||||
/// The hand's vertices (also read at set 1 for normals and tangents), its uniform and quad
|
||||
/// indices.
|
||||
pub struct PackHand {
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
vertices: wgpu::Buffer,
|
||||
uniform: wgpu::Buffer,
|
||||
pub group: wgpu::BindGroup,
|
||||
pub indices: wgpu::Buffer,
|
||||
capacity: u32,
|
||||
/// Quads uploaded this frame.
|
||||
pub quads: u32,
|
||||
}
|
||||
|
||||
impl PackHand {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack hand"),
|
||||
entries: &[
|
||||
entry(0, wgpu::BufferBindingType::Storage { read_only: true }),
|
||||
entry(1, wgpu::BufferBindingType::Uniform),
|
||||
],
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack hand"),
|
||||
size: UNIFORM,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let capacity = 256;
|
||||
let (vertices, group) = Self::vertex_buffer(device, &layout, &uniform, capacity);
|
||||
Self {
|
||||
indices: super::pack_entities::quad_indices(device, capacity),
|
||||
layout,
|
||||
vertices,
|
||||
uniform,
|
||||
group,
|
||||
capacity,
|
||||
quads: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn vertex_buffer(
|
||||
device: &wgpu::Device,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
uniform: &wgpu::Buffer,
|
||||
quads: u32,
|
||||
) -> (wgpu::Buffer, wgpu::BindGroup) {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack hand vertices"),
|
||||
size: u64::from(quads) * 4 * std::mem::size_of::<Vertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX
|
||||
| wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack hand"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
(buffer, group)
|
||||
}
|
||||
|
||||
/// Uploads this frame's hand and its projection.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
hand: &[Vertex],
|
||||
projection: Mat4,
|
||||
) {
|
||||
self.quads = (hand.len() / 4) as u32;
|
||||
if self.quads == 0 {
|
||||
return;
|
||||
}
|
||||
if self.quads > self.capacity {
|
||||
self.capacity = self.quads.next_power_of_two();
|
||||
(self.vertices, self.group) =
|
||||
Self::vertex_buffer(device, &self.layout, &self.uniform, self.capacity);
|
||||
self.indices = super::pack_entities::quad_indices(device, self.capacity);
|
||||
}
|
||||
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(hand));
|
||||
let matrices = [projection, projection.inverse()].map(|m| m.to_cols_array());
|
||||
queue.write_buffer(&self.uniform, 0, bytemuck::cast_slice(&matrices));
|
||||
}
|
||||
|
||||
pub fn vertices(&self) -> wgpu::BufferSlice<'_> {
|
||||
self.vertices.slice(..)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The hand's window depth lies within `MC_HAND_DEPTH` of the middle (packs tell the hand
|
||||
/// apart by it), and x, y match the plain projection.
|
||||
#[test]
|
||||
fn hand_depth_is_scaled_around_the_middle() {
|
||||
let p = projection(70.0, 16.0 / 9.0, 512.0, Mat4::IDENTITY);
|
||||
let plain = Mat4::perspective_rh_gl(70f32.to_radians(), 16.0 / 9.0, 0.05, 512.0);
|
||||
for z in [-0.06f32, -0.5, -3.0, -400.0] {
|
||||
let v = glam::Vec4::new(0.1, -0.2, z, 1.0);
|
||||
let (a, b) = (p * v, plain * v);
|
||||
let window = (a.z / a.w) * 0.5 + 0.5;
|
||||
assert!(
|
||||
(window - 0.5).abs() <= HAND_DEPTH * 0.5 + 1e-6,
|
||||
"{z}: {window}"
|
||||
);
|
||||
assert!((a.x / a.w - b.x / b.w).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
// Helpers of a shader pack's post-processing (pack_post.rs): the world into colortex0 and
|
||||
// depthtex0, and colortex0 onto the screen for packs without `final`.
|
||||
// depthtex0 (a depth attachment, for terrain's depth test), and colortex0 onto the screen for
|
||||
// packs without `final`.
|
||||
|
||||
@vertex
|
||||
fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
@@ -12,7 +13,7 @@ fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
|
||||
struct World {
|
||||
@location(0) color: vec4<f32>,
|
||||
@location(1) depth: vec4<f32>,
|
||||
@builtin(frag_depth) depth: f32,
|
||||
}
|
||||
|
||||
// Pack buffers keep GL's rows (row 0 at the bottom), and GL's window depth: Horizon's
|
||||
@@ -23,7 +24,7 @@ fn fs_blit(@builtin(position) p: vec4<f32>) -> World {
|
||||
let at = vec2<i32>(i32(p.x), size.y - 1 - i32(p.y));
|
||||
var out: World;
|
||||
out.color = textureLoad(source, at, 0);
|
||||
out.depth = vec4<f32>(1.0 - textureLoad(world_depth, at, 0), 0.0, 0.0, 1.0);
|
||||
out.depth = 1.0 - textureLoad(world_depth, at, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -52,3 +53,11 @@ fn vs_uv(@builtin(vertex_index) i: u32) -> Full {
|
||||
fn fs_copy(v: Full) -> @location(0) vec4<f32> {
|
||||
return textureSample(source, linear, v.uv);
|
||||
}
|
||||
|
||||
// One level of a buffer's mip chain from the level above (6.1.7): the average of the 2×2
|
||||
// texels under each destination texel, by one bilinear sample at their shared corner.
|
||||
@fragment
|
||||
fn fs_down(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let size = max(textureDimensions(source) / 2u, vec2<u32>(1u));
|
||||
return textureSampleLevel(source, linear, p.xy / vec2<f32>(size), 0.0);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! A shader pack's shadow buffers (spec 6.3): the shadow map's depth (`shadowtex0`, and
|
||||
//! `shadowtex1` without translucent geometry), and the colour buffers `shadowcolor0/1`, each
|
||||
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
|
||||
//! Square, `shadowMapResolution` pixels.
|
||||
|
||||
type Ops<T> = wgpu::Operations<T>;
|
||||
type Color = wgpu::Color;
|
||||
|
||||
/// Loaded and stored.
|
||||
pub(super) fn load<T>() -> Ops<T> {
|
||||
wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(c: [f32; 4]) -> Ops<Color> {
|
||||
wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: c[0].into(),
|
||||
g: c[1].into(),
|
||||
b: c[2].into(),
|
||||
a: c[3].into(),
|
||||
}),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the shadow pass writes and the programs read.
|
||||
pub(super) struct ShadowTargets {
|
||||
pub size: u32,
|
||||
/// `shadowtex0`, written by the shadow pass.
|
||||
pub depth: (wgpu::Texture, wgpu::TextureView),
|
||||
/// `shadowtex1`: a copy of `shadowtex0` taken before translucent terrain, when read.
|
||||
pub depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// Both sides of each shadowcolor buffer used.
|
||||
pub color: [Option<[wgpu::TextureView; 2]>; 2],
|
||||
/// The clear colour of each buffer cleared every frame.
|
||||
pub clears: [Option<[f32; 4]>; 2],
|
||||
/// Bit n set: B is shadowcolor n's "main".
|
||||
pub main: u16,
|
||||
/// Nothing cleared yet this frame: the first pass into the shadow buffers clears them as it
|
||||
/// begins (`clear_ops`), which saves a pass that loads and stores them all on tiled GPUs.
|
||||
fresh: bool,
|
||||
/// Bit n set: shadowcolor n was cleared that way.
|
||||
cleared: u8,
|
||||
/// Depth comparison for `sampler2DShadow` (hardware filtering): linear, as GL filters it.
|
||||
pub comparison: wgpu::Sampler,
|
||||
}
|
||||
|
||||
fn target(
|
||||
device: &wgpu::Device,
|
||||
label: &str,
|
||||
size: u32,
|
||||
format: wgpu::TextureFormat,
|
||||
copy: wgpu::TextureUsages,
|
||||
) -> (wgpu::Texture, wgpu::TextureView) {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | copy,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
(texture, view)
|
||||
}
|
||||
|
||||
impl ShadowTargets {
|
||||
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
|
||||
/// some program reads `shadowtex1`. The resolution is kept within the device's limit.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_shaders::Shadow,
|
||||
formats: [wgpu::TextureFormat; 2],
|
||||
colors: u16,
|
||||
depth1: bool,
|
||||
) -> Self {
|
||||
let size = shadow
|
||||
.resolution
|
||||
.clamp(1, device.limits().max_texture_dimension_2d);
|
||||
let copy = wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST;
|
||||
let depth = wgpu::TextureFormat::Depth32Float;
|
||||
let color = std::array::from_fn(|n| {
|
||||
(colors & 1 << n != 0).then(|| {
|
||||
[0, 1].map(|side| {
|
||||
let label = format!("Shader pack shadowcolor{n} {}", ["A", "B"][side]);
|
||||
target(
|
||||
device,
|
||||
&label,
|
||||
size,
|
||||
formats[n],
|
||||
wgpu::TextureUsages::empty(),
|
||||
)
|
||||
.1
|
||||
})
|
||||
})
|
||||
});
|
||||
Self {
|
||||
size,
|
||||
depth: target(device, "Shader pack shadowtex0", size, depth, copy),
|
||||
depth1: depth1.then(|| target(device, "Shader pack shadowtex1", size, depth, copy)),
|
||||
color,
|
||||
clears: std::array::from_fn(|n| {
|
||||
let b = &shadow.colors[n];
|
||||
b.clear.then(|| b.clear_color.unwrap_or([1.0; 4]))
|
||||
}),
|
||||
main: 0,
|
||||
fresh: false,
|
||||
cleared: 0,
|
||||
comparison: device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack shadow comparison"),
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Side `alt` (of "main" and "alt") of shadowcolor `n`.
|
||||
pub fn color(&self, n: usize, alt: bool) -> Option<&wgpu::TextureView> {
|
||||
let side = usize::from(self.main & 1 << n != 0) ^ usize::from(alt);
|
||||
self.color.get(n)?.as_ref().map(|c| &c[side])
|
||||
}
|
||||
|
||||
/// A new frame: the shadow map and the shadowcolor buffers cleared every frame (6.3) are
|
||||
/// cleared by the first pass that begins (`clear_ops`), or else by `clear_rest`.
|
||||
pub fn begin_frame(&mut self) {
|
||||
self.fresh = true;
|
||||
self.cleared = 0;
|
||||
}
|
||||
|
||||
/// The load operations of a pass into the shadow map and shadowcolor buffers `writes`:
|
||||
/// clears in the frame's first one, loads after.
|
||||
pub fn clear_ops(
|
||||
&mut self,
|
||||
writes: impl Iterator<Item = usize>,
|
||||
) -> (Ops<f32>, [Ops<Color>; 2]) {
|
||||
if !std::mem::take(&mut self.fresh) {
|
||||
return (load(), [load(); 2]);
|
||||
}
|
||||
let mut colors = [load(); 2];
|
||||
for n in writes.filter(|&n| n < 2) {
|
||||
if let Some(c) = self.clears[n] {
|
||||
colors[n] = clear(c);
|
||||
self.cleared |= 1 << n;
|
||||
}
|
||||
}
|
||||
let depth = wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
};
|
||||
(depth, colors)
|
||||
}
|
||||
|
||||
/// Clears what no pass has cleared this frame: all of it when no shadow program drew, the
|
||||
/// cleared shadowcolor buffers the first one did not write.
|
||||
pub fn clear_rest(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let depth = std::mem::take(&mut self.fresh);
|
||||
let cleared = std::mem::replace(&mut self.cleared, 0b11);
|
||||
let attachments: Vec<_> = (0..2)
|
||||
.filter(|n| cleared & 1 << n == 0)
|
||||
.filter_map(|n| {
|
||||
Some(Some(wgpu::RenderPassColorAttachment {
|
||||
view: self.color(n, false)?,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: clear(self.clears[n]?),
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
if !depth && attachments.is_empty() {
|
||||
return;
|
||||
}
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack shadow clear"),
|
||||
color_attachments: &attachments,
|
||||
depth_stencil_attachment: depth.then_some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.depth.1,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// `shadowtex1`: the shadow map as it is now (before translucent geometry).
|
||||
pub fn copy_depth1(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
if let Some((copy, _)) = &self.depth1 {
|
||||
encoder.copy_texture_to_texture(
|
||||
self.depth.0.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
self.depth.0.size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user