mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
427
Commits
No files matched your search
@@ -8,7 +8,7 @@ name: nightly audit
|
||||
# gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues
|
||||
on:
|
||||
schedule:
|
||||
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris.
|
||||
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:07 in Paris today.
|
||||
- cron: '7 1 * * *'
|
||||
- cron: '7 2 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -29,15 +29,21 @@ env:
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
timeout-minutes: 150
|
||||
# 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: 240 # every harness (~80 min) plus lan_integration (~40 min an attempt under load, retried)
|
||||
steps:
|
||||
- name: 03:00 in Paris
|
||||
- name: 03:07 in Paris
|
||||
id: when
|
||||
env:
|
||||
SCHEDULE: ${{ github.event.schedule }}
|
||||
run: |
|
||||
hour=$(TZ=Europe/Paris date +%H)
|
||||
if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then
|
||||
echo "It is ${hour}:00 in Paris: the other schedule runs the audit."
|
||||
# Which cron is 03:07 in Paris, from Paris's offset today: GitHub can start a schedule hours late, so the
|
||||
# clock at start time can't tell (on 2026-10-26 both started after 04:00 Paris and both skipped).
|
||||
offset=$(TZ=Europe/Paris date +%z)
|
||||
ours=$([ "$offset" = +0200 ] && echo '7 1 * * *' || echo '7 2 * * *')
|
||||
if [[ "${{ github.event_name }}" == schedule && "$SCHEDULE" != "$ours" ]]; then
|
||||
echo "Paris is at UTC${offset} today: the '$ours' schedule runs the audit."
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -65,7 +71,8 @@ jobs:
|
||||
run: |
|
||||
echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT"
|
||||
set +e
|
||||
python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log"
|
||||
# lan_integration (the whole lan_e2e suite) takes ~2,400 s under load, past the usual 1,800.
|
||||
python3 tools/parity.py --all --timeout 3000 2>&1 | tee "$RUNNER_TEMP/audit.log"
|
||||
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
|
||||
{ echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Issues for the harnesses that regressed
|
||||
|
||||
@@ -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/
|
||||
@@ -66,7 +66,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
@@ -156,7 +156,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Most harnesses test this path.
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
@@ -164,8 +164,8 @@ when you touch player-facing gameplay:
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
|
||||
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
|
||||
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
|
||||
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
|
||||
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
|
||||
Generated
+292
-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", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "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>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
|
||||
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
|
||||
//! when given a key, and for a mod a trial run in the sandbox: it must start,
|
||||
//! survive ticks, frames, a chat line and each of its commands, and import
|
||||
//! no more than its capabilities.
|
||||
//! survive ticks, frames, a chat line and each of its commands (a server mod:
|
||||
//! a player joining, chatting, typing them and leaving), and import no more
|
||||
//! than its capabilities.
|
||||
use horizon_mods::{
|
||||
Clock, Mods, Output, Package, Player, Snapshot, Status,
|
||||
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
@@ -46,6 +47,7 @@ impl Report {
|
||||
"api": self.facts.api,
|
||||
"side": self.facts.side,
|
||||
"capabilities": self.facts.capabilities,
|
||||
"api_level": self.facts.api_level,
|
||||
"game": self.facts.game,
|
||||
"requires": requires,
|
||||
"sha256": self.sha256,
|
||||
@@ -69,6 +71,8 @@ impl Report {
|
||||
if let Some(side) = f.side {
|
||||
let side = serde_json::to_value(side).unwrap_or_default();
|
||||
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
|
||||
} else if self.kind == Kind::Shader {
|
||||
out += &format!(", shader API {}", f.api);
|
||||
} else if !f.api.is_empty() {
|
||||
out += &format!(", format {}", f.api);
|
||||
}
|
||||
@@ -84,6 +88,9 @@ impl Report {
|
||||
")\n {} bytes, SHA-256 {}, {signed}\n",
|
||||
self.size, self.sha256
|
||||
);
|
||||
if let Some(level) = f.api_level {
|
||||
out += &format!(" needs mod API level {level} or newer\n");
|
||||
}
|
||||
for c in &f.capabilities {
|
||||
out += &format!(" may {}\n", c.describe());
|
||||
}
|
||||
@@ -113,6 +120,7 @@ pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
"hshader" => Some(Kind::Shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -191,45 +199,81 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
report.commands = mods.commands().map(str::to_owned).collect();
|
||||
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
|
||||
let server: Vec<String> = mods.server_commands().map(str::to_owned).collect();
|
||||
report.commands = [client.clone(), server.clone()].concat();
|
||||
// A server mod plays with one player on, who joins, chats and leaves.
|
||||
let steve = ServerPlayer {
|
||||
id: "00000000-0000-4000-8000-000000000001".into(),
|
||||
name: "Steve".into(),
|
||||
x: 0.5,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
game_mode: GameMode::Survival,
|
||||
health: 20.0,
|
||||
};
|
||||
let snapshot = |t: u64| Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
players: vec![steve.clone()],
|
||||
// The GUI of a 1280 x 720 window at scale 3.
|
||||
gui_size: (427.0, 240.0),
|
||||
..Snapshot::default()
|
||||
};
|
||||
mods.set_snapshot(snapshot(0));
|
||||
mods.player_joined(&steve);
|
||||
for t in 0..TICKS {
|
||||
mods.set_snapshot(Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
});
|
||||
mods.set_snapshot(snapshot(t));
|
||||
mods.tick(t);
|
||||
mods.server_tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
mods.chat("<Steve> hello");
|
||||
for name in report.commands.clone() {
|
||||
mods.command(&name);
|
||||
mods.player_chat(&steve, "hello");
|
||||
for name in &client {
|
||||
mods.command(name);
|
||||
}
|
||||
for name in &server {
|
||||
mods.server_command(&steve, name);
|
||||
}
|
||||
mods.set_snapshot(Snapshot::default());
|
||||
mods.player_left(&steve);
|
||||
for out in mods.drain() {
|
||||
report.output.push(match out {
|
||||
Output::Chat { text, .. } => format!("chat: {text}"),
|
||||
Output::Feedback { text, error } => {
|
||||
Output::Tell { text, .. } => format!("tell: {text}"),
|
||||
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
|
||||
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Command { line } => format!("command: /{line}"),
|
||||
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(()),
|
||||
@@ -275,7 +319,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(e.contains("signature"), "{e}");
|
||||
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
|
||||
assert!(e.contains("shader packs open"), "{e}");
|
||||
assert!(e.contains("horizon-shaders.toml"), "{e}");
|
||||
assert!(check(&pack, Kind::Mod, None, None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -143,6 +143,7 @@ const PAGES: Ty = Ty::Enum(&[
|
||||
"font",
|
||||
"packs",
|
||||
"mods",
|
||||
"shader-packs",
|
||||
"graphics",
|
||||
"graphics-advanced",
|
||||
"extras",
|
||||
|
||||
@@ -610,12 +610,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unreachable_games_fail_with_status_3() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
|
||||
let home =
|
||||
std::env::temp_dir().join(format!("horizon-ctl-unreachable-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
let mut s = Session::new(home.join("nobody.sock"), None);
|
||||
s.home = home.clone();
|
||||
let f = s.run(&["state".to_owned()]).unwrap_err();
|
||||
assert_eq!(f.status, UNREACHABLE);
|
||||
assert_eq!(f.reply["kind"], "connection");
|
||||
let f = s.run(&["sleep".to_owned(), "x".to_owned()]).unwrap_err();
|
||||
assert_eq!(f.status, USAGE);
|
||||
std::fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -24,6 +24,20 @@ pub fn block_strategy() -> Strategy {
|
||||
Strategy::blocks(BLOCK_BITS.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Frees `columns` (`count` of them) on a short-lived thread of its own
|
||||
/// when there are many: a teleport or a dimension change drops a few
|
||||
/// thousand columns at once, and freeing their sections took the frame
|
||||
/// 15-20 ms. A few are freed here.
|
||||
pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
|
||||
if count < 64 {
|
||||
return;
|
||||
}
|
||||
// Should the thread not start, the closure drops `columns` here.
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("Chunk freeing".into())
|
||||
.spawn(move || drop(columns));
|
||||
}
|
||||
|
||||
/// Sizes the block states' global palette for a registry of `states`.
|
||||
pub fn set_block_states(states: usize) {
|
||||
let bits = horizon_core::paletted::ceil_log2(states).max(16);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use crate::{
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
|
||||
mod fluids;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ItemEntity {
|
||||
pub health: i32,
|
||||
@@ -165,18 +167,16 @@ impl ItemEntity {
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
if self.pickup_delay > 0 && self.pickup_delay != u32::MAX {
|
||||
self.pickup_delay -= 1;
|
||||
}
|
||||
let eye = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y + 0.1).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
let in_water = facts.get(eye).fluid == Fluid::Water;
|
||||
// Entity.baseTick pushes before ItemEntity applies buoyancy and moves.
|
||||
let (water_height, lava_height) = self.update_fluids(world, fluids);
|
||||
let in_water = water_height > f64::from(0.1f32);
|
||||
if in_water {
|
||||
// ItemEntity.setUnderwaterMovement
|
||||
self.velocity = Vec3::new(
|
||||
@@ -184,7 +184,7 @@ impl ItemEntity {
|
||||
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
|
||||
self.velocity.z * 0.99,
|
||||
);
|
||||
} else if facts.get(eye).fluid == Fluid::Lava {
|
||||
} else if lava_height > f64::from(0.1f32) {
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.95,
|
||||
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
|
||||
@@ -245,6 +245,8 @@ impl ItemEntity {
|
||||
}
|
||||
}
|
||||
self.age += 1;
|
||||
// ItemEntity.tick refreshes immersion and pushes again after moving.
|
||||
self.update_fluids(world, fluids);
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
}
|
||||
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Dropped-item immersion and currents through the shared vanilla fluid helpers.
|
||||
use super::ItemEntity;
|
||||
use crate::{
|
||||
motion::{FluidResources, FluidTags, LAVA, WATER},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{
|
||||
entity_fluid::{
|
||||
CurrentTarget, EntityFluidInteraction, FluidInteractionWorld, FluidMembership,
|
||||
FluidSections, FluidTagId,
|
||||
},
|
||||
fluid::{FluidHeightWorld, FluidKind, FluidState},
|
||||
fluid_flow::{FluidFlowWorld, flow_at},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
type Error = std::convert::Infallible;
|
||||
struct ItemFluidWorld<'a> {
|
||||
item: &'a mut ItemEntity,
|
||||
world: &'a World,
|
||||
fluids: &'a FluidResources,
|
||||
}
|
||||
impl ItemEntity {
|
||||
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
|
||||
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
|
||||
let mut scene = ItemFluidWorld {
|
||||
item: self,
|
||||
world,
|
||||
fluids,
|
||||
};
|
||||
if interaction.update(&mut scene, false, 4096).is_err() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let water_height = interaction.fluid_height(&scene, WATER);
|
||||
let lava_height = interaction.fluid_height(&scene, LAVA);
|
||||
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
|
||||
let _ = interaction.apply_current(
|
||||
&mut scene,
|
||||
LAVA,
|
||||
if world.fast_lava {
|
||||
0.007
|
||||
} else {
|
||||
0.0023333333333333335
|
||||
},
|
||||
);
|
||||
(water_height, lava_height)
|
||||
}
|
||||
}
|
||||
impl FluidMembership for ItemFluidWorld<'_> {
|
||||
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
|
||||
FluidTags.fluid_in_tag(kind, tag)
|
||||
}
|
||||
}
|
||||
impl CurrentTarget for ItemFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
fn is_player_class(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn velocity(&mut self) -> Result<Vec3, Error> {
|
||||
Ok(self.item.velocity)
|
||||
}
|
||||
fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Error> {
|
||||
let v = self.item.velocity;
|
||||
let sum = Vec3::new(v.x + delta.x, v.y + delta.y, v.z + delta.z);
|
||||
if delta.is_finite() && sum.is_finite() {
|
||||
self.item.velocity = sum;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl FluidInteractionWorld for ItemFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
type Chunk = ();
|
||||
fn interaction_box(&mut self) -> Result<Option<Aabb>, Error> {
|
||||
Ok(Some(
|
||||
self.item.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)),
|
||||
))
|
||||
}
|
||||
fn loaded_chunk(&mut self, x: i32, z: i32) -> Result<Option<()>, Error> {
|
||||
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
|
||||
}
|
||||
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
|
||||
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
|
||||
Ok(FluidSections {
|
||||
minimum_section_y: crate::chunk::MIN_Y >> 4,
|
||||
has_fluid: &SECTIONS,
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
self.item.position.y
|
||||
}
|
||||
fn block_x(&self) -> i32 {
|
||||
self.item.position.x.floor() as i32
|
||||
}
|
||||
fn eye_y(&mut self) -> Result<f64, Error> {
|
||||
Ok(self.item.position.y + f64::from(0.2125f32))
|
||||
}
|
||||
fn block_z(&self) -> i32 {
|
||||
self.item.position.z.floor() as i32
|
||||
}
|
||||
fn fluid_state(&mut self, p: BlockPos) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
|
||||
}
|
||||
fn fluid_height(&mut self, f: FluidState, p: BlockPos) -> Result<f32, Error> {
|
||||
f.height_at(self, [p.x, p.y, p.z])
|
||||
}
|
||||
fn camera_height(&mut self, f: FluidState, p: BlockPos) -> Result<f32, Error> {
|
||||
f.camera_height_at(self, [p.x, p.y, p.z])
|
||||
}
|
||||
fn flow(&mut self, f: FluidState, p: BlockPos) -> Result<Vec3, Error> {
|
||||
flow_at(self, f, p)
|
||||
}
|
||||
}
|
||||
impl FluidHeightWorld for ItemFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
fn fluid(&mut self, p: [i32; 3]) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p[0], p[1], p[2])))
|
||||
}
|
||||
fn sturdy_down(&mut self, p: [i32; 3]) -> Result<bool, Error> {
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
Ok(self.fluids.sturdy(state, Direction::Down))
|
||||
}
|
||||
}
|
||||
impl FluidFlowWorld for ItemFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
type Block = BlockStateId;
|
||||
fn fluid(&mut self, p: BlockPos) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
|
||||
}
|
||||
fn block(&mut self, p: BlockPos) -> Result<BlockStateId, Error> {
|
||||
Ok(self.world.block(p.x, p.y, p.z))
|
||||
}
|
||||
fn blocks_fluid_flow(&self, s: BlockStateId) -> bool {
|
||||
self.fluids.blocks_flow(s)
|
||||
}
|
||||
fn is_ice_class(&self, s: BlockStateId) -> bool {
|
||||
self.fluids.ice(s)
|
||||
}
|
||||
fn is_face_sturdy(
|
||||
&mut self,
|
||||
s: BlockStateId,
|
||||
_: BlockPos,
|
||||
d: Direction,
|
||||
) -> Result<bool, Error> {
|
||||
Ok(self.fluids.sturdy(s, d))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn items_follow_flowing_water_but_still_water_has_no_horizontal_push() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let mut state = assets.registry.state(water).unwrap().clone();
|
||||
state.properties.insert("level".into(), "1".into());
|
||||
let flowing = assets.registry.resolve(&state).unwrap();
|
||||
for x in 19..=21 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 3, z, stone).unwrap();
|
||||
world.set(x, 4, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let item = ItemEntity {
|
||||
id: 1,
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
stack: crate::items::ItemStack {
|
||||
item: "minecraft:sugar_cane".into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
thrower: None,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 0,
|
||||
};
|
||||
let mut still = item.clone();
|
||||
assert!(still.tick(&world, &facts, &collision, false, &fluids));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let mut carried = item;
|
||||
assert!(carried.tick(&world, &facts, &collision, false, &fluids));
|
||||
// First push is damped by buoyancy before motion, second follows drag.
|
||||
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
|
||||
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
|
||||
for _ in 0..20 {
|
||||
carried.tick(&world, &facts, &collision, false, &fluids);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
}
|
||||
@@ -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,23 +378,6 @@ impl Cps {
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
Zoom,
|
||||
ZoomKey,
|
||||
ZoomLevel,
|
||||
ZoomSmooth,
|
||||
ZoomSensitivity,
|
||||
Fps,
|
||||
FpsCorner,
|
||||
FpsLows,
|
||||
Keystrokes,
|
||||
KeystrokesCorner,
|
||||
KeystrokesCps,
|
||||
Minimap,
|
||||
MinimapCorner,
|
||||
MinimapSize,
|
||||
MinimapShape,
|
||||
MinimapZoom,
|
||||
MinimapRotate,
|
||||
AutoReconnect,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
@@ -482,27 +396,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),
|
||||
Ok(MinimapSize),
|
||||
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),
|
||||
@@ -512,46 +405,16 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(AutoReconnect),
|
||||
]
|
||||
}
|
||||
/// "1 block", "2 blocks", "½ block".
|
||||
fn block_count(n: f32) -> String {
|
||||
if n < 1.0 {
|
||||
"½ block".into()
|
||||
} else if n == 1.0 {
|
||||
"1 block".into()
|
||||
} else {
|
||||
format!("{n} blocks")
|
||||
}
|
||||
}
|
||||
fn next<T: Copy + PartialEq>(all: &[T], current: T, back: bool) -> T {
|
||||
let i = all.iter().position(|v| *v == current).unwrap_or(0);
|
||||
let n = all.len();
|
||||
all[if back { (i + n - 1) % n } else { (i + 1) % n }]
|
||||
}
|
||||
impl Item {
|
||||
pub fn caption(self) -> &'static str {
|
||||
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::Minimap => "Minimap",
|
||||
Item::MinimapSize => "Size",
|
||||
Item::MinimapShape => "Shape",
|
||||
Item::MinimapZoom => "Zoom",
|
||||
Item::MinimapRotate => "Turn with the player",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::MouseTweaks => "Mouse Tweaks controls",
|
||||
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 +424,10 @@ 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 | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -580,24 +439,9 @@ 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::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.",
|
||||
Item::MinimapZoom => "Blocks per pixel: smaller shows more detail, larger more land.",
|
||||
Item::MinimapRotate => "Forward is up; off keeps north up.",
|
||||
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 +454,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,8 +469,8 @@ 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::Minimap | Item::DynamicLights => Some(Cost::Low),
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
@@ -638,15 +480,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,
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
@@ -660,65 +493,32 @@ 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()
|
||||
.find(|s| s.0 == e.minimap_size)
|
||||
.map_or("Medium", |s| s.1)
|
||||
.into(),
|
||||
Item::MinimapShape => if e.minimap_round { "Round" } else { "Square" }.into(),
|
||||
Item::MinimapZoom => format!("{} per pixel", block_count(e.minimap_zoom)),
|
||||
other => match other.switch(e) {
|
||||
Some(true) => "On".into(),
|
||||
_ => "Off".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
/// A click (`back` for the right button). The zoom key binds instead.
|
||||
pub fn cycle(self, e: &mut Extras, back: bool) {
|
||||
/// A click (`_back` for the right button: every item left is a switch,
|
||||
/// so both flip it). 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,
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort = !e.sort,
|
||||
Item::MinimapCorner => e.minimap_corner = next(&Corner::ALL, e.minimap_corner, back),
|
||||
Item::MinimapSize => {
|
||||
let sizes: Vec<u8> = MINIMAP_SIZES.iter().map(|s| s.0).collect();
|
||||
e.minimap_size = next(&sizes, e.minimap_size, back);
|
||||
}
|
||||
Item::MinimapShape => e.minimap_round = !e.minimap_round,
|
||||
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
|
||||
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 +550,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 +576,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 +600,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,20 +653,9 @@ 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::MinimapZoom.cycle(&mut e, true);
|
||||
assert_eq!(Item::MinimapZoom.value(&e), "½ block per pixel");
|
||||
Item::MinimapSize.cycle(&mut e, false);
|
||||
assert_eq!(Item::MinimapSize.value(&e), "Large");
|
||||
Item::Sort.cycle(&mut e, true);
|
||||
assert_eq!(Item::Sort.value(&e), "On");
|
||||
// The minimap's old keys still parse, for the move to its mod.
|
||||
assert_eq!(
|
||||
Extras::parse("minimapSize:7\nminimapZoom:3").minimap_zoom,
|
||||
1.0
|
||||
|
||||
@@ -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;
|
||||
@@ -126,6 +131,7 @@ mod pumpkin;
|
||||
mod raids;
|
||||
mod recipe_book;
|
||||
mod remote;
|
||||
mod remote_chunks;
|
||||
mod remote_mob_data;
|
||||
mod remote_mob_use;
|
||||
mod remote_objects;
|
||||
@@ -349,10 +355,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 +364,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 +381,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 +559,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 +696,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 +1152,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 +1221,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 +1254,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 +1345,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 +1512,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 +1580,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 +1594,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 +2210,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;
|
||||
};
|
||||
@@ -2239,7 +2272,13 @@ impl Game<'_> {
|
||||
let speed = self.world.random.next_float() * 0.2 + 0.1;
|
||||
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
|
||||
}
|
||||
let alive = item.tick(&self.world, self.res.facts(), self.collision, no_physics);
|
||||
let alive = item.tick(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
no_physics,
|
||||
&self.res.motion().fluids,
|
||||
);
|
||||
cells.moved(i, item.previous, item.position);
|
||||
// ItemEntity.tick: merge every 2 ticks while moving between
|
||||
// blocks, every 40 otherwise, before the lifetime check.
|
||||
@@ -2448,6 +2487,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 +2624,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 +2836,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 +3480,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 +3583,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 +3920,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));
|
||||
@@ -4091,6 +4200,9 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
|
||||
}
|
||||
fn tick(&mut self) -> Result<()> {
|
||||
if self.agent.hold_server {
|
||||
return Ok(());
|
||||
}
|
||||
self.tick_debug_keys()?;
|
||||
self.tick_debug_charts();
|
||||
if self.remote.is_some() {
|
||||
@@ -4310,6 +4422,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 +4528,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 +4806,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 +4929,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 +4956,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 +5062,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 +5277,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 +5547,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 => {
|
||||
@@ -5472,6 +5610,8 @@ impl Game<'_> {
|
||||
self.craft_size = 2;
|
||||
self.craft_table = None;
|
||||
if let Some(key) = self.container.take() {
|
||||
// A cart's or chest boat's items go back to it.
|
||||
self.close_cart_container(key);
|
||||
self.container_opener_event(key, false);
|
||||
self.post_block_game_event(
|
||||
"minecraft:container_close",
|
||||
@@ -5511,7 +5651,6 @@ impl Game<'_> {
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
self.close_ender_chest();
|
||||
self.close_cart_container();
|
||||
}
|
||||
/// `quickMoveStack` of the open plain menu for a shift-click.
|
||||
fn inventory_quick_move(&mut self, slot: usize) -> bool {
|
||||
@@ -6391,7 +6530,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 +6891,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 +6986,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 +7008,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 +7072,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 +7103,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 +7119,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 +7128,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 +7192,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 +7223,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 +7262,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 +8575,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 +8599,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 +8810,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 +9409,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 +10174,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 +10406,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 +10471,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 +10518,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 +10598,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 +10651,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 +10796,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 +10919,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 {
|
||||
@@ -10793,11 +11021,7 @@ impl Game<'_> {
|
||||
bytes += size;
|
||||
}
|
||||
}
|
||||
for c in unloaded {
|
||||
for sy in 0..(HEIGHT / 16) {
|
||||
renderer.remove((c.x, sy, c.z));
|
||||
}
|
||||
}
|
||||
renderer.remove_columns(unloaded.iter().map(|c| (c.x, c.z)));
|
||||
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
|
||||
let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info);
|
||||
lod.set_near_loading(self.streaming.busy());
|
||||
@@ -11034,6 +11258,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 +11300,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 +11316,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 +11372,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 +11491,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 +11551,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 +11577,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 +11590,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 +11727,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 +11819,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 +12080,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 +12135,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 +12154,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 +12213,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 +12329,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 +12361,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 +13095,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
hud,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
entity_spans: &[],
|
||||
dynamic_lights: &[],
|
||||
clouds: None,
|
||||
lightning: Default::default(),
|
||||
@@ -12910,6 +13170,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 +13198,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 +13224,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 +13252,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 +13356,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 +13394,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);
|
||||
}
|
||||
|
||||
@@ -300,6 +300,8 @@ struct Glide {
|
||||
pub(super) struct Agent {
|
||||
task: Option<Task>,
|
||||
glide: Option<Glide>,
|
||||
/// Control-fixture barrier: hold simulation and packet handling, not transport threads.
|
||||
pub hold_server: bool,
|
||||
}
|
||||
|
||||
pub(super) enum Handled {
|
||||
@@ -390,6 +392,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";
|
||||
}
|
||||
@@ -476,6 +481,8 @@ impl Game<'_> {
|
||||
"absorption": p.health.absorption(),
|
||||
// The hurt animation's ticks left (the camera's hurt tilt).
|
||||
"hurt_time": p.health.hurt_animation_time,
|
||||
// LivingEntity.deathTime while dead (the camera's death roll).
|
||||
"death_time": self.death_time(),
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
@@ -485,6 +492,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 {
|
||||
@@ -663,6 +671,7 @@ impl Game<'_> {
|
||||
});
|
||||
if let Some(chat) = &self.ui.chat_screen {
|
||||
v["chat_input"] = json!(chat.input.value);
|
||||
v["chat_suggestions"] = json!(chat.suggestions_visible());
|
||||
}
|
||||
if self.title.is_some() {
|
||||
v["menu"] = self.title_value();
|
||||
@@ -682,6 +691,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.
|
||||
@@ -171,6 +219,11 @@ impl Game<'_> {
|
||||
/// client tick (`ClientLevel.tickBlockEntities`).
|
||||
pub(super) fn tick_block_entity_anims(&mut self) {
|
||||
let Some(r) = &self.renderer else {
|
||||
// A server has no window, but its gateways still run their
|
||||
// server ticker.
|
||||
if self.remote.is_none() {
|
||||
self.tick_gateway_servers();
|
||||
}
|
||||
return;
|
||||
};
|
||||
// Sorted by position: the order they tick in, and a quick lookup.
|
||||
@@ -330,6 +383,31 @@ impl Game<'_> {
|
||||
}
|
||||
self.be_anims.present = positions;
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the
|
||||
/// gateways a server keeps a copy of, in block-ticking chunks
|
||||
/// (`Level.tickBlockEntities`): the cooldown an entry started runs out,
|
||||
/// and every 2400 ticks `triggerCooldown` sends its block event.
|
||||
fn tick_gateway_servers(&mut self) {
|
||||
let levels = self.tickets.levels();
|
||||
let mut positions: Vec<[i32; 3]> = self
|
||||
.be_anims
|
||||
.gateway_servers
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|p| levels.block_ticking_at(p[0], p[2]))
|
||||
.collect();
|
||||
positions.sort_unstable();
|
||||
for pos in positions {
|
||||
let triggered = self
|
||||
.be_anims
|
||||
.gateway_servers
|
||||
.get_mut(&pos)
|
||||
.is_some_and(EndGateway::portal_tick);
|
||||
if triggered {
|
||||
self.block_entity_event(pos, 1, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `EndGatewayBlock.entityInside` on the server: whether the gateway at
|
||||
/// `pos` let the entity in (`triggerCooldown`, with its block event).
|
||||
pub(super) fn gateway_enter(&mut self, pos: [i32; 3]) -> bool {
|
||||
@@ -392,4 +470,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),
|
||||
@@ -1094,6 +1097,12 @@ impl Game<'_> {
|
||||
Target::Player => {
|
||||
// ServerPlayer.teleport: removeVehicle before placing the player.
|
||||
self.stop_riding();
|
||||
// A server's player teleported within their level is placed
|
||||
// there at once: the arrival they were waiting for elsewhere
|
||||
// in it (whose chunks lose their tickets) is abandoned.
|
||||
if let Some(conn) = self.lan_acting {
|
||||
self.server_trips.retain(|trip| !trip.is(conn));
|
||||
}
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = v;
|
||||
p.velocity = Vec3::new(0.0, 0.0, 0.0);
|
||||
@@ -2499,12 +2508,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 +2529,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 +2848,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", &[]))?;
|
||||
|
||||
@@ -11,6 +11,9 @@ use winit::{event::MouseButton, event_loop::ActiveEventLoop, keyboard::KeyCode};
|
||||
|
||||
const HELP: &[&str] = &[
|
||||
"status game, player, streaming and renderer state",
|
||||
"simulation authoritative projectiles in the owner's level",
|
||||
"server-hold on|off hold simulation and player packets at a control-fixture barrier",
|
||||
"stall MILLISECONDS stall the window thread (0..5000), leaving transport alive",
|
||||
"key KEY down|up|tap|repeat press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)",
|
||||
"release release every key and mouse button",
|
||||
"mouse left|right|middle down|up|click",
|
||||
@@ -238,9 +241,57 @@ impl Game<'_> {
|
||||
}
|
||||
"sculk" => Ok(Some(self.sculk_command(&words)?)),
|
||||
"status" => Ok(Some(self.control_status())),
|
||||
"stall" => {
|
||||
let millis: u64 = arg(1)?.parse()?;
|
||||
if millis > 5000 {
|
||||
return Err("stall is limited to 5000 milliseconds".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(millis));
|
||||
ok()
|
||||
}
|
||||
"simulation" => {
|
||||
self.require_world()?;
|
||||
let value = if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(|g| {
|
||||
g.in_owner_level(|g| {
|
||||
json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() })
|
||||
})
|
||||
})
|
||||
.ok_or("the integrated server stopped")?
|
||||
} else {
|
||||
json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() })
|
||||
};
|
||||
Ok(Some(value))
|
||||
}
|
||||
"server-hold" => {
|
||||
self.require_world()?;
|
||||
let held = match arg(1)? {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return Err("server-hold on|off".into()),
|
||||
};
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.agent.hold_server = held)
|
||||
.ok_or("the integrated server stopped")?;
|
||||
} else if self.remote.is_some() {
|
||||
return Err("server-hold requires the world's host".into());
|
||||
} else {
|
||||
self.agent.hold_server = held;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"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 +339,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 +419,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)?;
|
||||
}
|
||||
@@ -496,10 +552,23 @@ impl Game<'_> {
|
||||
"mobs" => {
|
||||
self.require_world()?;
|
||||
let radius: f64 = words.get(1).map_or(Ok(64.0), |r| r.parse())?;
|
||||
if let Some(server) = &self.integrated {
|
||||
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let value = server
|
||||
.call(move |g| g.in_owner_level(|g| g.control_mobs_at(radius, center)))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(Some(self.control_mobs(radius)))
|
||||
}
|
||||
"hurtlog" => {
|
||||
self.require_world()?;
|
||||
if let Some(server) = &self.integrated {
|
||||
let value = server
|
||||
.call(|g| g.control_hurt_log())
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(Some(self.control_hurt_log()))
|
||||
}
|
||||
"resume" => {
|
||||
@@ -533,13 +602,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();
|
||||
@@ -596,6 +669,7 @@ impl Game<'_> {
|
||||
"font" => Page::Font,
|
||||
"packs" => Page::Packs,
|
||||
"mods" => Page::Mods,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"extras" => Page::Extras,
|
||||
@@ -609,6 +683,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.open_options(self.title.is_some());
|
||||
self.ui.options.show(page);
|
||||
if page == Page::ShaderPacks {
|
||||
self.settings.shader_packs =
|
||||
horizon_client::shader_pack_list::scan(&Self::shader_packs_dir());
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"chat-log" => {
|
||||
|
||||
@@ -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).
|
||||
@@ -316,6 +312,9 @@ impl Game<'_> {
|
||||
/// The `mobs` command: nearby mobs with position, health and running goals.
|
||||
pub(super) fn control_mobs(&self, radius: f64) -> serde_json::Value {
|
||||
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
self.control_mobs_at(radius, center)
|
||||
}
|
||||
pub(super) fn control_mobs_at(&self, radius: f64, center: Vec3) -> serde_json::Value {
|
||||
let list: Vec<serde_json::Value> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -508,6 +507,17 @@ impl Game<'_> {
|
||||
Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players),
|
||||
_ => Default::default(),
|
||||
};
|
||||
// The guests' entity ids by connection, for the shulker bullets fired
|
||||
// at the guest a mob sees.
|
||||
let guest_ids: Vec<(u32, u32)> = match self.lan.as_ref() {
|
||||
Some(host) if !guests.is_empty() => host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.map(|g| (g.conn, g.entity_id as u32))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut guest_events = Vec::new();
|
||||
let level_random;
|
||||
{
|
||||
@@ -582,6 +592,19 @@ impl Game<'_> {
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
// bullet homes on it while the projectiles tick, where
|
||||
// `Who::Player` is the host's own player.
|
||||
if let Some(&(_, id)) = guest_ids.iter().find(|g| g.0 == conn) {
|
||||
for e in &mut events[from..] {
|
||||
if let MobEvent::Projectile(p) = e
|
||||
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
|
||||
&& *target == Who::Player
|
||||
{
|
||||
*target = Who::Mob(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if super::lan_mobs::acts_on_player(&events[k]) {
|
||||
@@ -642,7 +665,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 +1594,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 +2894,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 +2926,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 +3049,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`); a mod's minimap
|
||||
//! (`render.set-minimap`: its tiles, image and HUD) and zoom (`render.set-zoom`) on the camera; clicks
|
||||
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
|
||||
//! Each hook does nothing while its extra is off and no mod runs.
|
||||
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,29 @@ 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 a mod shows (`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 m = self.mod_minimap()?;
|
||||
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: m.rotate,
|
||||
blocks_per_pixel: m.blocks_per_pixel,
|
||||
round: m.round,
|
||||
};
|
||||
Some((m.corner, m.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 {
|
||||
if self.mod_minimap().is_none() || self.player.is_none() {
|
||||
self.extras.minimap.clear();
|
||||
self.extras.minimap_dim = None;
|
||||
}
|
||||
@@ -209,45 +198,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 +253,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);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,18 @@ impl Rocket {
|
||||
(self.position, self.velocity)
|
||||
}
|
||||
}
|
||||
/// `damageAmount * (float)Math.sqrt((5.0 - this.distanceTo(target)) / 5.0)`
|
||||
/// from the rocket at `from` to a target at `to`; `Entity.distanceTo` sums
|
||||
/// the squared offsets in floats.
|
||||
fn blast_damage(damage: f32, from: Vec3, to: Vec3) -> f32 {
|
||||
let (x, y, z) = (
|
||||
(from.x - to.x) as f32,
|
||||
(from.y - to.y) as f32,
|
||||
(from.z - to.z) as f32,
|
||||
);
|
||||
let distance = f64::from(x * x + y * y + z * z).sqrt() as f32;
|
||||
damage * ((5.0 - f64::from(distance)) / 5.0).sqrt() as f32
|
||||
}
|
||||
pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
// The item's default component: flight 1, no explosions.
|
||||
stack
|
||||
@@ -251,21 +263,24 @@ impl Game<'_> {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
if !r.explosions.is_empty() {
|
||||
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
|
||||
// The player it boosts takes the full damage, a guest as well.
|
||||
if r.attached {
|
||||
match r.guest {
|
||||
None => self.hurt_player_by("minecraft:fireworks", damage),
|
||||
Some(conn) => {
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
self.as_guest(&mut host, conn, |g| {
|
||||
g.hurt_player_by("minecraft:fireworks", damage);
|
||||
});
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let from = r.position;
|
||||
// `attachedToEntity` takes the full damage, a guest as well; every
|
||||
// other player in range and in sight takes the blast.
|
||||
let boosted = r.guest.filter(|_| r.attached);
|
||||
if r.attached && boosted.is_none() {
|
||||
self.rocket_hurt_player(damage);
|
||||
} else {
|
||||
self.rocket_blast_player(from, damage);
|
||||
}
|
||||
if let Some(conn) = boosted
|
||||
&& let Some(mut host) = self.lan.take()
|
||||
{
|
||||
self.as_guest(&mut host, conn, |g| g.rocket_hurt_player(damage));
|
||||
self.lan = Some(host);
|
||||
}
|
||||
// Every other player in the level (LAN guests, the players of a
|
||||
// server thread), each swapped in as the player.
|
||||
self.each_guest_hit_except(boosted, |g| g.rocket_blast_player(from, damage));
|
||||
let hits: Vec<(u32, f32)> = {
|
||||
let world = self.mob_world_base(&[]);
|
||||
self.mobs
|
||||
@@ -285,7 +300,7 @@ impl Game<'_> {
|
||||
);
|
||||
world.clear_between(from, to, false)
|
||||
});
|
||||
sees.then(|| (m.id, damage * ((5.0 - d2.sqrt()) / 5.0).sqrt() as f32))
|
||||
sees.then(|| (m.id, blast_damage(damage, from, m.position)))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -307,6 +322,49 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera);
|
||||
}
|
||||
/// `dealExplosionDamage` on a player that is not the rocket's
|
||||
/// `attachedToEntity`: within 5 blocks, when the rocket sees its feet or
|
||||
/// its middle (`ClipContext.Block.COLLIDER`, no fluids).
|
||||
fn rocket_blast_player(&mut self, from: Vec3, damage: f32) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let feet = p.position;
|
||||
let d2 = (feet.x - from.x).powi(2) + (feet.y - from.y).powi(2) + (feet.z - from.z).powi(2);
|
||||
if d2 > 25.0 {
|
||||
return;
|
||||
}
|
||||
let height = f64::from(p.dimensions.height);
|
||||
let sees = {
|
||||
let world = self.mob_world_base(&[]);
|
||||
(0..2).any(|step| {
|
||||
let to = Vec3::new(feet.x, feet.y + height * 0.5 * f64::from(step), feet.z);
|
||||
world.clear_between(from, to, false)
|
||||
})
|
||||
};
|
||||
if sees {
|
||||
self.rocket_hurt_player(blast_damage(damage, from, feet));
|
||||
}
|
||||
}
|
||||
/// `hurtServer` with `damageSources().fireworks`: armor absorbs it and
|
||||
/// wears (`hurtArmor`); creative and spectator players are immune.
|
||||
fn rocket_hurt_player(&mut self, amount: f32) {
|
||||
if self.creative || self.spectator() {
|
||||
return;
|
||||
}
|
||||
let hurt = if let Some(p) = self.player.as_mut()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& let Some(id) = self.motion.health.damage.id("minecraft:fireworks")
|
||||
{
|
||||
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if hurt {
|
||||
self.hurt_armor(amount);
|
||||
}
|
||||
}
|
||||
/// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera,
|
||||
/// lit at the rocket's light probe.
|
||||
pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
|
||||
@@ -64,14 +64,14 @@ impl Game<'_> {
|
||||
});
|
||||
self.view.tick(eye, target);
|
||||
}
|
||||
/// The player's `deathTime` while dead or dying.
|
||||
fn death_time(&self) -> Option<i32> {
|
||||
/// The player's `deathTime` while dead or dying: whole ticks counted by
|
||||
/// `LivingEntity.tickDeath`, so `deathTime + partialTicks` in
|
||||
/// `Camera.extractRenderState` rises smoothly across frames.
|
||||
pub(super) fn death_time(&self) -> Option<i32> {
|
||||
let p = self.player.as_ref()?;
|
||||
p.health.dead_or_dying().then(|| {
|
||||
self.ui
|
||||
.died_at
|
||||
.map_or(0, |t| (t.elapsed().as_secs_f32() * 20.0) as i32)
|
||||
})
|
||||
p.health
|
||||
.dead_or_dying()
|
||||
.then(|| self.ui.death_time.min(i32::MAX as u32) as i32)
|
||||
}
|
||||
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
|
||||
pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
|
||||
|
||||
@@ -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,9 +357,25 @@ 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();
|
||||
if self.agent.hold_server {
|
||||
// Keep servicing control requests so the fixture can release the barrier.
|
||||
// Unlike /tick freeze, no player's queued packets are applied here.
|
||||
self.wait_for_next_server_tick(&mut next, true);
|
||||
continue;
|
||||
}
|
||||
let paused = flags.paused.load(Ordering::Relaxed);
|
||||
if paused {
|
||||
// Only the connections run.
|
||||
@@ -692,6 +708,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 +760,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) {
|
||||
@@ -1138,6 +1192,10 @@ impl Game<'_> {
|
||||
{
|
||||
self.container_changed(key);
|
||||
}
|
||||
// A vehicle's menu edits the vehicle's own items.
|
||||
if let Some(Slots::Block(key)) = slots {
|
||||
self.store_cart_items(key);
|
||||
}
|
||||
self.drop_guest_items(host, gi, drops);
|
||||
}
|
||||
S::ContainerClose(p) => {
|
||||
@@ -1777,12 +1835,28 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
if self.on_server_trip(conn) {
|
||||
// LivingEntity.hurt kills at once (die), whether the level
|
||||
// has loaded around the player or not: its respawn request
|
||||
// is taken as soon as it comes.
|
||||
self.guest_die(host, gi, conn);
|
||||
continue;
|
||||
}
|
||||
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 +1882,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;
|
||||
}
|
||||
@@ -1851,69 +1924,72 @@ impl Game<'_> {
|
||||
self.guest_dig_feedback(host, conn);
|
||||
self.guest_pickups(host, gi);
|
||||
self.guest_orbs(host, gi);
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
// FishingHook.shouldStopFishing: its owner is dead.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.hook = None;
|
||||
}
|
||||
self.guest_die(host, gi, conn);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPlayer.die` for a guest whose health has run out: the death
|
||||
/// message, the loot and experience, the death screen.
|
||||
fn guest_die(&mut self, host: &mut LanHost, gi: usize, conn: u32) {
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
// FishingHook.shouldStopFishing: its owner is dead.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.hook = None;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
g.last_death = Some(g.position.map(|v| v.floor() as i32));
|
||||
let (id, at) = (g.entity_id, g.position);
|
||||
let reward = Experience {
|
||||
level: g.xp_level,
|
||||
progress: g.xp_progress,
|
||||
total: g.xp_total,
|
||||
}
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
let (name, uuid) = (g.name.clone(), g.uuid);
|
||||
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
|
||||
let drops = (g.game_mode != SPECTATOR)
|
||||
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
// ServerPlayer.die: with showDeathMessages, the message goes to
|
||||
// the dying player's death screen and to everyone the team lets
|
||||
// see it; without it, the death screen gets an empty one.
|
||||
let message = if self.ui.rules.bool("show_death_messages") {
|
||||
let me = self.player_text(&name, uuid);
|
||||
let text = match cause {
|
||||
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
|
||||
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
|
||||
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
|
||||
}
|
||||
None => self.guest_death_message(conn, me),
|
||||
};
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
&format!("System chat: {}", text.plain()),
|
||||
);
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
g.last_death = Some(g.position.map(|v| v.floor() as i32));
|
||||
let (id, at) = (g.entity_id, g.position);
|
||||
let reward = Experience {
|
||||
level: g.xp_level,
|
||||
progress: g.xp_progress,
|
||||
total: g.xp_total,
|
||||
}
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
let (name, uuid) = (g.name.clone(), g.uuid);
|
||||
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
|
||||
let drops =
|
||||
(g.game_mode != SPECTATOR).then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
// ServerPlayer.die: with showDeathMessages, the message goes to
|
||||
// the dying player's death screen and to everyone the team lets
|
||||
// see it; without it, the death screen gets an empty one.
|
||||
let message = if self.ui.rules.bool("show_death_messages") {
|
||||
let me = self.player_text(&name, uuid);
|
||||
let text = match cause {
|
||||
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
|
||||
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
|
||||
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
|
||||
}
|
||||
message
|
||||
} else {
|
||||
literal("")
|
||||
None => self.guest_death_message(conn, me),
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::PlayerCombatKill(play::PlayerCombatKill { id, message }),
|
||||
);
|
||||
// LivingEntity.die: the death animation, the dying player included.
|
||||
host.hub.entity_event(id, 3, at);
|
||||
if let Some(eyes) = drops {
|
||||
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log("INFO", &format!("System chat: {}", text.plain()));
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
}
|
||||
self.spawn_orbs(at, reward);
|
||||
message
|
||||
} else {
|
||||
literal("")
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::PlayerCombatKill(play::PlayerCombatKill { id, message }),
|
||||
);
|
||||
// LivingEntity.die: the death animation, the dying player included.
|
||||
host.hub.entity_event(id, 3, at);
|
||||
if let Some(eyes) = drops {
|
||||
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
|
||||
}
|
||||
self.spawn_orbs(at, reward);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2111,7 +2187,16 @@ impl Game<'_> {
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
// Player.tick ends with updatePlayerPose: a swimmer, or a player
|
||||
// crawling under a block, takes the short box its moves are checked with.
|
||||
let _ = w.update_player_pose();
|
||||
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 +2238,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),
|
||||
|
||||
@@ -291,6 +291,11 @@ impl Game<'_> {
|
||||
let at = host.hub.guests[gi].position;
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
// Slot.setChanged on a changed container slot: BlockEntity.setChanged,
|
||||
// so the chunk is saved and comparators read the block again.
|
||||
if changed.iter().any(|&s| s < items.len()) {
|
||||
self.container_changed(pos);
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
|
||||
host.hub.inventory_clicked(conn, p, &self.world, &changed);
|
||||
true
|
||||
|
||||
@@ -119,7 +119,17 @@ impl Game<'_> {
|
||||
|
||||
/// Runs `f` as each guest in play here, for what strikes every player in
|
||||
/// an area (`Level.getEntities`), not only the window's own.
|
||||
pub(super) fn each_guest_hit(&mut self, mut f: impl FnMut(&mut Self)) {
|
||||
pub(super) fn each_guest_hit(&mut self, f: impl FnMut(&mut Self)) {
|
||||
self.each_guest_hit_except(None, f);
|
||||
}
|
||||
|
||||
/// [`Self::each_guest_hit`] leaving out the guest `except` (the one a
|
||||
/// firework rocket is attached to, which it hurts on its own).
|
||||
pub(super) fn each_guest_hit_except(
|
||||
&mut self,
|
||||
except: Option<u32>,
|
||||
mut f: impl FnMut(&mut Self),
|
||||
) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
@@ -127,7 +137,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && Some(g.conn) != except)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
@@ -171,6 +181,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 {
|
||||
|
||||
@@ -1117,22 +1117,35 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// Closing a cart's container copies the items back.
|
||||
pub(super) fn close_cart_container(&mut self) {
|
||||
let Some(key) = self.container.filter(|k| k.1 == CART_KEY_Y) else {
|
||||
/// Closing the cart's container open under `key` copies the items back.
|
||||
pub(super) fn close_cart_container(&mut self, key: (i32, i32, i32)) {
|
||||
if key.1 != CART_KEY_Y {
|
||||
return;
|
||||
};
|
||||
let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key) else {
|
||||
}
|
||||
self.store_cart_items(key);
|
||||
self.world.block_entities.remove(&key);
|
||||
if let Some(c) = self.carts.iter().find(|c| c.id as i32 == key.2)
|
||||
&& c.kind == CartKind::Chest
|
||||
{
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio
|
||||
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
|
||||
}
|
||||
}
|
||||
/// The menu of a chest or hopper minecart or a chest boat works on the
|
||||
/// vehicle's own items (`ContainerEntity`): the placeholder under `key`
|
||||
/// that the menu edits is copied back to the vehicle.
|
||||
pub(super) fn store_cart_items(&mut self, key: (i32, i32, i32)) {
|
||||
if key.1 != CART_KEY_Y {
|
||||
return;
|
||||
}
|
||||
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
let items = items.clone();
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id as i32 == key.2) {
|
||||
c.items = items;
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
if c.kind == CartKind::Chest {
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio
|
||||
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
|
||||
}
|
||||
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id as i32 == key.2) {
|
||||
b.items = items;
|
||||
}
|
||||
@@ -1463,6 +1476,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,29 @@ 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, Runs, 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 +63,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 +107,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 +135,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 +248,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) {}
|
||||
}
|
||||
|
||||
@@ -227,8 +272,10 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let enabled = &self.settings.mods.enabled;
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let mods = Mods::load(&paths, on, vanilla_command);
|
||||
let mods = Mods::load_for(Runs::Client, &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 +369,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 +385,37 @@ 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(),
|
||||
players: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A game tick.
|
||||
pub(super) fn tick_mods(&mut self) {
|
||||
@@ -350,6 +427,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 +443,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 +453,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,18 +515,30 @@ 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);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Server mods' lines: this game runs no server mod yet.
|
||||
Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. } => continue,
|
||||
};
|
||||
// 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 +567,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 +698,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 +709,7 @@ impl Game<'_> {
|
||||
settings,
|
||||
under: self.screen,
|
||||
scrolls: Vec::new(),
|
||||
binding: None,
|
||||
});
|
||||
self.screen = Screen::Mod;
|
||||
if self.player.is_some() {
|
||||
@@ -561,13 +739,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 +771,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 +847,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 +911,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 +932,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 +1021,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,120 @@
|
||||
//! Horizon's own shader packs in the game (docs/shaders.md): the pick in `shaderpacks/` (a
|
||||
//! folder with `horizon-shaders.toml`, or a `.hshader`; `game/shader_packs.rs`) runs 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 name in `shaderpacks/`, of either kind (`game/shader_packs.rs`); empty: none.
|
||||
pub picked: String,
|
||||
/// The pack's files when it was last read (`fingerprint`).
|
||||
stamp: Option<u64>,
|
||||
looked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl NativeShaders {
|
||||
pub fn picking(name: &str) -> Self {
|
||||
Self {
|
||||
picked: name.to_owned(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -85,6 +78,10 @@ impl Game<'_> {
|
||||
.unwrap_or_default()
|
||||
.join("options.txt")
|
||||
}
|
||||
/// `shaderpacks/` beside `options.txt`.
|
||||
pub(super) fn shader_packs_dir() -> std::path::PathBuf {
|
||||
Self::options_path().with_file_name(horizon_client::shader_pack_list::DIR)
|
||||
}
|
||||
pub(super) fn gui_size(&self) -> (f32, f32) {
|
||||
let size = self
|
||||
.renderer
|
||||
@@ -237,6 +234,17 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ListShaderPacks => {
|
||||
self.settings.shader_packs =
|
||||
horizon_client::shader_pack_list::scan(&Self::shader_packs_dir());
|
||||
}
|
||||
// Saved; the next frame's `shader_pack_inputs` loads the pick.
|
||||
Event::ShaderPack => self.settings.save(&Self::options_path()),
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = Self::shader_packs_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ModSettings(i) => self.open_mod_screen(i, true),
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,355 @@
|
||||
//! A server's chunk columns (`ClientboundLevelChunkWithLightPacket`): the
|
||||
//! sections and light of each packet become a client column on a decoder
|
||||
//! thread as soon as the packet arrives, so the frame that handles it in
|
||||
//! order only inserts the result (`ClientPacketListener.handleLevelChunkWithLight`).
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
chunk::{BIOME_STRATEGY, Chunk, Light, MIN_Y, SECTIONS, Section, block_strategy},
|
||||
streaming::Streaming,
|
||||
};
|
||||
use horizon_core::paletted::{PalettedContainer, Strategy};
|
||||
use horizon_protocol::{
|
||||
chunk::{LightData, SectionLight},
|
||||
packets::play::{self, Clientbound as C},
|
||||
registry::DimensionType,
|
||||
};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
mpsc::{self, TryRecvError},
|
||||
};
|
||||
|
||||
/// What turning a chunk packet into a client column depends on: the
|
||||
/// dimension's height and sky, and the server's biome registry mapped to
|
||||
/// ours. The connection replaces it when either changes, so a column decoded
|
||||
/// with an older one is decoded again.
|
||||
#[derive(Default)]
|
||||
pub(super) struct ChunkFormat {
|
||||
min_y: i32,
|
||||
sections: usize,
|
||||
has_skylight: bool,
|
||||
biome_map: Vec<u16>,
|
||||
biome_bits: u8,
|
||||
}
|
||||
|
||||
impl ChunkFormat {
|
||||
pub(super) fn new(dimension: &DimensionType, biome_map: Vec<u16>, biome_bits: u8) -> Self {
|
||||
Self {
|
||||
min_y: dimension.min_y,
|
||||
sections: dimension.sections(),
|
||||
has_skylight: dimension.has_skylight,
|
||||
biome_map,
|
||||
biome_bits,
|
||||
}
|
||||
}
|
||||
pub(super) fn with_dimension(&self, dimension: &DimensionType) -> Self {
|
||||
Self::new(dimension, self.biome_map.clone(), self.biome_bits)
|
||||
}
|
||||
|
||||
/// LevelChunkSection data into the client's fixed -64..320 column.
|
||||
fn column(&self, p: &play::LevelChunkWithLight) -> Result<Chunk, String> {
|
||||
let sections = p
|
||||
.chunk
|
||||
.sections(self.sections, 16, self.biome_bits)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut chunk = Chunk::empty(p.x, p.z);
|
||||
let offset = (self.min_y - MIN_Y).div_euclid(16);
|
||||
// Biome table index per quart cell of the dimension's own sections.
|
||||
let mut biome_cells: Vec<[u32; 64]> = Vec::with_capacity(sections.len());
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
let target = offset + i as i32;
|
||||
let mut cells = [0u32; 64];
|
||||
for (j, cell) in cells.iter_mut().enumerate() {
|
||||
*cell = u32::from(
|
||||
self.biome_map
|
||||
.get(s.biomes.get(j) as usize)
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
);
|
||||
}
|
||||
biome_cells.push(cells);
|
||||
if !(0..SECTIONS as i32).contains(&target) {
|
||||
continue;
|
||||
}
|
||||
// A global section is as wide as the server's registry: 16 bits
|
||||
// from a vanilla server, wider from a Horizon host with the same
|
||||
// content mods. Read it at that width, then widen it to ours.
|
||||
let ours = block_strategy();
|
||||
let strategy = if ours.wire_has_palette(s.states.bits) {
|
||||
ours
|
||||
} else {
|
||||
Strategy::blocks(s.states.bits)
|
||||
};
|
||||
let palette = s.states.palette.iter().map(|&p| p as u32).collect();
|
||||
let words = s.states.data.iter().map(|&w| w as u64).collect();
|
||||
let mut blocks = PalettedContainer::from_wire(strategy, s.states.bits, palette, words)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if strategy.global_bits != ours.global_bits {
|
||||
let mut values = [0u32; 4096];
|
||||
blocks.unpack(&mut values);
|
||||
// Ids past our registry (a host with mods we lack) read as air.
|
||||
let limit = 1u64 << ours.global_bits;
|
||||
for v in &mut values {
|
||||
if u64::from(*v) >= limit {
|
||||
*v = 0;
|
||||
}
|
||||
}
|
||||
blocks = PalettedContainer::from_values(ours, &values);
|
||||
}
|
||||
let mut section = Section {
|
||||
blocks,
|
||||
biomes: PalettedContainer::from_values(BIOME_STRATEGY, &cells),
|
||||
light: Light::Uniform(0xF0),
|
||||
};
|
||||
section.compact();
|
||||
chunk.sections[target as usize] = Arc::new(section);
|
||||
}
|
||||
// Sections outside the dimension's range take its nearest biomes
|
||||
// (the column's top or bottom cell layer repeated).
|
||||
if !biome_cells.is_empty() {
|
||||
let last = biome_cells.len() - 1;
|
||||
for si in 0..SECTIONS as i32 {
|
||||
let i = si - offset;
|
||||
if (0..sections.len() as i32).contains(&i) {
|
||||
continue;
|
||||
}
|
||||
let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) };
|
||||
let mut cells = [0u32; 64];
|
||||
for (j, cell) in cells.iter_mut().enumerate() {
|
||||
*cell = biome_cells[edge][layer * 16 + j % 16];
|
||||
}
|
||||
Arc::make_mut(&mut chunk.sections[si as usize]).biomes =
|
||||
PalettedContainer::from_values(BIOME_STRATEGY, &cells);
|
||||
}
|
||||
}
|
||||
// ClientPacketListener.applyLightData: the column keeps the light
|
||||
// the server sent; nothing is relit.
|
||||
let (layers, above) = self.light(&p.light);
|
||||
horizon_client::light::light_from_layers(&mut chunk, &layers, above, self.has_skylight);
|
||||
Ok(chunk)
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.readSectionList`: the sky and block layers of a
|
||||
/// light packet, by section of the client's column, and the sky light
|
||||
/// above the column's top section (from the light section just above
|
||||
/// it, else open sky).
|
||||
pub(super) fn light(&self, light: &LightData) -> (Vec<SectionLight>, [u8; 256]) {
|
||||
let mut layers: Vec<SectionLight> = vec![(None, None); SECTIONS];
|
||||
let sky = self.has_skylight;
|
||||
let mut above = [if sky { 15 } else { 0 }; 256];
|
||||
// Light section b is the dimension's section b - 1.
|
||||
let first = (self.min_y - MIN_Y).div_euclid(16) - 1;
|
||||
for (b, layer) in light.layers(self.sections + 2).into_iter().enumerate() {
|
||||
let si = first + b as i32;
|
||||
if (0..SECTIONS as i32).contains(&si) {
|
||||
layers[si as usize] = layer;
|
||||
} else if si == SECTIONS as i32
|
||||
&& sky
|
||||
&& let Some(l) = &layer.0
|
||||
{
|
||||
for (i, a) in above.iter_mut().enumerate() {
|
||||
*a = (l[i >> 1] >> ((i & 1) * 4)) & 15;
|
||||
}
|
||||
}
|
||||
}
|
||||
(layers, above)
|
||||
}
|
||||
}
|
||||
|
||||
type Decoded = (Box<C>, Result<Chunk, String>);
|
||||
|
||||
/// One thread that decodes chunk packets in the order they arrived; its
|
||||
/// results come back in that order, one per `submit`.
|
||||
pub(super) struct ChunkDecoder {
|
||||
jobs: mpsc::Sender<(Arc<ChunkFormat>, Box<C>)>,
|
||||
done: mpsc::Receiver<Decoded>,
|
||||
}
|
||||
|
||||
impl ChunkDecoder {
|
||||
pub(super) fn new() -> Self {
|
||||
let (jobs, todo) = mpsc::channel::<(Arc<ChunkFormat>, Box<C>)>();
|
||||
let (finished, done) = mpsc::channel();
|
||||
// The thread ends with the connection, when `jobs` is dropped.
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Chunk decoder".into())
|
||||
.spawn(move || {
|
||||
// A frame waits for its columns, as it would for the render
|
||||
// thread's own work.
|
||||
horizon_threads::favour_current();
|
||||
for (format, packet) in todo {
|
||||
let column = match &*packet {
|
||||
C::LevelChunkWithLight(p) => format.column(p),
|
||||
_ => Err("not a chunk packet".into()),
|
||||
};
|
||||
if finished.send((packet, column)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("Horizon: no chunk decoder thread ({e}); chunks decode in the frame");
|
||||
}
|
||||
Self { jobs, done }
|
||||
}
|
||||
|
||||
/// Start decoding a chunk packet with the format it arrived under.
|
||||
/// Without a decoder thread the packet comes back undecoded.
|
||||
pub(super) fn submit(&self, format: Arc<ChunkFormat>, packet: Box<C>) -> Option<Box<C>> {
|
||||
self.jobs.send((format, packet)).err().map(|e| (e.0).1)
|
||||
}
|
||||
|
||||
/// The oldest submitted packet and its column, unless it is still
|
||||
/// decoding: a frame never waits for the decoder.
|
||||
pub(super) fn next(&self) -> Next {
|
||||
match self.done.try_recv() {
|
||||
Ok(decoded) => Next::Ready(decoded),
|
||||
Err(TryRecvError::Empty) => Next::Decoding,
|
||||
// The decoder thread panicked: its columns are gone.
|
||||
Err(TryRecvError::Disconnected) => Next::Lost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum Next {
|
||||
Ready(Decoded),
|
||||
Decoding,
|
||||
Lost,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `handleLevelChunkWithLight`: the column replaces any loaded one, and
|
||||
/// its block entities load their update tags. `decoded` is the column the
|
||||
/// decoder made with `format`; another format since means decode again.
|
||||
pub(super) fn remote_chunk(
|
||||
&mut self,
|
||||
p: &play::LevelChunkWithLight,
|
||||
decoded: Option<(&Arc<ChunkFormat>, Result<Chunk, String>)>,
|
||||
) {
|
||||
// ClientChunkCache.replaceWithPacketData: a column outside the
|
||||
// storage around the view centre is ignored.
|
||||
if !self.remote_chunk_in_range(p.x, p.z) {
|
||||
log::warn!(
|
||||
"Ignoring chunk since it's not in the view range: {}, {}",
|
||||
p.x,
|
||||
p.z
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else {
|
||||
return;
|
||||
};
|
||||
let column = match decoded {
|
||||
Some((used, column)) if Arc::ptr_eq(used, &format) => column,
|
||||
_ => format.column(p),
|
||||
};
|
||||
let chunk = match column {
|
||||
Ok(chunk) => chunk,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: chunk {} {}: {e}", p.x, p.z);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (x, z) = (p.x, p.z);
|
||||
self.streaming.insert_remote(&mut self.world, chunk);
|
||||
self.world
|
||||
.block_entities
|
||||
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
|
||||
for info in &p.chunk.block_entities {
|
||||
let pos = (
|
||||
x * 16 + i32::from(info.packed_xz as u8 >> 4),
|
||||
i32::from(info.y),
|
||||
z * 16 + i32::from(info.packed_xz as u8 & 15),
|
||||
);
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
self.sync_block_entity(pos, state);
|
||||
// LevelChunk.replaceWithPacketData: the block entity loads its update tag.
|
||||
self.remote_block_entity_data([pos.0, pos.1, pos.2], info.kind, &info.tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleLightUpdatePacket`: the sections it names take its light.
|
||||
pub(super) fn remote_light_update(&mut self, p: &play::LightUpdate) {
|
||||
let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else {
|
||||
return;
|
||||
};
|
||||
if !self.world.chunks.contains_key(&(p.x, p.z)) {
|
||||
return;
|
||||
}
|
||||
let (layers, _) = format.light(&p.light);
|
||||
let Some(chunk) = self.world.chunks.get_mut(&(p.x, p.z)) else {
|
||||
return;
|
||||
};
|
||||
let changed = horizon_client::light::update_light_layers(
|
||||
Arc::make_mut(chunk),
|
||||
&layers,
|
||||
format.has_skylight,
|
||||
);
|
||||
Streaming::light_updated(&mut self.world, p.x, p.z, &changed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::chunk::{ChunkData, Paletted, Section as Wire, Strategy as Wiring};
|
||||
|
||||
/// A column of `state` with biome 1 everywhere, unlit.
|
||||
fn packet(x: i32, state: i32) -> Box<C> {
|
||||
let section = Wire {
|
||||
non_empty: 4096,
|
||||
fluids: 0,
|
||||
states: Paletted::from_values(&[state; 4096], Wiring::Blocks, 16),
|
||||
biomes: Paletted::from_values(&[1; 64], Wiring::Biomes, 2),
|
||||
};
|
||||
let none = LightData {
|
||||
sky_mask: Vec::new(),
|
||||
block_mask: Vec::new(),
|
||||
empty_sky_mask: Vec::new(),
|
||||
empty_block_mask: Vec::new(),
|
||||
sky: Vec::new(),
|
||||
block: Vec::new(),
|
||||
};
|
||||
Box::new(C::LevelChunkWithLight(play::LevelChunkWithLight {
|
||||
x,
|
||||
z: -3,
|
||||
chunk: ChunkData {
|
||||
heightmaps: Vec::new(),
|
||||
buffer: ChunkData::from_sections(&vec![section; 24]),
|
||||
block_entities: Vec::new(),
|
||||
},
|
||||
light: none,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Columns come back one per packet, in the order the packets went in,
|
||||
/// as the frame would have decoded them itself.
|
||||
#[test]
|
||||
fn the_decoder_returns_columns_in_order() {
|
||||
let overworld = DimensionType::vanilla("minecraft:overworld");
|
||||
let format = Arc::new(ChunkFormat::new(&overworld, vec![0, 7, 9], 2));
|
||||
let decoder = ChunkDecoder::new();
|
||||
for x in 0..20 {
|
||||
assert!(decoder.submit(format.clone(), packet(x, 1 + x)).is_none());
|
||||
}
|
||||
for x in 0..20 {
|
||||
let (packet, column) = loop {
|
||||
match decoder.next() {
|
||||
Next::Ready(decoded) => break decoded,
|
||||
Next::Decoding => std::thread::yield_now(),
|
||||
Next::Lost => panic!("the decoder stopped"),
|
||||
}
|
||||
};
|
||||
let C::LevelChunkWithLight(p) = *packet else {
|
||||
panic!("not a chunk packet");
|
||||
};
|
||||
let (column, direct) = (column.unwrap(), format.column(&p).unwrap());
|
||||
assert_eq!((column.x, column.z), (x, -3));
|
||||
for (cx, y, cz) in [(0, -64, 0), (5, 70, 9), (15, 319, 15)] {
|
||||
assert_eq!(column.get(cx, y, cz), 1 + x as u32);
|
||||
assert_eq!(column.get(cx, y, cz), direct.get(cx, y, cz));
|
||||
// Wire biome 1 is our biome 7.
|
||||
assert_eq!(column.biome(cx, y, cz), 7);
|
||||
}
|
||||
}
|
||||
assert!(matches!(decoder.next(), Next::Decoding));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -324,7 +353,8 @@ impl Game<'_> {
|
||||
out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position)}));
|
||||
}
|
||||
for p in &self.creatures.projectiles {
|
||||
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position)}));
|
||||
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position), "velocity": at(p.velocity),
|
||||
"player_owned": p.owner == Some(horizon_client::mobs::Who::Player)}));
|
||||
}
|
||||
for (kind, p, _) in self.potions.thrown_entities() {
|
||||
out.push(json!({"kind": kind, "position": at(p)}));
|
||||
|
||||
@@ -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,34 +1,69 @@
|
||||
//! 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
|
||||
//! programs of the current dimension's folder are loaded when the dimension changes (spec 1.2).
|
||||
//! Shader packs: the one picked on Options > Horizon Settings > Shader Packs, or
|
||||
//! `HORIZON_SHADER_PACK=<name>` (development; it wins over the pick), 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
|
||||
/// inputs to the pack's uniforms.
|
||||
pub(super) fn shader_pack_inputs(&mut self, camera: glam::DVec3, partial: f32) -> PackInputs {
|
||||
let Some(name) = std::env::var_os("HORIZON_SHADER_PACK") else {
|
||||
static DEV_PACK: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
let dev = DEV_PACK.get_or_init(|| std::env::var("HORIZON_SHADER_PACK").ok());
|
||||
let picked = dev.as_deref().unwrap_or(&self.settings.horizon.shader_pack);
|
||||
if self.native_shaders.picked != picked {
|
||||
// Another pack, or none: drop what ran.
|
||||
self.native_shaders = super::native_shaders::NativeShaders::picking(picked);
|
||||
self.pack_ids = None;
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.drop_shader_packs();
|
||||
}
|
||||
}
|
||||
if picked.is_empty() {
|
||||
return PackInputs::default();
|
||||
};
|
||||
}
|
||||
let name = picked.to_owned();
|
||||
let dimension = match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
};
|
||||
if let Some(renderer) = &mut self.renderer
|
||||
let path = Self::shader_packs_dir().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 name = name.as_str();
|
||||
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 +77,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 +133,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 +163,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);
|
||||
}
|
||||
@@ -368,6 +366,16 @@ impl Loading {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::ListShaderPacks => {
|
||||
self.settings.shader_packs =
|
||||
horizon_client::shader_pack_list::scan(&Game::shader_packs_dir());
|
||||
}
|
||||
Event::ShaderPack => self.save_menu_settings(),
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = Game::shader_packs_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::Credits => {
|
||||
if let Some(gui) = &self.gui {
|
||||
let name = self.options.as_ref().map_or_else(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user