mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
234
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3acb5f0d2 | ||
|
|
49b77529dd | ||
|
|
dafa794cb3 | ||
|
|
b7df7bd9e0 | ||
|
|
a1e56a4d1b | ||
|
|
ab32a64d08 | ||
|
|
f843f28a27 | ||
|
|
abcc2e7c80 | ||
|
|
ac78481794 | ||
|
|
650601848f | ||
|
|
f9ea299952 | ||
|
|
b6eb028bb6 | ||
|
|
9dffa8a2a9 | ||
|
|
cd89bc6dc1 | ||
|
|
8a863ad378 | ||
|
|
a27561da88 | ||
|
|
ed7213df2e | ||
|
|
b1b27240d4 | ||
|
|
8a2a31e6a3 | ||
|
|
0264b39aa6 | ||
|
|
83935015cf | ||
|
|
506a9c8f01 | ||
|
|
475b22ff19 | ||
|
|
0d1f56e015 | ||
|
|
958c02593c | ||
|
|
a266eee6d2 | ||
|
|
62898b4cf6 | ||
|
|
d08e05283a | ||
|
|
e7a07f88b3 | ||
|
|
4e59dab892 | ||
|
|
a1f4841526 | ||
|
|
7dd4b772c9 | ||
|
|
03dd78d67b | ||
|
|
6267cd3e15 | ||
|
|
265640ac18 | ||
|
|
f5a3088f0d | ||
|
|
3ccbba76df | ||
|
|
c519ff6c56 | ||
|
|
400d15ab3f | ||
|
|
d6971605d4 | ||
|
|
7dc818eb5b | ||
|
|
2772738d47 | ||
|
|
131625c414 | ||
|
|
478ae35b53 | ||
|
|
5f868011ee | ||
|
|
67712eeeef | ||
|
|
5b7d19f02b | ||
|
|
f66f66c3b4 | ||
|
|
7cbb8c4506 | ||
|
|
6a05885ee3 | ||
|
|
c979ce46e9 | ||
|
|
0c5c38ac9e | ||
|
|
ae3b9e273f | ||
|
|
d0b6e48921 | ||
|
|
07e4a86658 | ||
|
|
3c98062662 | ||
|
|
e1b2b0b2fd | ||
|
|
9755416a02 | ||
|
|
0281365907 | ||
|
|
a5fcb49900 | ||
|
|
6f6229c2d4 | ||
|
|
0ca3a5be98 | ||
|
|
8993772de0 | ||
|
|
6a261a00a5 | ||
|
|
f8b83eea51 | ||
|
|
88398e8bda | ||
|
|
6d3489d30b | ||
|
|
7a84c93843 | ||
|
|
cba52f4e5d | ||
|
|
4d477067c3 | ||
|
|
9f1fa0a0a2 | ||
|
|
fe84cc07a1 | ||
|
|
d4e42cf726 | ||
|
|
998c94bee3 | ||
|
|
466512c90e | ||
|
|
4db071e281 | ||
|
|
2649c5e506 | ||
|
|
505de825db | ||
|
|
1f6511d0f3 | ||
|
|
1523c118d8 | ||
|
|
9239089b76 | ||
|
|
281ab9759c | ||
|
|
febad05c78 | ||
|
|
519edfac19 | ||
|
|
6561df77f6 | ||
|
|
cd7f177104 | ||
|
|
07ab7ccfc1 | ||
|
|
727f6c932a | ||
|
|
cd1f58ef72 | ||
|
|
291aae00e8 | ||
|
|
bec8cce82d | ||
|
|
0ee2425d40 | ||
|
|
7f3bd901bc | ||
|
|
d308df6167 | ||
|
|
5ded675a7d | ||
|
|
564162a0b0 | ||
|
|
8b5a27ed3f | ||
|
|
c0505424bd | ||
|
|
137045e321 | ||
|
|
4a18a4bde4 | ||
|
|
f6b467eba9 | ||
|
|
23d2146066 | ||
|
|
5f8afb17fc | ||
|
|
f7bb1ceb79 | ||
|
|
b9494af976 | ||
|
|
d1ead6a5fa | ||
|
|
3511d556ef | ||
|
|
4ff7118db7 | ||
|
|
626068da3a | ||
|
|
4bf633e8a9 | ||
|
|
f4d6f9c0ed | ||
|
|
93e61ef936 | ||
|
|
28e3286e40 | ||
|
|
aef4186de4 | ||
|
|
c66554126d | ||
|
|
4a1c5fc54c | ||
|
|
a1b32304a3 | ||
|
|
7934a0b180 | ||
|
|
992c63eefb | ||
|
|
a967ca4340 | ||
|
|
c0b560527f | ||
|
|
557a827383 | ||
|
|
e06134f71a | ||
|
|
330314327a | ||
|
|
012a3cf908 | ||
|
|
0e03cac368 | ||
|
|
a48fcf0d16 | ||
|
|
2eb3de2748 | ||
|
|
67511d9749 | ||
|
|
df90f62d35 | ||
|
|
efa8df83b3 | ||
|
|
58422bcc19 | ||
|
|
5c14d18141 | ||
|
|
0f8e3a9b09 | ||
|
|
4ce1eb6b0a | ||
|
|
47d249e7dd | ||
|
|
ea6ebae264 | ||
|
|
d878f3bed2 | ||
|
|
ec5fc9d5e4 | ||
|
|
99948fdf3f | ||
|
|
73ad5f1dbb | ||
|
|
6e26990ad3 | ||
|
|
ffb6594600 | ||
|
|
4b4950d7c4 | ||
|
|
1d693cb3c3 | ||
|
|
23e3cf465c | ||
|
|
0d3ca74bd0 | ||
|
|
f69088012b | ||
|
|
154aaa2051 | ||
|
|
1ab273641e | ||
|
|
785d579f47 | ||
|
|
3fe8aa76b2 | ||
|
|
dc75e77eb1 | ||
|
|
94e8c85fd0 | ||
|
|
28c30923f4 | ||
|
|
2b74cfae4a | ||
|
|
60a4edaef9 | ||
|
|
5096b41100 | ||
|
|
e9eaa66542 | ||
|
|
2c2aa41619 | ||
|
|
f164427cba | ||
|
|
b77301b6eb | ||
|
|
e08adcd31c | ||
|
|
830a9d3d84 | ||
|
|
6f0d56512d | ||
|
|
482444afc4 | ||
|
|
0d7f842b09 | ||
|
|
a1dfcbf2b3 | ||
|
|
5703fdfdc4 | ||
|
|
57494e6871 | ||
|
|
ad8ef0b7e8 | ||
|
|
f9fca5688d | ||
|
|
4156a6ac93 | ||
|
|
6c3defa821 | ||
|
|
8057d27511 | ||
|
|
4e814a152a | ||
|
|
3f378dda96 | ||
|
|
70068076f2 | ||
|
|
59b67af6a8 | ||
|
|
1b469a542f | ||
|
|
567e92e2a2 | ||
|
|
5388064d88 | ||
|
|
20ffd8e538 | ||
|
|
c3e19973cf | ||
|
|
7aaa4b433b | ||
|
|
3cf601f760 | ||
|
|
39327fca33 | ||
|
|
6489cdf829 | ||
|
|
1727f2acf1 | ||
|
|
079963f27e | ||
|
|
3c72bf2d79 | ||
|
|
16cda1637b | ||
|
|
513aed64c5 | ||
|
|
7ab2b20bd5 | ||
|
|
dddcc99726 | ||
|
|
b8f0cde900 | ||
|
|
c4348be8d2 | ||
|
|
a34a41e1b5 | ||
|
|
22cab3eeb3 | ||
|
|
9b6faeb8cf | ||
|
|
8cfc13fe2c | ||
|
|
a7e3bcf79f | ||
|
|
e7f5201c1f | ||
|
|
f507a067cd | ||
|
|
28f906c37d | ||
|
|
f8f2f904bc | ||
|
|
ce848af067 | ||
|
|
d3be8dbf05 | ||
|
|
152334eee3 | ||
|
|
67e0d54062 | ||
|
|
53cb3a19d1 | ||
|
|
84390f6dc1 | ||
|
|
9d191c28f2 | ||
|
|
5d42108a98 | ||
|
|
1972ef3bef | ||
|
|
e8145b07fa | ||
|
|
5e7295a631 | ||
|
|
761a770174 | ||
|
|
7f0a61954b | ||
|
|
a8903cba07 | ||
|
|
00df6c646f | ||
|
|
1f040d06eb | ||
|
|
d13b478ca2 | ||
|
|
91de744250 | ||
|
|
1654cb51db | ||
|
|
f105583803 | ||
|
|
fa940d5b6b | ||
|
|
bcdbc252fe | ||
|
|
186198afe6 | ||
|
|
a77639022c | ||
|
|
fcc7a818b4 | ||
|
|
a3e581b56f | ||
|
|
9ebccd6a59 | ||
|
|
0f565f4e4c |
No files matched your search
@@ -25,6 +25,9 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
|
||||
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
|
||||
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
|
||||
# the tag. Like sign, it never executes what build produced.
|
||||
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
|
||||
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
|
||||
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
|
||||
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
|
||||
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
|
||||
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
|
||||
@@ -76,6 +79,7 @@ jobs:
|
||||
date: ${{ steps.pick.outputs.date }}
|
||||
version: ${{ steps.pick.outputs.version }}
|
||||
nightly: ${{ steps.pick.outputs.nightly }}
|
||||
source_tag: ${{ steps.pick.outputs.source_tag }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -103,6 +107,18 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: The source of the build (horizon-source.tar.gz)
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
env:
|
||||
SHA: ${{ steps.pick.outputs.sha }}
|
||||
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
with:
|
||||
name: horizon-source
|
||||
path: horizon-source.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-notes
|
||||
@@ -144,7 +160,7 @@ jobs:
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
@@ -187,6 +203,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SHA: ${{ needs.pick.outputs.sha }}
|
||||
DATE: ${{ needs.pick.outputs.date }}
|
||||
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
@@ -198,7 +215,9 @@ jobs:
|
||||
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
|
||||
python3 tools/ci/nightly.py prune --days 7
|
||||
- name: Dry run, the nightly that would be published
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
|
||||
Generated
+37
@@ -876,6 +876,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
@@ -1398,6 +1404,7 @@ dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
"rustix 1.1.5",
|
||||
@@ -1877,6 +1884,25 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -2691,6 +2717,17 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
|
||||
+4
-2
@@ -61,8 +61,10 @@ large_enum_variant = "allow"
|
||||
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
|
||||
# pool with no pool sizes while it creates the device, which Vulkan allows only
|
||||
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
|
||||
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
|
||||
# folder once a wgpu release has the fix.
|
||||
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
|
||||
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
|
||||
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
|
||||
# Drop this and the folder once a wgpu release has both fixes.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal" }
|
||||
|
||||
|
||||
@@ -97,6 +97,9 @@ impl Report {
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::NativeMod {
|
||||
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
|
||||
}
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
@@ -118,6 +121,7 @@ impl Report {
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hnmod" => Some(Kind::NativeMod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
"hshader" => Some(Kind::Shader),
|
||||
|
||||
@@ -195,6 +195,11 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
}
|
||||
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
|
||||
// the dialog itself; the server may send it (isAllowedFromServer).
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ pub enum ClickEvent {
|
||||
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
|
||||
RunCommand(String),
|
||||
CopyToClipboard(String),
|
||||
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
|
||||
/// dialog itself as the component carried it.
|
||||
ShowDialog(horizon_world::nbt::Tag),
|
||||
/// A local file to open with the system handler.
|
||||
/// Only allow this in locally constructed messages, never server/LAN text.
|
||||
OpenFile(String),
|
||||
@@ -557,6 +560,11 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
ClickEvent::RunCommand(command) => {
|
||||
json!({ "action": "run_command", "command": command })
|
||||
}
|
||||
// ClickEvent.ShowDialog.CODEC: the dialog the server sent it with.
|
||||
ClickEvent::ShowDialog(dialog) => json!({
|
||||
"action": "show_dialog",
|
||||
"dialog": crate::scoreboard::tag_to_json(dialog),
|
||||
}),
|
||||
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
|
||||
};
|
||||
out.push(("click_event".to_owned(), event));
|
||||
|
||||
@@ -324,6 +324,24 @@ pub fn ease_in_out(t: f64) -> f64 {
|
||||
0.5 - 0.5 * (std::f64::consts::PI * t).cos()
|
||||
}
|
||||
|
||||
/// `MouseHandler.grabMouse` / `releaseMouse`: the mouse position is the
|
||||
/// window's centre whenever the pointer's grab state changes, so a screen
|
||||
/// opened from gameplay starts with the cursor in the middle. A release of
|
||||
/// an already free pointer (or a grab of an already grabbed one) leaves the
|
||||
/// position where it is, as vanilla does.
|
||||
pub fn mouse_position_after_grab_change(
|
||||
grab: bool,
|
||||
was_grabbed: bool,
|
||||
size: [u32; 2],
|
||||
scale: f32,
|
||||
) -> Option<(f32, f32)> {
|
||||
if grab == was_grabbed {
|
||||
None
|
||||
} else {
|
||||
Some((size[0] as f32 / 2.0 / scale, size[1] as f32 / 2.0 / scale))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -485,4 +503,27 @@ mod tests {
|
||||
assert_eq!(look.on_scroll(0.0, 0.4, false, 1.0), None);
|
||||
assert_eq!(look.on_scroll(0.0, 0.7, false, 1.0), Some((1, (0, 1))));
|
||||
}
|
||||
#[test]
|
||||
fn the_mouse_position_is_the_centre_when_the_grab_changes() {
|
||||
// A grab of a free pointer and a release of a grabbed one both put
|
||||
// the position at the window's centre (`MouseHandler.grabMouse`,
|
||||
// `MouseHandler.releaseMouse`).
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(true, false, [1920, 1080], 1.0),
|
||||
Some((960.0, 540.0))
|
||||
);
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(false, true, [1920, 1080], 2.0),
|
||||
Some((480.0, 270.0))
|
||||
);
|
||||
// Without a change of the grab state the position stays where it is.
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(false, false, [1920, 1080], 1.0),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(true, true, [1920, 1080], 1.0),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ unicode-bidi = "0.3.18"
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
|
||||
@@ -161,6 +161,7 @@ pub fn main() -> Result<()> {
|
||||
"sound {} {:08x} {:08x}",
|
||||
match sound {
|
||||
ArrowSound::HitGround => "hit",
|
||||
ArrowSound::CrossbowHit => "crossbow_hit",
|
||||
ArrowSound::Splash => "splash",
|
||||
ArrowSound::Extinguish => "extinguish",
|
||||
ArrowSound::TridentHitGround => "trident_hit_ground",
|
||||
|
||||
@@ -369,6 +369,7 @@ pub fn main() -> Result<()> {
|
||||
banner: &|_| layers.clone(),
|
||||
skull: &|_| None,
|
||||
glyph: &|c| atlas.glyph_cell(c),
|
||||
glyphs: atlas.font.epoch(),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
|
||||
|
||||
@@ -87,7 +87,11 @@ pub fn main() {
|
||||
}
|
||||
"tick" => {
|
||||
out.push_str("tick");
|
||||
for (p, _) in entities.tickers(|p| states.get(&p).copied().unwrap_or(0)) {
|
||||
for (p, _) in entities
|
||||
.tickers(|p| states.get(&p).copied().unwrap_or(0))
|
||||
.iter()
|
||||
.copied()
|
||||
{
|
||||
out.push_str(&format!(" {},{},{}", p.0, p.1, p.2));
|
||||
}
|
||||
out.push('\n');
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use horizon_client::{
|
||||
containers::{Furnace, FurnaceKind},
|
||||
crafting::Recipes,
|
||||
furnace::FurnaceCooking,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::Items,
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
|
||||
@@ -1121,6 +1121,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
mob_drops: true,
|
||||
max_entity_cramming: 24,
|
||||
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
|
||||
@@ -444,6 +444,8 @@ pub struct EntityView {
|
||||
pub gamemode: Option<String>,
|
||||
/// Lightning: entities it struck.
|
||||
pub struck: Vec<EntityView>,
|
||||
/// Lightning: blocks it set on fire (`LightningBolt.blocksSetOnFire`).
|
||||
pub blocks_set_on_fire: i32,
|
||||
pub fishing_open_water: Option<bool>,
|
||||
pub variant: Option<String>,
|
||||
/// The data components the entity reports (`Entity.get`): id and value.
|
||||
@@ -538,6 +540,11 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(d) = comps["minecraft:damage"].as_u64()
|
||||
&& u64::from(it.damage) != d
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let preds = &v["predicates"];
|
||||
if let Some(list) = preds["minecraft:enchantments"].as_array() {
|
||||
for e in list {
|
||||
@@ -734,7 +741,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
|
||||
}
|
||||
"minecraft:type_specific/lightning" => {
|
||||
range_i(&p["blocks_set_on_fire"], 0)
|
||||
range_i(&p["blocks_set_on_fire"], i64::from(e.blocks_set_on_fire))
|
||||
&& (p["entity_struck"].is_null()
|
||||
|| e.struck
|
||||
.iter()
|
||||
@@ -818,7 +825,13 @@ pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let ty = &v["type"];
|
||||
damage_source(&v["type"], d, w, origin)
|
||||
}
|
||||
/// `DamageSourcePredicate`: the damage type's tags and the entities behind it.
|
||||
pub fn damage_source(ty: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if ty.is_null() {
|
||||
return true;
|
||||
}
|
||||
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
|
||||
let tag = t["id"].as_str().unwrap_or("");
|
||||
let tag = tag.strip_prefix('#').unwrap_or(tag);
|
||||
@@ -1326,8 +1339,47 @@ mod tests {
|
||||
fn in_tag(&self, _: &str, tag: &str, id: &str) -> bool {
|
||||
(tag == "minecraft:stone_tool_materials" && id == "minecraft:cobblestone")
|
||||
|| (tag == "minecraft:sulfur_cube_archetype/explosive" && id == "minecraft:tnt")
|
||||
|| (tag == "minecraft:is_projectile"
|
||||
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
|
||||
}
|
||||
}
|
||||
/// Return to Sender's `killing_blow`: only the ghast's own fireball, as a
|
||||
/// projectile, kills it for the advancement; a sword or an arrow does not.
|
||||
#[test]
|
||||
fn killing_blow_needs_the_fireball() {
|
||||
let blow = serde_json::json!({
|
||||
"direct_entity": {"minecraft:entity_type": "minecraft:fireball"},
|
||||
"tags": [{"expected": true, "id": "#minecraft:is_projectile"}]
|
||||
});
|
||||
let entity = |kind: &str| EntityView {
|
||||
kind: kind.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let player = entity("minecraft:player");
|
||||
let fireball = DamageView {
|
||||
kind: "minecraft:fireball".into(),
|
||||
direct: Some(entity("minecraft:fireball")),
|
||||
source: Some(player.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(damage_source(&blow, &fireball, &Flat, [0.0; 3]));
|
||||
let sword = DamageView {
|
||||
kind: "minecraft:player_attack".into(),
|
||||
direct: Some(player.clone()),
|
||||
source: Some(player.clone()),
|
||||
is_direct: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!damage_source(&blow, &sword, &Flat, [0.0; 3]));
|
||||
let arrow = DamageView {
|
||||
kind: "minecraft:arrow".into(),
|
||||
direct: Some(entity("minecraft:arrow")),
|
||||
source: Some(player),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!damage_source(&blow, &arrow, &Flat, [0.0; 3]));
|
||||
assert!(damage_source(&Value::Null, &sword, &Flat, [0.0; 3]));
|
||||
}
|
||||
#[test]
|
||||
fn uh_oh_accepts_either_tnt_event_only_for_adult_sulfur_cubes() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(
|
||||
|
||||
@@ -130,6 +130,8 @@ pub enum ArrowSound {
|
||||
Splash,
|
||||
/// The arrow's `soundEvent` (`entity.arrow.hit`), for blocks and entities.
|
||||
HitGround,
|
||||
/// An arrow shot from a crossbow: `item.crossbow.hit`.
|
||||
CrossbowHit,
|
||||
/// `entity.generic.extinguish_fire`.
|
||||
Extinguish,
|
||||
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
|
||||
@@ -782,8 +784,16 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
|
||||
fn hit_sound(&mut self) {
|
||||
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
|
||||
// CrossbowItem.createProjectile: its arrows hit with the crossbow's sound.
|
||||
let crossbow = self
|
||||
.st
|
||||
.fired_from_weapon
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:crossbow");
|
||||
let sound = if self.trident() {
|
||||
ArrowSound::TridentHitGround
|
||||
} else if crossbow {
|
||||
ArrowSound::CrossbowHit
|
||||
} else {
|
||||
ArrowSound::HitGround
|
||||
};
|
||||
|
||||
@@ -20,12 +20,68 @@ pub struct SpawnEntry {
|
||||
/// A `ConstantInt` count (sampling draws nothing).
|
||||
pub constant: bool,
|
||||
}
|
||||
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
|
||||
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
|
||||
list.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let count = &e["count"];
|
||||
let (min, max, constant) = match count {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c if c["type"].as_str() == Some("minecraft:constant") => {
|
||||
let n = c["value"].as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c => (
|
||||
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
false,
|
||||
),
|
||||
};
|
||||
Some(SpawnEntry {
|
||||
entity: e["type"].as_str()?.to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min,
|
||||
max,
|
||||
constant,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `MobSpawnSettings.MobSpawnCost`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SpawnCost {
|
||||
pub energy_budget: f64,
|
||||
pub charge: f64,
|
||||
}
|
||||
/// The `spawn_costs` of a `natural_mob_spawns` value.
|
||||
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
|
||||
costs
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, c)| {
|
||||
(
|
||||
k.clone(),
|
||||
SpawnCost {
|
||||
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
|
||||
charge: c["charge"].as_f64().unwrap_or(0.0),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BiomeInfo {
|
||||
/// `natural_mob_spawns` by category (creature, monster, ...).
|
||||
pub spawns: HashMap<String, Vec<SpawnEntry>>,
|
||||
/// `natural_mob_spawns` spawn costs by entity type.
|
||||
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
|
||||
pub spawn_costs: HashMap<String, SpawnCost>,
|
||||
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
|
||||
pub creature_probability: f32,
|
||||
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
|
||||
@@ -150,12 +206,12 @@ impl Biomes {
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = crate::mobs::spawn_selection::parse_entries(entries);
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
use crate::chunk::block_entity::BlockEntity;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub type Pos = (i32, i32, i32);
|
||||
|
||||
@@ -46,7 +47,7 @@ pub enum Ticking {
|
||||
pub struct BlockEntities {
|
||||
map: HashMap<Pos, BlockEntity>,
|
||||
/// `Level.blockEntityTickers`: positions and the slot each was given.
|
||||
tickers: Vec<(Pos, u32)>,
|
||||
tickers: Arc<Vec<(Pos, u32)>>,
|
||||
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
|
||||
live: FxHashMap<Pos, u32>,
|
||||
/// Positions that may need a slot, in the order vanilla would have made
|
||||
@@ -233,7 +234,7 @@ impl BlockEntities {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.tickers.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
self.pending.clear();
|
||||
@@ -274,7 +275,7 @@ impl BlockEntities {
|
||||
/// and freed ones leave it. `state` reads the block state at a position.
|
||||
/// Returns the slots to tick, in order; check each with `is_live` before
|
||||
/// ticking it, as the ticks before it can free it.
|
||||
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Vec<(Pos, u32)> {
|
||||
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Arc<Vec<(Pos, u32)>> {
|
||||
for (pos, token) in std::mem::take(&mut self.added) {
|
||||
if self.pending.get(&pos) != Some(&token) {
|
||||
continue;
|
||||
@@ -288,21 +289,21 @@ impl BlockEntities {
|
||||
}
|
||||
let slot = self.next();
|
||||
self.live.insert(pos, slot);
|
||||
self.tickers.push((pos, slot));
|
||||
Arc::make_mut(&mut self.tickers).push((pos, slot));
|
||||
}
|
||||
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
|
||||
return self.tickers.clone();
|
||||
return Arc::clone(&self.tickers);
|
||||
}
|
||||
self.out.clear();
|
||||
let (live, map) = (&mut self.live, &self.map);
|
||||
self.tickers.retain(|(p, slot)| {
|
||||
Arc::make_mut(&mut self.tickers).retain(|(p, slot)| {
|
||||
let mine = live.get(p) == Some(slot);
|
||||
if mine && !map.contains_key(p) {
|
||||
live.remove(p);
|
||||
}
|
||||
mine && map.contains_key(p)
|
||||
});
|
||||
self.tickers.clone()
|
||||
Arc::clone(&self.tickers)
|
||||
}
|
||||
/// Whether a slot from `tickers` still ticks.
|
||||
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
|
||||
@@ -329,8 +330,8 @@ mod tests {
|
||||
}
|
||||
fn order(b: &mut BlockEntities, states: &HashMap<Pos, u32>) -> Vec<Pos> {
|
||||
b.tickers(|p| states.get(&p).copied().unwrap_or(0))
|
||||
.into_iter()
|
||||
.map(|(p, _)| p)
|
||||
.iter()
|
||||
.map(|(p, _)| *p)
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -435,7 +435,8 @@ pub struct BellTick {
|
||||
pub ended: Option<(Vec<([f64; 3], i32)>, Vec<u64>)>,
|
||||
}
|
||||
|
||||
fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
|
||||
/// `Vec3i.closerToCenterThan`: `p` is within `distance` of the block's centre.
|
||||
pub fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
|
||||
let dx = f64::from(pos[0]) + 0.5 - p[0];
|
||||
let dy = f64::from(pos[1]) + 0.5 - p[1];
|
||||
let dz = f64::from(pos[2]) + 0.5 - p[2];
|
||||
|
||||
@@ -597,6 +597,18 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
|
||||
line.to_owned()
|
||||
}
|
||||
|
||||
/// `AbstractSignRenderer.isOutlineVisible`: the camera within 16 blocks of
|
||||
/// the sign's centre.
|
||||
fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
|
||||
DVec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
)
|
||||
.distance_squared(camera)
|
||||
< 256.0
|
||||
}
|
||||
|
||||
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
|
||||
/// full-bright with an 8x outline in the dark colour.
|
||||
fn sign_text_calls(
|
||||
@@ -757,6 +769,9 @@ pub struct Scene<'a> {
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
|
||||
/// text laid out under another value is laid out again.
|
||||
pub glyphs: u64,
|
||||
}
|
||||
|
||||
pub struct BlockEntityRenderer {
|
||||
@@ -768,7 +783,30 @@ pub struct BlockEntityRenderer {
|
||||
pot_patterns: HashMap<String, String>,
|
||||
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
|
||||
visibility: Option<Mutex<FaceVisibility>>,
|
||||
sign_texts: Mutex<SignTexts>,
|
||||
}
|
||||
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
|
||||
/// again each frame, glyph by glyph; its vertices only change with what is
|
||||
/// in the key, so they are kept relative to the block and moved to the camera.
|
||||
#[derive(Default)]
|
||||
struct SignTexts {
|
||||
signs: crate::fast_hash::FxHashMap<[i32; 3], SignTextEntry>,
|
||||
/// Game time of the last sweep of signs out of view.
|
||||
swept: i64,
|
||||
}
|
||||
struct SignTextEntry {
|
||||
state: u32,
|
||||
data: crate::signs::SignData,
|
||||
packed: u8,
|
||||
/// Within the glowing text outline's distance (only kept for glowing text).
|
||||
near: bool,
|
||||
glyphs: u64,
|
||||
/// Game time it was last drawn.
|
||||
used: i64,
|
||||
vertices: Vec<Vertex>,
|
||||
}
|
||||
/// Game ticks a sign's text is kept after it was last drawn.
|
||||
const SIGN_TEXT_KEEP: i64 = 100;
|
||||
const MODELS: [(&str, &str); 27] = [
|
||||
("chest", "minecraft:chest#main"),
|
||||
("double_chest_left", "minecraft:double_chest_left#main"),
|
||||
@@ -898,6 +936,7 @@ impl BlockEntityRenderer {
|
||||
closed_chests,
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
}
|
||||
}
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
@@ -932,11 +971,81 @@ impl BlockEntityRenderer {
|
||||
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
|
||||
- scene.camera)
|
||||
.as_vec3();
|
||||
let base = Mat4::from_translation(origin);
|
||||
let packed = scene.world.light(pos[0], pos[1], pos[2]);
|
||||
if matches!(kind, Kind::Sign { .. }) && scene.texts.is_none() {
|
||||
self.sign_text(scene, *pos, *state, origin, packed, out);
|
||||
continue;
|
||||
}
|
||||
let base = Mat4::from_translation(origin);
|
||||
self.render_one(scene, *pos, kind, base, packed, lights, false, true, out);
|
||||
}
|
||||
}
|
||||
/// A sign's text from the cache, laid out again when its data, light,
|
||||
/// state, outline or font changed.
|
||||
fn sign_text(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
pos: [i32; 3],
|
||||
state: u32,
|
||||
origin: Vec3,
|
||||
packed: u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Some(data) = (scene.sign)(pos) else {
|
||||
return;
|
||||
};
|
||||
let near = (data.front.glowing || data.back.glowing) && sign_text_near(pos, scene.camera);
|
||||
let Ok(mut cache) = self.sign_texts.lock() else {
|
||||
return;
|
||||
};
|
||||
let now = scene.game_time;
|
||||
if (now - cache.swept).abs() >= SIGN_TEXT_KEEP {
|
||||
cache.swept = now;
|
||||
cache
|
||||
.signs
|
||||
.retain(|_, e| (now - SIGN_TEXT_KEEP..=now).contains(&e.used));
|
||||
}
|
||||
let glyphs = scene.glyphs;
|
||||
let signs = &mut cache.signs;
|
||||
if !signs.get(&pos).is_some_and(|e| {
|
||||
(e.state, e.packed, e.near, e.glyphs) == (state, packed, near, glyphs)
|
||||
&& e.data == *data
|
||||
}) {
|
||||
let mut vertices = signs.remove(&pos).map_or_else(Vec::new, |e| e.vertices);
|
||||
vertices.clear();
|
||||
self.render_one(
|
||||
scene,
|
||||
pos,
|
||||
self.kind(state),
|
||||
Mat4::IDENTITY,
|
||||
packed,
|
||||
diffuse_lights(scene.nether),
|
||||
false,
|
||||
true,
|
||||
&mut vertices,
|
||||
);
|
||||
signs.insert(
|
||||
pos,
|
||||
SignTextEntry {
|
||||
state,
|
||||
data: data.clone(),
|
||||
packed,
|
||||
near,
|
||||
glyphs,
|
||||
used: now,
|
||||
vertices,
|
||||
},
|
||||
);
|
||||
}
|
||||
let Some(entry) = signs.get_mut(&pos) else {
|
||||
return;
|
||||
};
|
||||
entry.used = now;
|
||||
out.extend(entry.vertices.iter().map(|v| Vertex {
|
||||
pos: (Vec3::from_array(v.pos) + origin).to_array(),
|
||||
..*v
|
||||
}));
|
||||
}
|
||||
/// One block entity at `pos` drawn under `base` with the given packed light
|
||||
/// (the parity fixture's entry point).
|
||||
pub fn render_block(
|
||||
@@ -1168,13 +1277,7 @@ impl BlockEntityRenderer {
|
||||
m *= Mat4::from_translation(Vec3::new(0.0, 1.0 / 3.0, 0.046_666_667))
|
||||
* Mat4::from_scale(s * 0.010_416_667);
|
||||
}
|
||||
let near = DVec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
)
|
||||
.distance_squared(scene.camera)
|
||||
< 256.0;
|
||||
let near = sign_text_near(pos, scene.camera);
|
||||
let calls = sign_text_calls(scene, data, text, m, packed, near);
|
||||
match scene.texts {
|
||||
Some(sink) => sink.borrow_mut().extend(calls),
|
||||
@@ -1954,6 +2057,7 @@ mod tests {
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
// including atlas replacement, current light and animated fallback.
|
||||
@@ -2154,6 +2258,128 @@ mod tests {
|
||||
assert_eq!(out[2].uv, [0.5, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
models: HashMap::new(),
|
||||
kinds: vec![
|
||||
Kind::Sign {
|
||||
hanging: false,
|
||||
wall: false,
|
||||
angle: 180.0,
|
||||
},
|
||||
Kind::Sign {
|
||||
hanging: true,
|
||||
wall: true,
|
||||
angle: 90.0,
|
||||
},
|
||||
],
|
||||
closed_chests: Default::default(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
};
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let sprites = HashMap::new();
|
||||
let text = |line: &str, glowing| crate::signs::SignText {
|
||||
lines: [line.into(), "two".into(), String::new(), "four".into()],
|
||||
color: 3,
|
||||
glowing,
|
||||
};
|
||||
let first_text = crate::signs::SignData {
|
||||
front: text("one", false),
|
||||
..Default::default()
|
||||
};
|
||||
let changed = crate::signs::SignData {
|
||||
front: text("changed", false),
|
||||
..Default::default()
|
||||
};
|
||||
let glowing = crate::signs::SignData {
|
||||
back: text("glow", true),
|
||||
..changed.clone()
|
||||
};
|
||||
let wide = std::cell::Cell::new(6.0);
|
||||
let glyph = |c: char| {
|
||||
let u = f32::from(c as u8 % 16) / 16.0;
|
||||
let rect = Rect {
|
||||
u0: u,
|
||||
v0: 0.0,
|
||||
u1: u + 0.0625,
|
||||
v1: 0.0625,
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
((c != ' ').then_some(rect), wide.get())
|
||||
};
|
||||
let (first_sign, changed_sign, glowing_sign) = (
|
||||
|_| Some(&first_text),
|
||||
|_| Some(&changed),
|
||||
|_| Some(&glowing),
|
||||
);
|
||||
let mut scene = Scene {
|
||||
camera: DVec3::new(3.25, 70.5, -4.75),
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &first_sign,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
};
|
||||
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
|
||||
let mut cached = Vec::new();
|
||||
renderer.render(scene, [(pos, state)].iter(), &mut cached);
|
||||
let corner = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
|
||||
let mut original = Vec::new();
|
||||
renderer.render_one(
|
||||
scene,
|
||||
pos,
|
||||
renderer.kind(state),
|
||||
Mat4::from_translation((corner - scene.camera).as_vec3()),
|
||||
world.light(pos[0], pos[1], pos[2]),
|
||||
diffuse_lights(false),
|
||||
false,
|
||||
true,
|
||||
&mut original,
|
||||
);
|
||||
assert!(!cached.is_empty());
|
||||
assert_eq!(cached.len(), original.len());
|
||||
for (a, b) in cached.iter().zip(&original) {
|
||||
assert!((Vec3::from_array(a.pos) - Vec3::from_array(b.pos)).length() < 1e-5);
|
||||
assert_eq!((a.uv, a.color, a.light), (b.uv, b.color, b.light));
|
||||
}
|
||||
cached.len()
|
||||
};
|
||||
let (pos, other) = ([1, 70, 2], [-3, 64, 9]);
|
||||
let first = check(&scene, pos, 0);
|
||||
// The camera moves: the same text, moved.
|
||||
scene.camera.x += 0.5;
|
||||
check(&scene, pos, 0);
|
||||
// New text, glowing text near and far, another state, other glyphs.
|
||||
scene.sign = &changed_sign;
|
||||
assert_ne!(check(&scene, pos, 0), first);
|
||||
scene.sign = &glowing_sign;
|
||||
check(&scene, pos, 0);
|
||||
scene.camera.z -= 20.0;
|
||||
check(&scene, pos, 0);
|
||||
check(&scene, pos, 1);
|
||||
check(&scene, other, 1);
|
||||
wide.set(5.0);
|
||||
scene.glyphs = 1;
|
||||
check(&scene, pos, 1);
|
||||
// Signs out of view for a while are dropped.
|
||||
scene.game_time = 2 * SIGN_TEXT_KEEP;
|
||||
check(&scene, pos, 0);
|
||||
assert_eq!(renderer.sign_texts.lock().unwrap().signs.len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn first_line_wraps_on_bytes_without_splitting_a_char() {
|
||||
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
|
||||
@@ -2214,6 +2440,7 @@ mod tests {
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
|
||||
|
||||
@@ -15,33 +15,11 @@ pub struct Confirm {
|
||||
pub buttons: [(f32, f32); 2],
|
||||
}
|
||||
impl Confirm {
|
||||
pub fn new(font: &Font, (w, h): (f32, f32), title: &Text, message: &Text) -> Self {
|
||||
let message: Vec<Text> = font
|
||||
.split_lines(message, w - 50.0)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.take(15)
|
||||
.collect();
|
||||
let tw = font.width_text(title);
|
||||
let mw = message
|
||||
.iter()
|
||||
.map(|l| font.width_text(l))
|
||||
.fold(0.0, f32::max);
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let col = tw.max(mw).max(304.0);
|
||||
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
|
||||
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
|
||||
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
|
||||
let mid = cell(mw) + (mw / 2.0).trunc();
|
||||
let mut lines = vec![(title.clone(), cell(tw), ly)];
|
||||
for (i, line) in message.into_iter().enumerate() {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
|
||||
}
|
||||
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
|
||||
pub fn new(font: &Font, size: (f32, f32), title: &Text, message: &Text) -> Self {
|
||||
let Choice { lines, buttons, .. } = Choice::new(font, size, title, message, 2, 150.0);
|
||||
Self {
|
||||
lines,
|
||||
buttons: [(bx, by), (bx + 154.0, by)],
|
||||
buttons: [buttons[0], buttons[1]],
|
||||
}
|
||||
}
|
||||
/// The answer a click at `cursor` gives: true for yes, false for no.
|
||||
@@ -84,3 +62,119 @@ impl Confirm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ConfirmScreen` with a row of `count` buttons `width` wide, 4 apart: `ConfirmLinkScreen`'s
|
||||
/// yes, copy and no are three of 100. `Confirm` is its two of 150.
|
||||
pub struct Choice {
|
||||
/// Each line and where it is drawn.
|
||||
pub lines: Vec<(Text, f32, f32)>,
|
||||
/// The buttons' corners, left to right.
|
||||
pub buttons: Vec<(f32, f32)>,
|
||||
pub width: f32,
|
||||
}
|
||||
impl Choice {
|
||||
pub fn new(
|
||||
font: &Font,
|
||||
(w, h): (f32, f32),
|
||||
title: &Text,
|
||||
message: &Text,
|
||||
count: usize,
|
||||
width: f32,
|
||||
) -> Self {
|
||||
let message: Vec<Text> = font
|
||||
.split_lines(message, w - 50.0)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.take(15)
|
||||
.collect();
|
||||
let tw = font.width_text(title);
|
||||
let mw = message
|
||||
.iter()
|
||||
.map(|l| font.width_text(l))
|
||||
.fold(0.0, f32::max);
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let row = width * count as f32 + 4.0 * count.saturating_sub(1) as f32;
|
||||
let col = tw.max(mw).max(row);
|
||||
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
|
||||
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
|
||||
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
|
||||
let mid = cell(mw) + (mw / 2.0).trunc();
|
||||
let mut lines = vec![(title.clone(), cell(tw), ly)];
|
||||
for (i, line) in message.into_iter().enumerate() {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
|
||||
}
|
||||
let (bx, by) = (cell(row), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
|
||||
Self {
|
||||
lines,
|
||||
buttons: (0..count)
|
||||
.map(|i| (bx + (width + 4.0) * i as f32, by))
|
||||
.collect(),
|
||||
width,
|
||||
}
|
||||
}
|
||||
/// The button a click at `cursor` hits, counted from the left.
|
||||
pub fn pressed_at(&self, cursor: (f32, f32)) -> Option<usize> {
|
||||
self.buttons
|
||||
.iter()
|
||||
.position(|&(x, y)| ui::hovered(cursor, x, y, self.width, 20.0))
|
||||
}
|
||||
/// The lines and the buttons, over whatever is behind.
|
||||
pub fn draw(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
labels: &[Text],
|
||||
) {
|
||||
for (line, x, y) in &self.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for (&(bx, by), label) in self.buttons.iter().zip(labels) {
|
||||
let hover = ui::hovered(cursor, bx, by, self.width, 20.0);
|
||||
ui::button(batch, atlas, bx, by, self.width, 20.0, label, true, hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hud::Rect;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
fn font() -> Font {
|
||||
// The missing-glyph metrics let these tests run without game assets.
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
Font::load(
|
||||
Path::new("/nonexistent-horizon-confirm-test-resources"),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
rect,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_buttons_sit_four_apart_in_the_middle() {
|
||||
let (title, message) = (Text::literal("Open?"), Text::literal("https://x"));
|
||||
let c = Choice::new(&font(), (480.0, 270.0), &title, &message, 3, 100.0);
|
||||
let xs: Vec<f32> = c.buttons.iter().map(|b| b.0).collect();
|
||||
// 3 x 100 + 2 x 4 = 308 wide, centred on 480.
|
||||
assert_eq!(xs, vec![86.0, 190.0, 294.0]);
|
||||
assert_eq!(c.pressed_at((191.0, c.buttons[1].1 + 1.0)), Some(1));
|
||||
assert_eq!(c.pressed_at((188.0, c.buttons[1].1 + 1.0)), None);
|
||||
// Confirm keeps its two 150-wide buttons where they were.
|
||||
let two = Confirm::new(&font(), (480.0, 270.0), &title, &message);
|
||||
assert_eq!(two.buttons[0].0, 88.0);
|
||||
assert_eq!(two.buttons[1].0, 242.0);
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,8 @@ pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
|
||||
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
|
||||
};
|
||||
use crate::{
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
@@ -380,167 +377,6 @@ impl BlockEntity {
|
||||
}
|
||||
}
|
||||
pub use crate::providers::Providers;
|
||||
fn remainder(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
|
||||
Some("minecraft:bucket")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl Furnace {
|
||||
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
|
||||
/// the same item with the same components restarts cooking, with the
|
||||
/// total time of the new input's recipe (200 ticks without one).
|
||||
pub fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
let same = match (&self.items[0], &stack) {
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
self.items[0] = stack;
|
||||
if !same {
|
||||
self.cooking_total = self.items[0]
|
||||
.as_ref()
|
||||
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
|
||||
.map_or(200, |recipe| total_time(recipe.time, self.speed));
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
pub fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
|
||||
if self.items[0] != before {
|
||||
let after = std::mem::replace(&mut self.items[0], before);
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
recipes: &Recipes,
|
||||
items: &Items,
|
||||
providers: &Providers,
|
||||
) -> Option<bool> {
|
||||
let was_lit = self.lit_remaining > 0;
|
||||
let mut lit = false;
|
||||
if self.lit_remaining > 0 {
|
||||
self.lit_remaining -= 1;
|
||||
lit = self.lit_remaining > 0;
|
||||
}
|
||||
let fast = self.kind.fast();
|
||||
let has_ingredient = self.items[0].is_some();
|
||||
let has_fuel = self.items[1].is_some();
|
||||
if lit || has_fuel && has_ingredient {
|
||||
if let Some(ingredient) = self.items[0].clone() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
|
||||
if self.cooking_timer == 0 {
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
}
|
||||
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
Some(out) => {
|
||||
out.item == recipe.result.item && out.count + recipe.result.count <= max
|
||||
}
|
||||
};
|
||||
if can_burn {
|
||||
if !lit {
|
||||
let fuel = self.items[1].clone();
|
||||
let time = fuel
|
||||
.as_ref()
|
||||
.map_or(0, |f| providers.burn_time(items, f, fast));
|
||||
let speed = fuel
|
||||
.as_ref()
|
||||
.map_or(1.0, |f| providers.speed(items, f, fast));
|
||||
self.lit_remaining = time;
|
||||
self.lit_total = time;
|
||||
self.speed = speed;
|
||||
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
|
||||
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
self.cooking_timer =
|
||||
(ratio * self.cooking_total as f32).ceil() as i32;
|
||||
}
|
||||
if time > 0 {
|
||||
if let Some(f) = self.items[1].as_mut() {
|
||||
let item = f.item.clone();
|
||||
f.count -= 1;
|
||||
if f.count == 0 {
|
||||
self.items[1] = remainder(&item).map(|r| ItemStack {
|
||||
item: Arc::from(r),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
lit = true;
|
||||
}
|
||||
}
|
||||
if lit {
|
||||
self.cooking_timer += 1;
|
||||
if self.cooking_timer >= self.cooking_total {
|
||||
self.cooking_timer = 0;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
match &mut self.items[2] {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
self.items[0] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else if self.cooking_timer > 0 {
|
||||
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
}
|
||||
fn total_time(time: u32, speed: f32) -> i32 {
|
||||
if speed > 0.0 {
|
||||
(time as f32 / speed).ceil() as i32
|
||||
} else {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
|
||||
/// more with the chance of what is left over (a level random draw only when there is some).
|
||||
|
||||
@@ -263,6 +263,16 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
|
||||
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
|
||||
// normal, so no cardinal light) wherever the texture is not fully clear.
|
||||
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
|
||||
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
|
||||
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
|
||||
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
|
||||
color *= in.light_color;
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
|
||||
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
|
||||
return vec4(in.color.rgb, 1.0);
|
||||
|
||||
@@ -130,6 +130,8 @@ pub struct Font {
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
cache: RefCell<Cache>,
|
||||
/// Set anew for each font and each `reset_cache` (see `epoch`).
|
||||
generation: std::cell::Cell<u64>,
|
||||
/// "Force Unicode Font": the `uniform` filter option.
|
||||
pub uniform: bool,
|
||||
/// "Japanese Glyph Variants": the `jp` filter option.
|
||||
@@ -559,6 +561,11 @@ impl Region {
|
||||
Some((ax, ay))
|
||||
}
|
||||
}
|
||||
/// A value no font has had yet (`Font::epoch`).
|
||||
fn next_generation() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
impl Font {
|
||||
/// Build from the atlas holding the bitmap font sheets (`sprites`, of
|
||||
/// `atlas` size) with `region` the reserved rows.
|
||||
@@ -603,6 +610,7 @@ impl Font {
|
||||
.map_or(0, |d| d.as_nanos() as i64),
|
||||
),
|
||||
}),
|
||||
generation: std::cell::Cell::new(next_generation()),
|
||||
uniform: false,
|
||||
japanese: false,
|
||||
};
|
||||
@@ -661,6 +669,13 @@ impl Font {
|
||||
}
|
||||
cache.region.cursor = (0, 0);
|
||||
cache.region.row = 0;
|
||||
self.generation.set(next_generation());
|
||||
}
|
||||
/// Changes whenever a glyph may come out different: another font, a
|
||||
/// cache reset (glyphs move in the atlas) or other font options. Text laid
|
||||
/// out under one epoch can be kept until it changes.
|
||||
pub fn epoch(&self) -> u64 {
|
||||
self.generation.get() << 2 | u64::from(self.uniform) << 1 | u64::from(self.japanese)
|
||||
}
|
||||
/// The glyph for a character in the default font (FontSet.getGlyph).
|
||||
pub fn glyph(&self, c: char) -> Glyph {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! `AbstractFurnaceBlockEntity`'s cooking over the furnace data the chunk
|
||||
//! holds (`containers::Furnace`): it needs the recipes, so it lives above them.
|
||||
use crate::{
|
||||
containers::{Furnace, recipe_used_key},
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The furnace methods that read the recipes.
|
||||
pub trait FurnaceCooking {
|
||||
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
|
||||
/// the same item with the same components restarts cooking, with the
|
||||
/// total time of the new input's recipe (200 ticks without one).
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>);
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)>;
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>);
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool>;
|
||||
}
|
||||
fn remainder(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
|
||||
Some("minecraft:bucket")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl FurnaceCooking for Furnace {
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
let same = match (&self.items[0], &stack) {
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
self.items[0] = stack;
|
||||
if !same {
|
||||
self.cooking_total = self.items[0]
|
||||
.as_ref()
|
||||
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
|
||||
.map_or(200, |recipe| total_time(recipe.time, self.speed));
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
|
||||
if self.items[0] != before {
|
||||
let after = std::mem::replace(&mut self.items[0], before);
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool> {
|
||||
let was_lit = self.lit_remaining > 0;
|
||||
let mut lit = false;
|
||||
if self.lit_remaining > 0 {
|
||||
self.lit_remaining -= 1;
|
||||
lit = self.lit_remaining > 0;
|
||||
}
|
||||
let fast = self.kind.fast();
|
||||
let has_ingredient = self.items[0].is_some();
|
||||
let has_fuel = self.items[1].is_some();
|
||||
if lit || has_fuel && has_ingredient {
|
||||
if let Some(ingredient) = self.items[0].clone() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
|
||||
if self.cooking_timer == 0 {
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
}
|
||||
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
Some(out) => {
|
||||
out.item == recipe.result.item && out.count + recipe.result.count <= max
|
||||
}
|
||||
};
|
||||
if can_burn {
|
||||
if !lit {
|
||||
let fuel = self.items[1].clone();
|
||||
let time = fuel
|
||||
.as_ref()
|
||||
.map_or(0, |f| providers.burn_time(items, f, fast));
|
||||
let speed = fuel
|
||||
.as_ref()
|
||||
.map_or(1.0, |f| providers.speed(items, f, fast));
|
||||
self.lit_remaining = time;
|
||||
self.lit_total = time;
|
||||
self.speed = speed;
|
||||
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
|
||||
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
self.cooking_timer =
|
||||
(ratio * self.cooking_total as f32).ceil() as i32;
|
||||
}
|
||||
if time > 0 {
|
||||
if let Some(f) = self.items[1].as_mut() {
|
||||
let item = f.item.clone();
|
||||
f.count -= 1;
|
||||
if f.count == 0 {
|
||||
self.items[1] = remainder(&item).map(|r| ItemStack {
|
||||
item: Arc::from(r),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
lit = true;
|
||||
}
|
||||
}
|
||||
if lit {
|
||||
self.cooking_timer += 1;
|
||||
if self.cooking_timer >= self.cooking_total {
|
||||
self.cooking_timer = 0;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
match &mut self.items[2] {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
self.items[0] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else if self.cooking_timer > 0 {
|
||||
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
}
|
||||
fn total_time(time: u32, speed: f32) -> i32 {
|
||||
if speed > 0.0 {
|
||||
(time as f32 / speed).ceil() as i32
|
||||
} else {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,7 @@ use horizon_client::{
|
||||
entities::{Experience, ExperienceOrb, ItemEntity},
|
||||
environment::Environment,
|
||||
first_person::{Hand, SwingAnimation},
|
||||
furnace::FurnaceCooking,
|
||||
gfx::{Frame, Renderer},
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
hud_layout, hud_overlays,
|
||||
@@ -273,6 +274,7 @@ use std::{
|
||||
};
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
error::EventLoopError,
|
||||
event::{DeviceEvent, DeviceId, ElementState, MouseButton, MouseScrollDelta, WindowEvent},
|
||||
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -1791,6 +1793,8 @@ struct Game<'a> {
|
||||
creaking: creaking::CreakingState,
|
||||
/// Monster spawners' display mobs (`game/spawner.rs`).
|
||||
spawners: spawner::SpawnerState,
|
||||
/// Reused candidate buffers for natural spawning.
|
||||
spawn_scratch: natural_spawning::SpawnScratch,
|
||||
trident_model: Option<horizon_client::mobs::Part>,
|
||||
arrow_model: Option<horizon_client::mobs::Part>,
|
||||
tnts: Vec<tnt::PrimedTnt>,
|
||||
@@ -2329,12 +2333,19 @@ impl Game<'_> {
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(64, |i| i.max_stack);
|
||||
// Prefer the selected slot, then the hotbar, then the main inventory.
|
||||
// getSlotWithRemainingSpace: the selected slot, the off hand, then
|
||||
// the hotbar and the main inventory.
|
||||
let order: Vec<usize> = std::iter::once(self.selected)
|
||||
.chain(std::iter::once(shield::OFFHAND_SLOT))
|
||||
.chain((0..36).filter(|&i| i != self.selected))
|
||||
.collect();
|
||||
for &i in &order {
|
||||
if let Some(slot) = &mut self.inventory[i]
|
||||
let slot = if i == shield::OFFHAND_SLOT {
|
||||
&mut self.offhand
|
||||
} else {
|
||||
&mut self.inventory[i]
|
||||
};
|
||||
if let Some(slot) = slot
|
||||
&& slot.same_item_same_components(&stack)
|
||||
&& slot.count < max
|
||||
{
|
||||
@@ -3552,6 +3563,9 @@ impl Game<'_> {
|
||||
state,
|
||||
kind: "dispense",
|
||||
} => self.dispense_from(pos, state)?,
|
||||
LevelEvent::BlockEvent {
|
||||
pos, kind: "golem", ..
|
||||
} => self.check_golem_spawn([pos.x, pos.y, pos.z])?,
|
||||
LevelEvent::BlockEvent {
|
||||
pos,
|
||||
kind: "command_block",
|
||||
@@ -3685,6 +3699,7 @@ impl Game<'_> {
|
||||
self.open_ender_chest(key);
|
||||
// EnderChestBlock.useWithoutItem
|
||||
self.stats.custom("open_enderchest", 1);
|
||||
self.anger_piglins(true);
|
||||
if self.container_opener_event(key, true) {
|
||||
self.container_edge_sound(key, true);
|
||||
}
|
||||
@@ -4012,12 +4027,36 @@ impl Game<'_> {
|
||||
// library: the block entity's side effects (containers spill), neighbour
|
||||
// and shape updates, and the destroy event (particles, sound).
|
||||
let creative = self.creative;
|
||||
// TripWireBlock.playerWillDestroy: shears disarm the string first (flags
|
||||
// 260), so taking it down does not trip the hook.
|
||||
if &*facts.name == "minecraft:tripwire"
|
||||
&& self.held().is_some_and(|s| &*s.item == "minecraft:shears")
|
||||
&& let Some(disarmed) = self.with_props(state, &[("disarmed", "true")])
|
||||
{
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, disarmed, 260));
|
||||
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
|
||||
}
|
||||
// BeehiveBlock.playerWillDestroy: a creative break still drops the hive
|
||||
// with its bees and honey level.
|
||||
if creative
|
||||
&& self.ui.rules.bool("block_drops")
|
||||
&& let Some((h, (honey, _))) = &hive
|
||||
&& (!h.bees.is_empty() || honey.is_some_and(|l| l > 0))
|
||||
&& let Some(stack) = self.picked_stack(pos, true)
|
||||
{
|
||||
let at = pos.map(f64::from);
|
||||
self.spawn_item(stack, at, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
let correct_tool =
|
||||
!facts.requires_tool || self.res.items().correct_for_drops(self.held(), &facts.name);
|
||||
let removed = self.with_level(|b, level, ticks| {
|
||||
b.player_destroy_block(
|
||||
level,
|
||||
ticks,
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
creative,
|
||||
correct_tool,
|
||||
)
|
||||
});
|
||||
if removed {
|
||||
@@ -4046,11 +4085,14 @@ impl Game<'_> {
|
||||
self.pop_resource(pos, d);
|
||||
}
|
||||
}
|
||||
// BeehiveBlock.playerDestroy: silk touch keeps the bees in.
|
||||
if let Some((hive, (honey, facing))) = hive
|
||||
&& !silk
|
||||
{
|
||||
self.hive_broken_by_player(pos, hive, honey, facing);
|
||||
// BeehiveBlock.playerDestroy: silk touch keeps the bees in, so
|
||||
// the trigger counts them; otherwise they are out already.
|
||||
if let Some((hive, (honey, facing))) = hive {
|
||||
let bees = if silk { hive.bees.len() as i64 } else { 0 };
|
||||
if !silk {
|
||||
self.hive_broken_by_player(pos, hive, honey, facing);
|
||||
}
|
||||
self.trigger_bee_nest_destroyed(broken, held.as_ref(), bees);
|
||||
}
|
||||
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
|
||||
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
|
||||
@@ -4244,6 +4286,10 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_redstone_ore(hit.pos, held.as_ref())? {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
match self.use_respawn_anchor(hit.pos, held.as_ref())? {
|
||||
respawn_anchor::AnchorUse::Charged => {
|
||||
self.swing_arm();
|
||||
@@ -4253,9 +4299,30 @@ impl Game<'_> {
|
||||
respawn_anchor::AnchorUse::Pass => anchor_passed = true,
|
||||
respawn_anchor::AnchorUse::Other => {}
|
||||
}
|
||||
// CeilingHangingSignBlock.useItemOn: a hanging sign aimed at the
|
||||
// underside passes, so the next sign chains below instead of
|
||||
// opening the editor (signs here carry no click commands).
|
||||
let chains_sign = hit.face == [0, -1, 0]
|
||||
&& held
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.item.ends_with("_hanging_sign"))
|
||||
&& {
|
||||
let name = &self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
|
||||
.name;
|
||||
name.ends_with("_hanging_sign") && !name.contains("_wall_")
|
||||
}
|
||||
&& matches!(
|
||||
self.world
|
||||
.block_entities
|
||||
.get(&(hit.pos[0], hit.pos[1], hit.pos[2])),
|
||||
Some(BlockEntity::Sign(_))
|
||||
);
|
||||
// TRY_WITH_EMPTY_HAND: only the main hand's turn uses the block
|
||||
// itself (useWithoutItem).
|
||||
if !anchor_passed && self.hand == Hand::Main {
|
||||
if !anchor_passed && !chains_sign && self.hand == Hand::Main {
|
||||
let face = match hit.face {
|
||||
[0, 1, 0] => Direction::Up,
|
||||
[0, -1, 0] => Direction::Down,
|
||||
@@ -5624,6 +5691,16 @@ impl Game<'_> {
|
||||
rotation.turn(xo * zoom, yo * zoom);
|
||||
self.yaw = rotation.y_rot;
|
||||
self.pitch = rotation.x_rot;
|
||||
// Entity.turn ends in AbstractBoat.onPassengerTurned: clampRotation
|
||||
// keeps the view within 105 degrees of the boat at every turn, not
|
||||
// only at the tick, so it never swings past and back.
|
||||
if let Some(b) = self
|
||||
.riding
|
||||
.and_then(|id| self.boats.iter().find(|b| b.id == id))
|
||||
{
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Minecraft.pause`: a single-player game with a pausing screen open.
|
||||
@@ -5686,6 +5763,8 @@ impl Game<'_> {
|
||||
let warp = |window: &Window| {
|
||||
let _ = window.set_cursor_position(centre);
|
||||
};
|
||||
let scale = HudBatch::new(size).scale;
|
||||
let was_grabbed = self.captured;
|
||||
if capture {
|
||||
self.mouse.set_ignore_first_move();
|
||||
// macOS warps by re-associating the mouse with the pointer,
|
||||
@@ -5716,13 +5795,23 @@ impl Game<'_> {
|
||||
if self.focused {
|
||||
warp(window);
|
||||
}
|
||||
let scale = HudBatch::new(size).scale;
|
||||
self.cursor = (size[0] as f32 / 2.0 / scale, size[1] as f32 / 2.0 / scale);
|
||||
} else {
|
||||
let _ = window.set_cursor_grab(CursorGrabMode::None);
|
||||
}
|
||||
self.captured = false;
|
||||
}
|
||||
// MouseHandler.grabMouse and releaseMouse: the mouse position is
|
||||
// the window's centre whenever the pointer's grab state changes, so
|
||||
// a screen opened from gameplay starts with the cursor in the middle
|
||||
// and the game starts with it away from the screen's last spot.
|
||||
if let Some(pos) = horizon_client::mouse::mouse_position_after_grab_change(
|
||||
capture,
|
||||
was_grabbed,
|
||||
size,
|
||||
scale,
|
||||
) {
|
||||
self.cursor = pos;
|
||||
}
|
||||
window.set_cursor_visible(!self.captured);
|
||||
}
|
||||
/// `GuiGraphicsExtractor.applyCursor` -> `Window.selectCursor` at the end
|
||||
@@ -5766,30 +5855,31 @@ impl Game<'_> {
|
||||
/// highlight (its count yellow when the slot caps it), and hides while
|
||||
/// it is the drag's only slot.
|
||||
fn draw_slot_item(
|
||||
&mut self,
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
drag: Option<&DragView>,
|
||||
slot: usize,
|
||||
stack: Option<ItemStack>,
|
||||
stack: &Option<ItemStack>,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let carried;
|
||||
let (stack, capped) = match drag.and_then(|d| d.slots.get(&slot)) {
|
||||
Some(None) => return,
|
||||
Some(Some((count, capped))) => {
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
let carried = self
|
||||
carried = self
|
||||
.carried
|
||||
.clone()
|
||||
.map(|c| ItemStack { count: *count, ..c });
|
||||
(carried, *capped)
|
||||
(carried.as_ref(), *capped)
|
||||
}
|
||||
None => (stack, false),
|
||||
None => (stack.as_ref(), false),
|
||||
};
|
||||
if let Some(stack) = stack {
|
||||
self.draw_item_model(batch, &stack, x, y);
|
||||
self.draw_item_model(batch, stack, x, y);
|
||||
let text = capped.then(|| format!("\u{a7}e{}", stack.count));
|
||||
self.draw_item_decorations(batch, &stack, x, y, text);
|
||||
self.draw_item_decorations(batch, stack, x, y, text);
|
||||
}
|
||||
}
|
||||
/// `extractCarriedItem`: the carried stack at the cursor, showing what a
|
||||
@@ -6407,7 +6497,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
let stack = self.slot_view(i);
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
|
||||
}
|
||||
}
|
||||
fn draw_inventory(&mut self, batch: &mut HudBatch) {
|
||||
@@ -8016,7 +8106,12 @@ impl Game<'_> {
|
||||
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
|
||||
.flatten();
|
||||
if let Some(sprite) = shadow_sprite {
|
||||
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
|
||||
// DimensionType.ambientLight: the_nether 0.1, the_end 0.25.
|
||||
let ambient = match self.dim {
|
||||
Dim::Nether => 0.1,
|
||||
Dim::End => 0.25,
|
||||
Dim::Overworld => 0.0,
|
||||
};
|
||||
let darken = self.sky_darken();
|
||||
let a = alpha;
|
||||
let lerp = |p: Vec3, q: Vec3| {
|
||||
@@ -8080,6 +8175,7 @@ impl Game<'_> {
|
||||
banner: &|p| self.banner_layers_at(p),
|
||||
skull: &|p| self.skull_skin_at(p),
|
||||
glyph: &|c| self.gui.atlas.glyph_cell(c),
|
||||
glyphs: self.gui.atlas.font.epoch(),
|
||||
};
|
||||
// Block entities outside the view frustum are skipped (with a
|
||||
// block of margin for models larger than their block).
|
||||
@@ -8401,7 +8497,9 @@ impl Game<'_> {
|
||||
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for (i, m) in self.mobs.iter().enumerate() {
|
||||
if !m.alive() {
|
||||
// ProjectileUtil.getEntityHitResult: the mob the player rides
|
||||
// shares its root vehicle and is never the hit.
|
||||
if !m.alive() || Some(m.id) == self.riding {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = ray_box(eye, dir, reach, m.bounds())
|
||||
@@ -10956,7 +11054,11 @@ impl Game<'_> {
|
||||
// Creative players skip the monster check (`!this.isCreative()`).
|
||||
let monsters = !self.creative
|
||||
&& self.mobs.iter().any(|m| {
|
||||
m.hostile()
|
||||
// `Monster.class` entities that `isPreventingPlayerRest`: a
|
||||
// zombified piglin only while it is angry.
|
||||
horizon_client::mobs::ai::is_monster(m.species())
|
||||
&& (m.species() != horizon_client::mobs::Species::ZombifiedPiglin
|
||||
|| (m.extra.anger > 0 && m.ai.target.is_some()))
|
||||
&& m.alive()
|
||||
&& (m.position.x - position.x).abs() < 8.0
|
||||
&& (m.position.y - position.y).abs() < 5.0
|
||||
@@ -11066,7 +11168,7 @@ impl Game<'_> {
|
||||
let selected = self.creative_screen.selected;
|
||||
// Only what is drawn of the tabs: their stacks stay where they are.
|
||||
let tabs = self.creative_screen.tab_headers();
|
||||
let tab = tabs[selected.min(tabs.len() - 1)].clone();
|
||||
let tab = &tabs[selected.min(tabs.len() - 1)];
|
||||
let tab_sprite = |t: &horizon_client::creative::Tab, on: bool| {
|
||||
format!(
|
||||
"gui/sprites/container/creative_inventory/tab_{}_{}_{}",
|
||||
@@ -11102,8 +11204,8 @@ impl Game<'_> {
|
||||
r.v1 = r.v0 + (r.v1 - r.v0) * Creative::HEIGHT / r.h as f32;
|
||||
batch.rect_uv(ox, oy, Creative::WIDTH, Creative::HEIGHT, r, u32::MAX);
|
||||
}
|
||||
let (tx, ty) = Creative::tab_position(&tab);
|
||||
batch.sprite(&self.gui.atlas, &tab_sprite(&tab, true), ox + tx, oy + ty);
|
||||
let (tx, ty) = Creative::tab_position(tab);
|
||||
batch.sprite(&self.gui.atlas, &tab_sprite(tab, true), ox + tx, oy + ty);
|
||||
for t in &tabs {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
let stack = ItemStack {
|
||||
@@ -11121,7 +11223,7 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
if !tab.inventory {
|
||||
let title = self.creative_tab_title(&tab);
|
||||
let title = self.creative_tab_title(tab);
|
||||
batch.text(
|
||||
atlas,
|
||||
&title,
|
||||
@@ -11200,15 +11302,14 @@ impl Game<'_> {
|
||||
let drag = self.drag_view();
|
||||
for c in 0..9 {
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
|
||||
let stack = self.inventory[c].clone();
|
||||
self.draw_slot_item(batch, drag.as_ref(), c, stack.clone(), x, y);
|
||||
self.draw_slot_item(batch, drag.as_ref(), c, &self.inventory[c], x, y);
|
||||
if self.cursor.0 >= x
|
||||
&& self.cursor.0 < x + 16.0
|
||||
&& self.cursor.1 >= y
|
||||
&& self.cursor.1 < y + 16.0
|
||||
{
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
hovered = stack;
|
||||
hovered = self.inventory[c].clone();
|
||||
}
|
||||
}
|
||||
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
|
||||
@@ -12131,7 +12232,8 @@ impl Game<'_> {
|
||||
KeyCode::KeyL if self.screen == Screen::Playing => self.open_advancements(false),
|
||||
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
|
||||
KeyCode::Slash if self.screen == Screen::Playing => self.open_chat("/"),
|
||||
KeyCode::F1 => self.toggle_hide_gui(),
|
||||
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
|
||||
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
|
||||
KeyCode::F11 => self.toggle_fullscreen(),
|
||||
KeyCode::F2 => self.take_screenshot(),
|
||||
KeyCode::F5 if self.screen == Screen::Playing => self.cycle_camera(),
|
||||
@@ -12995,6 +13097,12 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
self.extras_mouse(button, pressed);
|
||||
self.mod_mouse_key(button, pressed);
|
||||
if pressed
|
||||
&& self.settings.keys.get("key.smoothCamera")
|
||||
== horizon_client::keybinds::mouse_name(button)
|
||||
{
|
||||
self.smooth_camera = !self.smooth_camera;
|
||||
}
|
||||
for mapped in self.settings.keys.translate_mouse_all(button) {
|
||||
match mapped {
|
||||
Err(b) => self.play_mouse(b, pressed)?,
|
||||
@@ -13906,6 +14014,7 @@ impl<'a> Game<'a> {
|
||||
ominous_spawners: Vec::new(),
|
||||
creaking: Default::default(),
|
||||
spawners: Default::default(),
|
||||
spawn_scratch: Default::default(),
|
||||
trident_model: prepared.trident_model,
|
||||
arrow_model: prepared.arrow_model,
|
||||
tnts: Vec::new(),
|
||||
@@ -14939,8 +15048,26 @@ fn write_report(game: &Game<'_>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The window system ended the event loop with a failure (on Linux, a compositor dropping the
|
||||
/// connection after a protocol error) while fullscreen was on: the next start opens windowed, so a
|
||||
/// fullscreen the system can't show doesn't crash every launch (#2904).
|
||||
fn leave_fullscreen_after(
|
||||
result: &std::result::Result<(), EventLoopError>,
|
||||
settings: &mut Settings,
|
||||
) {
|
||||
if matches!(result, Err(EventLoopError::ExitFailure(_))) && settings.fullscreen {
|
||||
settings.fullscreen = false;
|
||||
settings.save(&Game::options_path());
|
||||
log::warn!(
|
||||
"the window system closed the game in fullscreen; the next start opens windowed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_event_loop(mut game: Game<'_>) -> Result<()> {
|
||||
EventLoop::new()?.run_app(&mut game)?;
|
||||
let result = EventLoop::new()?.run_app(&mut game);
|
||||
leave_fullscreen_after(&result, &mut game.settings);
|
||||
result?;
|
||||
horizon_client::discord_presence::stop();
|
||||
write_report(&game)?;
|
||||
if let Some(e) = game.error.take() {
|
||||
@@ -15297,7 +15424,12 @@ fn run_loading(
|
||||
error: None,
|
||||
};
|
||||
let mut app = App::Loading(Box::new(loading));
|
||||
EventLoop::new()?.run_app(&mut app)?;
|
||||
let result = EventLoop::new()?.run_app(&mut app);
|
||||
match &mut app {
|
||||
App::Ready(game) => leave_fullscreen_after(&result, &mut game.settings),
|
||||
App::Loading(loading) => leave_fullscreen_after(&result, &mut loading.settings),
|
||||
}
|
||||
result?;
|
||||
horizon_client::discord_presence::stop();
|
||||
match app {
|
||||
App::Ready(mut game) => {
|
||||
|
||||
@@ -408,6 +408,14 @@ impl Game<'_> {
|
||||
equipment[4] = slot(&m.extra.legs);
|
||||
equipment[5] = slot(&m.extra.feet);
|
||||
}
|
||||
// A wolf's armor is its body slot, with its durability.
|
||||
if m.species() == horizon_client::mobs::Species::Wolf {
|
||||
equipment[6] = m.extra.head.as_ref().map(|id| {
|
||||
let mut armor = ItemStack::new(id, 1);
|
||||
armor.damage = m.extra.body_damage.max(0) as u32;
|
||||
item_view(&armor)
|
||||
});
|
||||
}
|
||||
EntityView {
|
||||
kind: m.kind.id.clone(),
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
@@ -719,6 +727,32 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
|
||||
});
|
||||
}
|
||||
/// `KilledTrigger` (`player_killed_entity`): the entity killed and the
|
||||
/// killing blow's damage source (none: any `killing_blow` fails).
|
||||
pub(super) fn trigger_killed(&mut self, target: &EntityView, blow: Option<&adv::DamageView>) {
|
||||
let me = self.player_entity();
|
||||
let target = target.clone();
|
||||
let blow = blow.cloned();
|
||||
self.trigger("minecraft:player_killed_entity", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let ectx = adv::Entities {
|
||||
this: Some(&target),
|
||||
..pctx
|
||||
};
|
||||
let blow_ok = c["killing_blow"].is_null()
|
||||
|| blow
|
||||
.as_ref()
|
||||
.is_some_and(|d| adv::damage_source(&c["killing_blow"], d, w, me.position));
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::conditions(&c["entity"], &ectx, w)
|
||||
&& blow_ok
|
||||
});
|
||||
}
|
||||
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
|
||||
pub(super) fn trigger_killed_by_arrow(
|
||||
&mut self,
|
||||
@@ -869,9 +903,6 @@ impl Game<'_> {
|
||||
stack: &ItemStack,
|
||||
target: &EntityView,
|
||||
) {
|
||||
if self.lan_acting.is_some() {
|
||||
return;
|
||||
}
|
||||
let me = self.player_entity();
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
@@ -1043,6 +1074,110 @@ impl Game<'_> {
|
||||
adv::conditions(&c["location"], &ctx, w)
|
||||
});
|
||||
}
|
||||
/// `LightningStrikeTrigger`: the bolt, and the mobs around it that it did
|
||||
/// not hit (`bystander`), each tested from this player's position.
|
||||
pub(super) fn trigger_lightning_strike(&mut self, bolt: &EntityView, viewers: &[EntityView]) {
|
||||
let me = self.player_entity();
|
||||
let (bolt, viewers) = (bolt.clone(), viewers.to_vec());
|
||||
self.trigger("minecraft:lightning_strike", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let bctx = adv::Entities {
|
||||
this: Some(&bolt),
|
||||
..pctx
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::conditions(&c["lightning"], &bctx, w)
|
||||
&& (c["bystander"].is_null()
|
||||
|| viewers.iter().any(|v| {
|
||||
adv::conditions(
|
||||
&c["bystander"],
|
||||
&adv::Entities {
|
||||
this: Some(v),
|
||||
..pctx
|
||||
},
|
||||
w,
|
||||
)
|
||||
}))
|
||||
});
|
||||
}
|
||||
/// `ChanneledLightningTrigger`: each victim condition matched by some
|
||||
/// mob the bolt hit, from the player who channelled it.
|
||||
pub(super) fn trigger_channeled_lightning(&mut self, victims: &[EntityView]) {
|
||||
let me = self.player_entity();
|
||||
let victims = victims.to_vec();
|
||||
self.trigger("minecraft:channeled_lightning", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& c["victims"].as_array().into_iter().flatten().all(|want| {
|
||||
victims.iter().any(|v| {
|
||||
adv::conditions(
|
||||
want,
|
||||
&adv::Entities {
|
||||
this: Some(v),
|
||||
..pctx
|
||||
},
|
||||
w,
|
||||
)
|
||||
})
|
||||
})
|
||||
});
|
||||
}
|
||||
/// `SpearMobsTrigger`: the living entities the spear stabbed in this use.
|
||||
pub(super) fn trigger_spear_mobs(&mut self, stabbed: usize) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:spear_mobs", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& c["count"].as_u64().is_none_or(|n| stabbed as u64 >= n)
|
||||
});
|
||||
}
|
||||
/// `BeeNestDestroyedTrigger`: the nest's block state, the tool that broke
|
||||
/// it and the bees left inside.
|
||||
pub(super) fn trigger_bee_nest_destroyed(
|
||||
&mut self,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
tool: Option<&ItemStack>,
|
||||
bees: i64,
|
||||
) {
|
||||
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
|
||||
return;
|
||||
};
|
||||
let me = self.player_entity();
|
||||
let tool = tool.map(item_view).unwrap_or_default();
|
||||
self.trigger("minecraft:bee_nest_destroyed", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let state_ok = c["state"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|(k, v)| definition.properties.get(k).map(String::as_str) == v.as_str());
|
||||
adv::id_matches(&c["blocks"], "block", &definition.name, w)
|
||||
&& state_ok
|
||||
&& adv::item(&c["item"], &tool, w)
|
||||
&& adv::range_i(&c["num_bees_inside"], bees)
|
||||
&& adv::conditions(&c["player"], &pctx, w)
|
||||
});
|
||||
}
|
||||
/// A trigger with only the player condition (slept_in_bed, started_riding).
|
||||
pub(super) fn trigger_player(&mut self, name: &str) {
|
||||
let me = self.player_entity();
|
||||
|
||||
@@ -89,6 +89,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The UUID of the player an allay likes (`LIKED_PLAYER`).
|
||||
fn allay_liked_uuid(&self, i: usize) -> [i32; 4] {
|
||||
// An allay saved before its player was kept belongs to the host.
|
||||
self.mobs[i]
|
||||
.extra
|
||||
.owner
|
||||
.filter(|u| *u != [0, 0, 0, 1])
|
||||
.or(self.level.player_uuid)
|
||||
.unwrap_or([0, 0, 0, 1])
|
||||
}
|
||||
/// `AllayAi.getLikedPlayer`: the player the allay was given an item by,
|
||||
/// found by UUID among the host and the guests of the level (the window
|
||||
/// is a guest on the server thread), when they are not a spectator and
|
||||
@@ -98,13 +108,7 @@ impl Game<'_> {
|
||||
if !m.extra.tame {
|
||||
return None;
|
||||
}
|
||||
// An allay saved before its player was kept belongs to the host.
|
||||
let uuid = m
|
||||
.extra
|
||||
.owner
|
||||
.filter(|u| *u != [0, 0, 0, 1])
|
||||
.or(self.level.player_uuid)
|
||||
.unwrap_or([0, 0, 0, 1]);
|
||||
let uuid = self.allay_liked_uuid(i);
|
||||
let host = self
|
||||
.player_view()
|
||||
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
|
||||
@@ -159,6 +163,19 @@ impl Game<'_> {
|
||||
];
|
||||
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
|
||||
let v = [d[0] / len * 0.2, d[1] / len * 0.3, d[2] / len * 0.2];
|
||||
// AllayAi.throwItem: the liked player hears of the block below the deposit.
|
||||
let below = [
|
||||
target.x.floor() as i32,
|
||||
(target.y - 1.0).floor() as i32,
|
||||
target.z.floor() as i32,
|
||||
];
|
||||
let uuid = self.allay_liked_uuid(i);
|
||||
let stack = ItemStack::new(&item, 1);
|
||||
self.for_each_player(|g| {
|
||||
if g.acting_uuid() == uuid && !g.spectator() {
|
||||
g.trigger_block("minecraft:allay_drop_item_on_block", below, &stack);
|
||||
}
|
||||
});
|
||||
let m = &mut self.mobs[i];
|
||||
m.extra.ints[1] -= 1;
|
||||
m.extra.ints[3] = 60;
|
||||
|
||||
@@ -16,6 +16,21 @@ use horizon_core::{
|
||||
};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
/// `EquipmentLayerRenderer.getColorForLayer`: a dyeable layer (one with a
|
||||
/// colour when undyed) takes the stack's `minecraft:dyed_color`, else that
|
||||
/// colour; any other layer stays white, dyed or not. The result is in the
|
||||
/// renderer's byte order (R and B swapped).
|
||||
fn layer_color(undyed: Option<u32>, stack: Option<&ItemStack>) -> u32 {
|
||||
let dyed = stack
|
||||
.and_then(|s| s.data())
|
||||
.and_then(|d| d.dyed_color)
|
||||
.map(|c| c as u32 & 0xFF_FFFF);
|
||||
undyed.map_or(0xFFFF_FFFF, |undyed| {
|
||||
let c = dyed.unwrap_or(undyed);
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
})
|
||||
}
|
||||
|
||||
/// Armor stand models and the humanoid layers of every equipment asset.
|
||||
pub(super) struct ArmorStandAssets {
|
||||
pub main: Option<Part>,
|
||||
@@ -179,9 +194,7 @@ impl Game<'_> {
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let color = undyed.map_or(0xFFFF_FFFF, |c| {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
let color = layer_color(*undyed, stack);
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(r) = stack
|
||||
@@ -695,9 +708,7 @@ impl Game<'_> {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let color = undyed.map_or(0xFFFF_FFFF, |c| {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
let color = layer_color(*undyed, Some(stack));
|
||||
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
|
||||
// when `stack.hasFoil()`, as on a living entity.
|
||||
let item_glint = self
|
||||
@@ -993,3 +1004,21 @@ mod burn_drop_tests {
|
||||
assert_eq!(drops, vec![kept]);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod layer_color_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_dyed_stack_overrides_the_undyed_leather_colour() {
|
||||
let mut dyed = ItemStack::new("minecraft:leather_helmet", 1);
|
||||
dyed.data_mut().dyed_color = Some(0x1E_90FF);
|
||||
// Dyed blue, R and B swapped into the renderer's order, opaque.
|
||||
assert_eq!(layer_color(Some(0xA06540), Some(&dyed)), 0xFFFF_901E);
|
||||
// An undyed stack keeps the asset's brown.
|
||||
let plain = ItemStack::new("minecraft:leather_helmet", 1);
|
||||
assert_eq!(layer_color(Some(0xA06540), Some(&plain)), 0xFF40_65A0);
|
||||
// No dye and no undyed colour: white.
|
||||
assert_eq!(layer_color(None, Some(&plain)), 0xFFFF_FFFF);
|
||||
// A layer that isn't dyeable stays white even on a dyed stack.
|
||||
assert_eq!(layer_color(None, Some(&dyed)), 0xFFFF_FFFF);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use super::Game;
|
||||
use horizon_client::{
|
||||
block_entity_anim::{
|
||||
BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker,
|
||||
closer_to_center_than,
|
||||
},
|
||||
block_entity_render::BlockAnim,
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
@@ -49,11 +50,16 @@ impl Game<'_> {
|
||||
}
|
||||
Some(Ticker::Bell) => {
|
||||
let gathered = self.bell_gather(pos);
|
||||
self.be_anims
|
||||
.bells
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.trigger_event(a, b, time, || gathered);
|
||||
let rung =
|
||||
self.be_anims
|
||||
.bells
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.trigger_event(a, b, time, || gathered);
|
||||
// The server's copy tells the living entities within 32 blocks.
|
||||
if rung && self.remote.is_none() {
|
||||
self.bell_heard(pos, time);
|
||||
}
|
||||
}
|
||||
Some(Ticker::Gateway) => {
|
||||
let initial = self.gateway_loaded(pos);
|
||||
@@ -200,6 +206,26 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `BellBlockEntity.updateEntities`: every gathered entity alive within 32
|
||||
/// blocks of the bell's centre gets `HEARD_BELL_TIME` (only villagers
|
||||
/// have a brain that reads it).
|
||||
fn bell_heard(&mut self, pos: [i32; 3], now: i64) {
|
||||
let nearby = self
|
||||
.be_anims
|
||||
.bells
|
||||
.get(&pos)
|
||||
.and_then(|b| b.nearby.clone())
|
||||
.unwrap_or_default();
|
||||
for m in &mut self.mobs {
|
||||
if m.alive()
|
||||
&& !m.removed
|
||||
&& nearby.contains(&u64::from(m.id))
|
||||
&& closer_to_center_than(pos, [m.position.x, m.position.y, m.position.z], 32.0)
|
||||
{
|
||||
horizon_client::mobs::villager_brain::heard_bell(m, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ShulkerBoxBlock.canOpen`: a closed box opens only when its lid has
|
||||
/// room to rise (`level.noCollision` of the half-open lid box).
|
||||
pub(super) fn shulker_can_open(&self, pos: [i32; 3]) -> bool {
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
Result,
|
||||
block_entities::{Pos, Ticking},
|
||||
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
|
||||
furnace::FurnaceCooking,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -23,12 +24,16 @@ impl Game<'_> {
|
||||
let states = self.ticking_states();
|
||||
self.world.block_entities.set_states(states);
|
||||
}
|
||||
self.forget_gone_hoppers();
|
||||
// Only lingering cooldowns are dropped; a hopper that ticks checks
|
||||
// its own block. Once a second is soon enough for the rest.
|
||||
if self.world.game_time % 20 == 0 {
|
||||
self.forget_gone_hoppers();
|
||||
}
|
||||
let ticking = self.block_ticking_area();
|
||||
let mut spawners = Vec::new();
|
||||
let mut trial_spawners = Vec::new();
|
||||
self.item_columns = Some(Default::default());
|
||||
for slot in self.world.block_entity_tickers() {
|
||||
for slot in self.world.block_entity_tickers().iter().copied() {
|
||||
let pos = slot.0;
|
||||
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
|
||||
if !self.world.block_entities.is_live(slot)
|
||||
|
||||
@@ -151,6 +151,9 @@ impl Game<'_> {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
// BookViewScreen.handleClickEvent: the rest is
|
||||
// Screen.defaultHandleGameClickEvent's.
|
||||
ClickEvent::ShowDialog(dialog) => self.show_dialog_ref(&dialog),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,6 +786,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
|
||||
// Screen.defaultHandleGameClickEvent: the dialog opens over the
|
||||
// screen, and closes back to it.
|
||||
Some(ClickEvent::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
|
||||
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
|
||||
Some(ClickEvent::ChangePage(_)) | None => {}
|
||||
}
|
||||
@@ -808,16 +811,19 @@ impl Game<'_> {
|
||||
&self,
|
||||
url: &str,
|
||||
size: (f32, f32),
|
||||
) -> horizon_client::confirm_screen::Confirm {
|
||||
) -> horizon_client::confirm_screen::Choice {
|
||||
let warning = Text::translate("chat.link.warning", &[]).with_color(0xFF_CCCC);
|
||||
let message = Text::literal(url)
|
||||
.append(Text::literal("\n"))
|
||||
.append(warning);
|
||||
horizon_client::confirm_screen::Confirm::new(
|
||||
// ConfirmLinkScreen.addButtons: yes, copy and no, 100 wide each.
|
||||
horizon_client::confirm_screen::Choice::new(
|
||||
&self.gui.atlas.font,
|
||||
size,
|
||||
&Text::translate("chat.link.confirm", &[]),
|
||||
&message,
|
||||
3,
|
||||
100.0,
|
||||
)
|
||||
}
|
||||
pub(super) fn draw_link_prompt(&self, batch: &mut HudBatch) {
|
||||
@@ -827,12 +833,13 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let (yes, no) = (
|
||||
let labels = [
|
||||
Text::translate("gui.yes", &[]),
|
||||
Text::translate("chat.copy", &[]),
|
||||
Text::translate("gui.no", &[]),
|
||||
);
|
||||
];
|
||||
self.link_prompt_layout(url, (w, h))
|
||||
.draw(batch, atlas, self.cursor, [&yes, &no]);
|
||||
.draw(batch, atlas, self.cursor, &labels);
|
||||
}
|
||||
/// The prompt's answer: the link opens on yes; either way the chat is back.
|
||||
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
|
||||
@@ -843,15 +850,20 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) fn link_prompt_click(&mut self) {
|
||||
let Some(url) = &self.link_prompt else {
|
||||
let Some(url) = self.link_prompt.clone() else {
|
||||
return;
|
||||
};
|
||||
let answer = self
|
||||
.link_prompt_layout(url, self.gui_size())
|
||||
.answer_at(self.cursor);
|
||||
if let Some(yes) = answer {
|
||||
let pressed = self
|
||||
.link_prompt_layout(&url, self.gui_size())
|
||||
.pressed_at(self.cursor);
|
||||
if let Some(button) = pressed {
|
||||
self.audio.play_button_click();
|
||||
self.answer_link_prompt(yes);
|
||||
if button == LINK_COPY {
|
||||
// ConfirmLinkScreen.copyToClipboard, then the callback's no.
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&url);
|
||||
}
|
||||
self.answer_link_prompt(button == LINK_YES);
|
||||
}
|
||||
}
|
||||
/// `Screen.clickCommandAction`: run a clicked command as the player
|
||||
@@ -992,6 +1004,7 @@ impl Game<'_> {
|
||||
spawn_mobs: r.bool("spawn_mobs"),
|
||||
spawn_monsters: r.bool("spawn_monsters"),
|
||||
fire_spread_radius_around_player: r.int("fire_spread_radius_around_player"),
|
||||
spread_vines: r.bool("spread_vines"),
|
||||
}
|
||||
}
|
||||
/// Apply rules mirrored in other state (natural regeneration, difficulty).
|
||||
@@ -1210,3 +1223,7 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
fn owner_permission(allow_commands: bool) -> u8 {
|
||||
if allow_commands { 4 } else { 0 }
|
||||
}
|
||||
|
||||
/// `ConfirmLinkScreen`'s buttons, left to right: yes, copy, no.
|
||||
const LINK_YES: usize = 0;
|
||||
const LINK_COPY: usize = 1;
|
||||
@@ -39,6 +39,11 @@ pub(super) struct CombatTracker {
|
||||
pub(super) last_hurt_mob: Option<(u32, i64)>,
|
||||
/// The mob whose projectile is hitting the player now.
|
||||
pub(super) projectile_owner: Option<u32>,
|
||||
/// The display name of the projectile hitting the player now: the
|
||||
/// `DamageSource.directEntity` the death message names when no owner is
|
||||
/// known (`AbstractArrow.onHitEntity` makes an ownerless arrow its own
|
||||
/// cause).
|
||||
pub(super) projectile_direct: Option<Text>,
|
||||
/// The projectile hitting the player now is another player's that
|
||||
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
|
||||
pub(super) refuse_player_hit: bool,
|
||||
@@ -208,6 +213,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.combat.pending.clear();
|
||||
self.combat.pending_direct.clear();
|
||||
self.combat.projectile_direct = None;
|
||||
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
|
||||
}
|
||||
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
|
||||
|
||||
@@ -1851,7 +1851,7 @@ impl Game<'_> {
|
||||
if self.ui.difficulty == 0 && t.category == "monster" {
|
||||
return Err(err("commands.summon.failed.peaceful", &[]));
|
||||
}
|
||||
self.spawn_mob(&short, pos, true);
|
||||
self.spawn_mob_spawned(&short, pos);
|
||||
name = Text::translate(&format!("entity.minecraft.{short}"), &[]);
|
||||
} else {
|
||||
match short.as_str() {
|
||||
|
||||
@@ -227,7 +227,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// Whether the level in place has a running task, which the server pumps
|
||||
/// between its ticks (`pump_pregen`).
|
||||
/// between its ticks (`pump_chunks`).
|
||||
pub(in crate::game) fn pregen_runs_here(&self) -> bool {
|
||||
self.cmds
|
||||
.pregen
|
||||
@@ -243,11 +243,19 @@ impl Game<'_> {
|
||||
/// 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) {
|
||||
pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) {
|
||||
// Force-loaded chunks too: decorating in vanilla's order, a chunk
|
||||
// waits for its neighbours far more often than once a tick allows.
|
||||
// And the players' chunks while they load, as vanilla's server runs
|
||||
// its chunk tasks while it waits for the next tick
|
||||
// (`MinecraftServer.waitUntilNextTick`): once a tick, a few jobs per
|
||||
// worker (`schedule_generation`) capped joining a world at about 120
|
||||
// columns a second with two workers, the workers idle most of the
|
||||
// time. Not while a respawn search waits for a chunk: its area is
|
||||
// only asked for by the tick's own `update_world`.
|
||||
let players = !paused && self.spawn_wait.is_empty();
|
||||
while self.pregen_runs_here()
|
||||
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
|
||||
|| ((players || !self.streaming.forced.is_empty()) && self.streaming.busy())
|
||||
{
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
|
||||
@@ -277,6 +277,19 @@ impl Game<'_> {
|
||||
pub(super) fn spawn_mob(&mut self, kind: &str, at: [f64; 3], persistent: bool) -> Option<u32> {
|
||||
self.spawn_mob_for(kind, at, persistent, false)
|
||||
}
|
||||
/// `SummonCommand` and `SpawnEggItem`: vanilla spawns the mob, but it
|
||||
/// never calls `Mob.setPersistenceRequired` for it, so the mob is not
|
||||
/// persistent: the despawn rule holds it like any other mob, and only
|
||||
/// its NBT can make it persistent.
|
||||
pub(super) fn spawn_mob_spawned(&mut self, kind: &str, at: [f64; 3]) -> Option<u32> {
|
||||
// The spawn is not a natural one (no jockeys, the patrol leader roll
|
||||
// stays), but the mob is not persistent.
|
||||
let id = self.spawn_mob_for(kind, at, true, false)?;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.persistent = false;
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
/// `spawn_mob` for a patrol or raid (`EntitySpawnReason.PATROL` / `EVENT`),
|
||||
/// which never rolls a patrol leader.
|
||||
pub(super) fn spawn_mob_for(
|
||||
@@ -334,7 +347,7 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let id = self
|
||||
.spawn_mob(kind, at, true)
|
||||
.spawn_mob_spawned(kind, at)
|
||||
.ok_or_else(|| format!("unknown or unsupported mob {kind}"))?;
|
||||
if baby && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.extra.age = if m.species() == Species::Sniffer {
|
||||
@@ -456,6 +469,7 @@ impl Game<'_> {
|
||||
biomes: self.res.biomes(),
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
mob_drops: self.ui.rules.bool("mob_drops"),
|
||||
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
|
||||
end: self.dim == Dim::End,
|
||||
world_seed: self.level.seed,
|
||||
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
|
||||
@@ -732,6 +746,7 @@ impl Game<'_> {
|
||||
self.guardian_bubbles();
|
||||
self.villager_reputation_tick();
|
||||
self.merchant_tick();
|
||||
self.villager_item_sense();
|
||||
self.villager_pickups();
|
||||
for m in dead {
|
||||
self.mob_died(&m);
|
||||
@@ -789,14 +804,30 @@ impl Game<'_> {
|
||||
// takes the experience (before the loot is dropped). It takes what
|
||||
// would drop, whoever killed the mob.
|
||||
self.sculk_mob_died(m);
|
||||
let mut catalyst_xp = 0;
|
||||
let consumed =
|
||||
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |_| {
|
||||
if horizon_client::mobs::drops::drops_experience(&dying) {
|
||||
catalyst_xp = if horizon_client::mobs::drops::drops_experience(&dying) {
|
||||
horizon_client::mobs::drops::base_experience(&mut dying).max(0) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
catalyst_xp
|
||||
});
|
||||
// CatalystListener: with experience to take, the player who last hurt
|
||||
// the mob gets KILL_MOB_NEAR_SCULK_CATALYST.
|
||||
if consumed && catalyst_xp > 0 {
|
||||
let who = match killer {
|
||||
Some(Some(conn)) => Some(super::commands::Target::Guest(conn)),
|
||||
Some(None) if self.lan_acting.is_none() => Some(super::commands::Target::Player),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(who) = who {
|
||||
self.for_player(who, |g| {
|
||||
g.trigger_player("minecraft:kill_mob_near_sculk_catalyst")
|
||||
});
|
||||
}
|
||||
}
|
||||
if by_player {
|
||||
// A guest's kill is theirs: their statistics, scores and
|
||||
// advancements (LivingEntity.die credits lastHurtByPlayer).
|
||||
@@ -856,7 +887,42 @@ impl Game<'_> {
|
||||
self.stats.custom("mob_kills", 1);
|
||||
self.score_player_killed(super::commands::Target::Mob(m.id));
|
||||
let view = self.mob_entity(m);
|
||||
self.trigger_entity("minecraft:player_killed_entity", "entity", &view);
|
||||
let blow = m.last_damage.map(|(d, _)| self.damage_view(d));
|
||||
self.trigger_killed(&view, blow.as_ref());
|
||||
}
|
||||
/// A mob's damage source as advancement predicates see it: its type, the
|
||||
/// causing entity, and the projectile when one dealt it.
|
||||
fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
|
||||
let source = match d.attacker {
|
||||
Some(Who::Player) => Some(self.player_entity()),
|
||||
Some(Who::Mob(id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| self.mob_entity(o)),
|
||||
None => None,
|
||||
};
|
||||
let projectile = match d.kind {
|
||||
DamageKind::Arrow => Some("minecraft:arrow"),
|
||||
DamageKind::Fireball => Some("minecraft:fireball"),
|
||||
_ => None,
|
||||
};
|
||||
let direct = match projectile {
|
||||
Some(kind) => Some(horizon_client::advancements::EntityView {
|
||||
kind: kind.into(),
|
||||
position: d.from.unwrap_or_default(),
|
||||
..Default::default()
|
||||
}),
|
||||
None => source.clone(),
|
||||
};
|
||||
horizon_client::advancements::DamageView {
|
||||
kind: damage_type(d.kind, matches!(d.attacker, Some(Who::Player))).to_owned(),
|
||||
// DamageSource.isDirect: the causing entity is the direct one.
|
||||
is_direct: projectile.is_none(),
|
||||
direct,
|
||||
source,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `dropAllDeathLoot` through `mobs::drops`: the entity loot table from the
|
||||
/// world's random sequence, equipment by drop chance, species extras and
|
||||
@@ -1193,14 +1259,18 @@ impl Game<'_> {
|
||||
owner,
|
||||
} => self.add_fangs(at, yaw, delay, owner),
|
||||
MobEvent::Shoot(..) => {}
|
||||
MobEvent::ExplosionSource { mob, charged } => {
|
||||
MobEvent::ExplosionSource { mob, charged, name } => {
|
||||
self.combat.explosion_source = Some(mob);
|
||||
// The creeper causes the blast's damage, but it is out of
|
||||
// the mob list already: its name comes with the event.
|
||||
self.note_named_attacker("minecraft:explosion", mob, name);
|
||||
if charged {
|
||||
self.combat.charged_exploders.push(mob);
|
||||
}
|
||||
}
|
||||
MobEvent::Explode(at, radius, species) => {
|
||||
// The exploding mob causes the damage (Creeper.explodeCreeper).
|
||||
let noted = self.combat.explosion_source.is_some();
|
||||
let source_mob = self.combat.explosion_source.or_else(|| {
|
||||
self.mobs
|
||||
.iter()
|
||||
@@ -1211,7 +1281,7 @@ impl Game<'_> {
|
||||
.map(|m| m.id)
|
||||
.next()
|
||||
});
|
||||
if let Some(id) = source_mob {
|
||||
if !noted && let Some(id) = source_mob {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
self.combat.explosion_source = source_mob;
|
||||
@@ -1279,8 +1349,10 @@ impl Game<'_> {
|
||||
MobEvent::Hiss { mob } => {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
|
||||
let at = m.center();
|
||||
let sound = horizon_client::mobs::finalize::variant_sound(m, "hiss")
|
||||
.unwrap_or("entity.cat.hiss");
|
||||
self.audio
|
||||
.play("minecraft:entity.cat.hiss", Some(at), 1.0, 1.0);
|
||||
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
MobEvent::Tamed { .. } | MobEvent::Buck { .. } => self.mount_event(&e),
|
||||
@@ -1296,6 +1368,26 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
},
|
||||
MobEvent::Bonemeal { pos } => {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
if self.with_level(|b, level, ticks| b.grow_crop(level, ticks, at)) {
|
||||
self.level_events
|
||||
.push(horizon_world::level::LevelEvent::LevelEvent {
|
||||
id: 1505,
|
||||
pos: at,
|
||||
data: 15,
|
||||
});
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
}
|
||||
MobEvent::RingBell { bell } => {
|
||||
let pos = BlockPos::new(bell[0], bell[1], bell[2]);
|
||||
let rang = self
|
||||
.with_level(|b, level, ticks| b.bell_attempt_to_ring(level, ticks, pos));
|
||||
if rang {
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
}
|
||||
MobEvent::ChestOpen { chest, open } => self.golem_chest_open(chest, open),
|
||||
MobEvent::ChestSlot { chest, slot, stack } => {
|
||||
self.golem_chest_slot(chest, slot, stack)
|
||||
@@ -1857,6 +1949,9 @@ impl Game<'_> {
|
||||
&& let Some(child) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
child.extra.age = -24000;
|
||||
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
|
||||
// the default (`classic`) sound variant.
|
||||
child.extra.sound_variant = None;
|
||||
// A llama's baby was drawn in the parent's tick, with the
|
||||
// experience.
|
||||
if let Some(o) = llama {
|
||||
@@ -1952,8 +2047,12 @@ impl Game<'_> {
|
||||
}
|
||||
// Offspring inherit a parent's variant (pigs/cows/chickens) or mixed wool.
|
||||
child.extra.variant = p.extra.variant;
|
||||
// Fox.getBreedOffspring: either parent's variant.
|
||||
if p.species() == Species::Fox && !coin {
|
||||
// Fox, Pig, Cow and Chicken.getBreedOffspring: either parent's variant.
|
||||
if matches!(
|
||||
p.species(),
|
||||
Species::Fox | Species::Pig | Species::Cow | Species::Chicken
|
||||
) && !coin
|
||||
{
|
||||
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
|
||||
}
|
||||
if p.species() == Species::Sheep {
|
||||
@@ -2083,6 +2182,10 @@ impl Game<'_> {
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
self.note_attacker(damage_type, a);
|
||||
}
|
||||
// The projectile is the direct entity: named when it has no owner.
|
||||
if let Some(name) = self.combat.projectile_direct.take() {
|
||||
self.note_direct_entity(damage_type, name);
|
||||
}
|
||||
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
|
||||
// owner) caused the hit, not a player.
|
||||
let living_cause = attacker.or(owner).is_some();
|
||||
@@ -2559,6 +2662,7 @@ impl Game<'_> {
|
||||
let name = match sound {
|
||||
ArrowSound::Splash => "minecraft:entity.generic.splash",
|
||||
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
|
||||
ArrowSound::CrossbowHit => "minecraft:item.crossbow.hit",
|
||||
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
|
||||
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
|
||||
ArrowSound::TridentHit => "minecraft:item.trident.hit",
|
||||
@@ -2700,6 +2804,26 @@ impl Game<'_> {
|
||||
Some(Who::Mob(id)) => Some(id),
|
||||
_ => None,
|
||||
};
|
||||
self.combat.projectile_direct = match hit {
|
||||
Hit::Entity {
|
||||
who: Who::Player, ..
|
||||
} => {
|
||||
let spectral = p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow");
|
||||
let id = if spectral {
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
p.kind.id()
|
||||
};
|
||||
Some(Text::translate(
|
||||
&format!("entity.{}", id.replace(':', ".")),
|
||||
&[],
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let at = match hit {
|
||||
Hit::Entity { at, .. } | Hit::Block { at, .. } => at,
|
||||
Hit::None => return Ok(()),
|
||||
@@ -3269,7 +3393,16 @@ impl Game<'_> {
|
||||
}
|
||||
{
|
||||
let item = self.held().map(|h| h.item.to_string()).unwrap_or_default();
|
||||
let used = self.held().cloned();
|
||||
if self.interact_leash(index, &item) {
|
||||
if let Some(stack) = &used {
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity(
|
||||
"minecraft:player_interacted_with_entity",
|
||||
stack,
|
||||
&target,
|
||||
);
|
||||
}
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -3843,6 +3976,11 @@ impl Game<'_> {
|
||||
if !handled {
|
||||
return Ok(false);
|
||||
}
|
||||
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
|
||||
if let Some(stack) = &held {
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
|
||||
}
|
||||
// Turtle.ageBoundaryReached: food that finishes a baby's growth.
|
||||
if baby_turtle && !self.mobs[index].is_baby() {
|
||||
self.handle_mob_events(vec![MobEvent::GiftLoot {
|
||||
@@ -3886,6 +4024,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if let Some(stack) = armor_drop {
|
||||
// Mob.shearItem: PLAYER_SHEARED_EQUIPMENT with the armor and the wolf.
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
|
||||
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
|
||||
}
|
||||
for (item, count) in drops {
|
||||
@@ -4060,7 +4201,7 @@ impl Game<'_> {
|
||||
};
|
||||
let parent = &self.mobs[index];
|
||||
if !parent.alive()
|
||||
|| stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str())
|
||||
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|
||||
|| !horizon_client::mobs::drops::can_be_baby(parent.species())
|
||||
// WanderingTrader.getBreedOffspring returns null: no offspring, the
|
||||
// click carries on to trading.
|
||||
@@ -4075,15 +4216,11 @@ impl Game<'_> {
|
||||
let (species, variant, color) =
|
||||
(parent.species(), parent.extra.variant, parent.extra.color);
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
let Some(id) = self.spawn_mob(&kind, at, true) else {
|
||||
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::finalize::set_baby(child, true);
|
||||
child.extra.variant = variant;
|
||||
if species == Species::Sheep {
|
||||
child.extra.color = color;
|
||||
}
|
||||
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
|
||||
}
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
|
||||
self.consume_held();
|
||||
@@ -4121,7 +4258,7 @@ impl Game<'_> {
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
];
|
||||
if self.spawn_mob(&kind, at, true).is_none() {
|
||||
if self.spawn_mob_spawned(&kind, at).is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.swing_arm();
|
||||
@@ -4439,6 +4576,63 @@ impl Game<'_> {
|
||||
self.wind_charge_geometry(a, c, packed, out);
|
||||
continue;
|
||||
}
|
||||
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
|
||||
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
|
||||
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
|
||||
static BULLET: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
|
||||
std::sync::OnceLock::new();
|
||||
let model = BULLET.get_or_init(|| {
|
||||
horizon_client::mobs::load_model(
|
||||
&self.options.resources,
|
||||
"minecraft:shulker_bullet#main",
|
||||
)
|
||||
});
|
||||
if let (Some(model), Some(r)) =
|
||||
(model, atlas.sprites.get("entity/shulker/spark").copied())
|
||||
{
|
||||
use horizon_core::geometry::trig;
|
||||
let tc = a.age as f32 + alpha as f32;
|
||||
let mut poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
poses.yrot("main", a.yaw.to_radians());
|
||||
poses.xrot("main", a.pitch.to_radians());
|
||||
let light = (packed & 0xFF) | ((15 * 16) << 8);
|
||||
let base = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
|
||||
* glam::Mat4::from_rotation_y(
|
||||
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_rotation_x(
|
||||
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_rotation_z(
|
||||
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
|
||||
let uv = [r.u0, r.v0, r.u1, r.v1];
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
uv,
|
||||
0xFFFF_FFFF,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// The translucent shell blends over the opaque core.
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
|
||||
&poses,
|
||||
uv,
|
||||
0x26FF_FFFF,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// WitherSkullRenderer: the skull model facing its flight.
|
||||
if let Some(dangerous) = super::wither::skull_kind(a) {
|
||||
static SKULL: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
|
||||
@@ -4561,6 +4755,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::SonicBoom => "minecraft:sonic_boom",
|
||||
K::Freeze => "minecraft:freeze",
|
||||
K::OutOfWorld => "minecraft:out_of_world",
|
||||
K::Cramming => "minecraft:cramming",
|
||||
}
|
||||
}
|
||||
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
|
||||
|
||||
@@ -163,7 +163,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A `show_dialog` click event's dialog: a registry id or the dialog itself.
|
||||
fn show_dialog_ref(&mut self, dialog: &Tag) {
|
||||
pub(super) fn show_dialog_ref(&mut self, dialog: &Tag) {
|
||||
match dialog {
|
||||
Tag::String(id) => {
|
||||
let id = id.to_string_lossy();
|
||||
|
||||
@@ -1083,7 +1083,7 @@ impl Game<'_> {
|
||||
self.stats.add("killed", "minecraft:ender_dragon", 1);
|
||||
self.stats.custom("mob_kills", 1);
|
||||
self.score_player_killed(super::commands::Target::Mob(id));
|
||||
self.trigger_entity("minecraft:player_killed_entity", "entity", view);
|
||||
self.trigger_killed(view, None);
|
||||
}
|
||||
/// Player melee on the dragon's parts or a crystal under the crosshair.
|
||||
/// The dragon part under the crosshair within reach: its id and distance.
|
||||
@@ -2230,12 +2230,20 @@ impl Game<'_> {
|
||||
.filter(|p| p.alive && !p.creative && !p.spectator)
|
||||
.collect();
|
||||
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
|
||||
// DragonSittingFlamingPhase.end discards the flame cloud.
|
||||
let flame_over = self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.is_some_and(|d| !matches!(d.phase, Phase::Flaming { .. }));
|
||||
let mut clouds = std::mem::take(&mut self.breath_clouds);
|
||||
clouds.retain_mut(|c| {
|
||||
// Lingering potions' clouds tick with the potions.
|
||||
if c.potion.is_some() {
|
||||
return true;
|
||||
}
|
||||
if c.from_dragon && flame_over {
|
||||
return false;
|
||||
}
|
||||
c.age += 1;
|
||||
if c.age >= c.duration {
|
||||
return false;
|
||||
|
||||
@@ -71,10 +71,18 @@ impl Game<'_> {
|
||||
Some(r) => self.settings.render_distance.min(r.view_distance),
|
||||
None => self.settings.render_distance,
|
||||
};
|
||||
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
|
||||
entity_view_scale(distance, self.settings.entity_distance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity.setViewScale: `Mth.clamp(renderDistance / 8, 1, 2.5) *
|
||||
/// entityDistanceScaling`. Every entity's draw distance is scaled by it, so
|
||||
/// a small mob is drawn nearer than a big one and a longer render distance
|
||||
/// draws both further away.
|
||||
pub(super) fn entity_view_scale(render_distance: i32, entity_distance: f32) -> f64 {
|
||||
(f64::from(render_distance) / 8.0).clamp(1.0, 2.5) * f64::from(entity_distance)
|
||||
}
|
||||
|
||||
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
|
||||
/// box's mean edge, scaled by the view scale.
|
||||
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
|
||||
@@ -135,3 +143,41 @@ impl Game<'_> {
|
||||
self.entity_in_view(camera, position, range, lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `Entity.setViewScale`: the entity distance option times the render
|
||||
/// distance's boost — 1 at eight chunks, 1.5 at twelve, 2.5 at twenty.
|
||||
#[test]
|
||||
fn the_view_scale_follows_the_render_distance() {
|
||||
assert_eq!(entity_view_scale(4, 1.0), 1.0);
|
||||
assert_eq!(entity_view_scale(8, 1.0), 1.0);
|
||||
assert_eq!(entity_view_scale(12, 1.0), 1.5);
|
||||
assert_eq!(entity_view_scale(20, 1.0), 2.5);
|
||||
assert_eq!(entity_view_scale(32, 1.0), 2.5);
|
||||
// The entity distance option scales it down.
|
||||
assert_eq!(entity_view_scale(12, 0.5), 0.75);
|
||||
}
|
||||
|
||||
/// `Entity.shouldRenderAtSqrDistance`: a chicken's box (0.4 by 0.7 by
|
||||
/// 0.4) is drawn up to 48 blocks away at the vanilla defaults, not the
|
||||
/// 32 blocks a missing view scale leaves (#2273).
|
||||
#[test]
|
||||
fn a_chicken_is_drawn_forty_eight_blocks_away() {
|
||||
let range = default_range(glam::DVec3::new(0.4, 0.7, 0.4), entity_view_scale(12, 1.0));
|
||||
assert_eq!(range, 48.0);
|
||||
}
|
||||
|
||||
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
|
||||
/// back to one block.
|
||||
#[test]
|
||||
fn a_broken_bounding_box_falls_back_to_one_block() {
|
||||
let range = default_range(
|
||||
glam::DVec3::new(f64::NAN, 0.7, 0.4),
|
||||
entity_view_scale(12, 1.0),
|
||||
);
|
||||
assert_eq!(range, 96.0);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
furnace::FurnaceCooking,
|
||||
items::ItemStack,
|
||||
lan::{guest::Guest, menu::Slots},
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
|
||||
@@ -408,9 +408,9 @@ impl Game<'_> {
|
||||
}
|
||||
*next += self.clock.tick_duration();
|
||||
if *next > now {
|
||||
// A `/pregen` task or force-loaded chunks keep the workers fed
|
||||
// until the next tick.
|
||||
self.pump_pregen(*next);
|
||||
// The chunks loading (the players', force-loaded ones, a
|
||||
// `/pregen` task) keep the workers fed until the next tick.
|
||||
self.pump_chunks(*next, paused);
|
||||
let now = Instant::now();
|
||||
if *next > now {
|
||||
std::thread::sleep(*next - now);
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! `thunderHit` (damage and burning, charged creepers, zombified piglins,
|
||||
//! witches, mooshroom colours, turtles, burnt items), lightning rods, the sky
|
||||
//! flash, the sounds and the branching additive bolt.
|
||||
use super::Game;
|
||||
use super::{Game, commands::Target};
|
||||
use horizon_client::advancements::EntityView;
|
||||
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
|
||||
@@ -18,6 +19,13 @@ pub(super) struct Bolt {
|
||||
visual_only: bool,
|
||||
/// Mooshrooms and copper golems change once per bolt.
|
||||
converted: Vec<u32>,
|
||||
/// The mobs it has struck (`hitEntities`).
|
||||
hit: Vec<u32>,
|
||||
/// Blocks it set on fire (`blocksSetOnFire`).
|
||||
fires: i32,
|
||||
/// The player who channelled it (`LightningBolt.cause`): `Some(None)` for
|
||||
/// the host, `Some(Some(conn))` for a guest.
|
||||
pub cause: Option<Option<u32>>,
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ServerChunkCache.tickSpawningChunk`: one `tickThunder` roll per
|
||||
@@ -29,10 +37,13 @@ impl Game<'_> {
|
||||
if rain <= 0.2 || thunder <= 0.9 || self.dim != super::Dim::Overworld {
|
||||
return;
|
||||
}
|
||||
let (_, mut chunks) = self.spawning_chunks(&self.level_players());
|
||||
let mut scratch = std::mem::take(&mut self.spawn_scratch);
|
||||
self.spawning_chunks(&self.level_players(), &mut scratch);
|
||||
let levels = self.tickets.levels();
|
||||
chunks.retain(|&(x, z)| levels.random_ticking(x, z));
|
||||
for (cx, cz) in chunks {
|
||||
scratch
|
||||
.candidates
|
||||
.retain(|&(x, z)| levels.random_ticking(x, z));
|
||||
for (cx, cz) in scratch.candidates.drain(..) {
|
||||
if self.world.random.next_int_bound(100_000).unwrap_or(1) != 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -77,6 +88,7 @@ impl Game<'_> {
|
||||
];
|
||||
self.spawn_bolt(at, trap);
|
||||
}
|
||||
self.spawn_scratch = scratch;
|
||||
}
|
||||
/// `ServerLevel.findLightningRod`: `PoiManager.findClosest` over the
|
||||
/// lightning rod points of interest within 128 blocks of `center` whose
|
||||
@@ -206,6 +218,9 @@ impl Game<'_> {
|
||||
seed,
|
||||
visual_only,
|
||||
converted: Vec::new(),
|
||||
hit: Vec::new(),
|
||||
fires: 0,
|
||||
cause: None,
|
||||
});
|
||||
}
|
||||
/// `ClientLevel.addEntity` for a server's lightning bolt: on a client it
|
||||
@@ -224,11 +239,16 @@ impl Game<'_> {
|
||||
seed,
|
||||
visual_only: true,
|
||||
converted: Vec::new(),
|
||||
hit: Vec::new(),
|
||||
fires: 0,
|
||||
cause: None,
|
||||
});
|
||||
}
|
||||
/// `spawnFire`: fire at the strike point and `extra` random neighbours,
|
||||
/// wherever `BaseFireBlock.getState` fire can survive.
|
||||
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
|
||||
/// wherever `BaseFireBlock.getState` fire can survive. Returns how many
|
||||
/// blocks it set on fire.
|
||||
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) -> i32 {
|
||||
let mut set = 0;
|
||||
let base = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
@@ -253,8 +273,10 @@ impl Game<'_> {
|
||||
});
|
||||
if let Some(fire) = fire {
|
||||
let _ = self.level_set(p, fire);
|
||||
set += 1;
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
/// `LightningBolt.tick` for every bolt.
|
||||
pub(super) fn tick_bolts(&mut self) {
|
||||
@@ -278,7 +300,7 @@ impl Game<'_> {
|
||||
if !b.visual_only {
|
||||
// Only Normal and Hard start fires with the strike itself.
|
||||
if self.ui.difficulty >= 2 {
|
||||
self.bolt_fire(b.at, 4);
|
||||
b.fires += self.bolt_fire(b.at, 4);
|
||||
}
|
||||
self.strike_rod(b.at);
|
||||
}
|
||||
@@ -293,6 +315,7 @@ impl Game<'_> {
|
||||
b.life -= 1;
|
||||
if b.life < 0 {
|
||||
if b.flashes == 0 {
|
||||
self.lightning_strike_triggers(b);
|
||||
return false;
|
||||
}
|
||||
if b.life < -self.world.random.next_int_bound(10).unwrap_or(0) {
|
||||
@@ -300,7 +323,7 @@ impl Game<'_> {
|
||||
b.life = 1;
|
||||
b.seed = self.world.random.next_long();
|
||||
if !b.visual_only {
|
||||
self.bolt_fire(b.at, 0);
|
||||
b.fires += self.bolt_fire(b.at, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,7 +333,11 @@ impl Game<'_> {
|
||||
}
|
||||
self.flash = 2;
|
||||
if !b.visual_only {
|
||||
self.thunder_hit(b);
|
||||
let struck = self.thunder_hit(b);
|
||||
if let Some(cause) = b.cause {
|
||||
let who = cause.map_or(Target::Player, Target::Guest);
|
||||
self.for_player(who, |g| g.trigger_channeled_lightning(&struck));
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -355,8 +382,41 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.lightning_clear_copper(level, ticks, p));
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
/// `thunderHit` for entities within 3 blocks (up to 9 above).
|
||||
fn thunder_hit(&mut self, b: &mut Bolt) {
|
||||
/// `LightningStrikeTrigger` at the bolt's discard: every player within 256
|
||||
/// blocks, with the mobs in its 30-block box that it did not hit.
|
||||
fn lightning_strike_triggers(&mut self, b: &Bolt) {
|
||||
let at = b.at;
|
||||
let bolt = EntityView {
|
||||
kind: "minecraft:lightning_bolt".into(),
|
||||
position: at,
|
||||
blocks_set_on_fire: b.fires,
|
||||
..Default::default()
|
||||
};
|
||||
let viewers: Vec<EntityView> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive() && !b.hit.contains(&m.id))
|
||||
.filter(|m| {
|
||||
(m.position.x - at[0]).abs() <= 15.0
|
||||
&& (m.position.z - at[2]).abs() <= 15.0
|
||||
&& m.position.y >= at[1] - 15.0
|
||||
&& m.position.y <= at[1] + 21.0
|
||||
})
|
||||
.map(|m| self.mob_entity(m))
|
||||
.collect();
|
||||
self.for_each_player(|g| {
|
||||
let Some(p) = g.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let d2 = (p.x - at[0]).powi(2) + (p.y - at[1]).powi(2) + (p.z - at[2]).powi(2);
|
||||
if d2 < 256.0 * 256.0 {
|
||||
g.trigger_lightning_strike(&bolt, &viewers);
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `thunderHit` for entities within 3 blocks (up to 9 above). Returns the
|
||||
/// mobs in that box before the hit, as `CHANNELED_LIGHTNING` counts them.
|
||||
fn thunder_hit(&mut self, b: &mut Bolt) -> Vec<EntityView> {
|
||||
let at = b.at;
|
||||
let inside = move |p: [f64; 3]| {
|
||||
(p[0] - at[0]).abs() <= 3.0
|
||||
@@ -364,6 +424,13 @@ impl Game<'_> {
|
||||
&& p[1] >= at[1] - 3.0
|
||||
&& p[1] <= at[1] + 9.0
|
||||
};
|
||||
let struck: Vec<(u32, EntityView)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive() && inside([m.position.x, m.position.y, m.position.z]))
|
||||
.map(|m| (m.id, self.mob_entity(m)))
|
||||
.collect();
|
||||
b.hit.extend(struck.iter().map(|(id, _)| *id));
|
||||
let damage = Damage {
|
||||
kind: DamageKind::Generic,
|
||||
attacker: None,
|
||||
@@ -461,6 +528,7 @@ impl Game<'_> {
|
||||
!inside([e.position.x, e.position.y, e.position.z])
|
||||
|| e.stack.item.contains("netherite")
|
||||
});
|
||||
struck.into_iter().map(|(_, view)| view).collect()
|
||||
}
|
||||
/// Simulation-order render inputs; seeded geometry lives in the renderer.
|
||||
pub(super) fn bolt_instances(&self) -> Vec<horizon_client::gfx::lightning::Bolt> {
|
||||
|
||||
@@ -595,6 +595,18 @@ impl Game<'_> {
|
||||
/// `HopperBlockEntity.pushItemsTick` of the hopper at `pos`, if its block
|
||||
/// is still a hopper (`BlockEntityType.isValid`).
|
||||
pub(super) fn tick_hopper(&mut self, pos: Pos) {
|
||||
let time = self.world.game_time;
|
||||
let waiting = {
|
||||
let state = self.hoppers.entry(pos).or_default();
|
||||
state.cooldown -= 1;
|
||||
state.ticked = time;
|
||||
state.cooldown > 0
|
||||
};
|
||||
if waiting {
|
||||
return;
|
||||
}
|
||||
// Most ticks only advance the cooldown: the block is checked when the
|
||||
// hopper is about to move, not on every tick it waits.
|
||||
let hopper = self
|
||||
.res
|
||||
.facts()
|
||||
@@ -606,14 +618,10 @@ impl Game<'_> {
|
||||
self.hoppers.remove(&pos);
|
||||
return;
|
||||
}
|
||||
let time = self.world.game_time;
|
||||
let state = self.hoppers.entry(pos).or_default();
|
||||
state.cooldown -= 1;
|
||||
state.ticked = time;
|
||||
if state.cooldown <= 0 {
|
||||
if let Some(state) = self.hoppers.get_mut(&pos) {
|
||||
state.cooldown = 0;
|
||||
self.hopper_try_move_items(pos);
|
||||
}
|
||||
self.hopper_try_move_items(pos);
|
||||
}
|
||||
/// Drops the cooldowns of hoppers whose block entity is gone.
|
||||
pub(super) fn forget_gone_hoppers(&mut self) {
|
||||
|
||||
@@ -739,6 +739,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let at = [x, y, z].map(|v| f64::from(v) + 0.5);
|
||||
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
|
||||
self.audio
|
||||
.play("minecraft:item.lodestone_compass.lock", Some(at), 1.0, 1.0);
|
||||
let target = Tag::compound([(
|
||||
|
||||
@@ -1459,6 +1459,7 @@ impl Game<'_> {
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|c| self.gui.atlas.glyph_cell(c),
|
||||
glyphs: self.gui.atlas.font.epoch(),
|
||||
};
|
||||
self.res
|
||||
.block_entity_render()
|
||||
|
||||
@@ -259,6 +259,7 @@ impl Game<'_> {
|
||||
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
|
||||
Species::Donkey => "minecraft:entity.donkey.eat",
|
||||
Species::Mule => "minecraft:entity.mule.eat",
|
||||
Species::Horse if m.is_baby() => "minecraft:entity.baby_horse.eat",
|
||||
_ => "minecraft:entity.horse.eat",
|
||||
};
|
||||
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
|
||||
@@ -337,7 +338,16 @@ impl Game<'_> {
|
||||
}
|
||||
// The nautilus plays its dash sound itself.
|
||||
if equine::is_equine(m.species()) {
|
||||
let sound = "minecraft:entity.horse.jump";
|
||||
// playJumpSound: Donkey and Mule have their own; a skeleton
|
||||
// horse in water splashes.
|
||||
let sound = match m.species() {
|
||||
Species::Donkey => "minecraft:entity.donkey.jump",
|
||||
Species::Mule => "minecraft:entity.mule.jump",
|
||||
Species::SkeletonHorse if m.fluids.in_water => {
|
||||
"minecraft:entity.skeleton_horse.jump_water"
|
||||
}
|
||||
_ => "minecraft:entity.horse.jump",
|
||||
};
|
||||
let at = m.center();
|
||||
self.audio.play(sound, Some(at), 0.4, 1.0);
|
||||
}
|
||||
|
||||
@@ -81,10 +81,13 @@ impl Game<'_> {
|
||||
let viewer = self.player_team();
|
||||
let mut tags = Vec::new();
|
||||
let players = self.other_players();
|
||||
let named = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter_map(|m| Some((m, m.custom_name.clone()?, false)));
|
||||
let named = self.mobs.iter().filter_map(|m| {
|
||||
let name = m.custom_name.as_ref()?;
|
||||
// The tag's 64 block distance is measured to the entity: copy the
|
||||
// name only for a mob the frame can show it on.
|
||||
let at = glam::DVec3::new(m.position.x, m.position.y, m.position.z);
|
||||
(at.distance_squared(camera) < 64.0 * 64.0).then(|| (m, name.clone(), false))
|
||||
});
|
||||
let players = players.into_iter().map(|(m, name)| (m, name, true));
|
||||
// Who is riding whom: asked once per tag below, and rescanning every mob
|
||||
// for each tag made this quadratic in the number of loaded mobs.
|
||||
@@ -328,7 +331,15 @@ impl Game<'_> {
|
||||
) {
|
||||
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
|
||||
const BACKGROUND: u32 = 0x3F00_0000;
|
||||
let forward = glam::DVec3::from_array(view_vector(self.yaw, self.pitch));
|
||||
// The camera's facing (Camera.rotation()), turned round in third-person
|
||||
// front view, so the text reads the right way from where it is seen.
|
||||
let (view_yaw, view_pitch) =
|
||||
if self.camera_type == super::perspective::CameraType::ThirdPersonFront {
|
||||
(self.yaw + 180.0, -self.pitch)
|
||||
} else {
|
||||
(self.yaw, self.pitch)
|
||||
};
|
||||
let forward = glam::DVec3::from_array(view_vector(view_yaw, view_pitch));
|
||||
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
|
||||
let up = right.cross(forward).normalize_or_zero();
|
||||
let white = self.gui.atlas.font.white();
|
||||
|
||||
@@ -24,6 +24,15 @@ use horizon_core::{
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Reused buffers for one pass over the spawning chunks: the candidate chunks
|
||||
/// and the set that keeps each one once. The buffers keep their capacity
|
||||
/// between ticks.
|
||||
#[derive(Default)]
|
||||
pub(super) struct SpawnScratch {
|
||||
pub(super) candidates: Vec<(i32, i32)>,
|
||||
pub(super) seen: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
|
||||
}
|
||||
|
||||
/// A player in the level in place, as spawning and despawning see it.
|
||||
pub(super) struct LevelPlayer {
|
||||
pub(super) position: Vec3,
|
||||
@@ -445,9 +454,17 @@ impl<'a> Game<'a> {
|
||||
/// chunks (within 8 chunks of a player, `getSpawnCandidateChunks`) that
|
||||
/// are ticking and within 128 blocks of a non-spectator. Also returns how
|
||||
/// many chunks the tracker holds (`getNaturalSpawnChunkCount`).
|
||||
pub(super) fn spawning_chunks(&self, players: &[LevelPlayer]) -> (i32, Vec<(i32, i32)>) {
|
||||
let mut candidates: Vec<(i32, i32)> = Vec::with_capacity(289 * players.len());
|
||||
let mut seen = horizon_client::fast_hash::FxHashSet::default();
|
||||
pub(super) fn spawning_chunks(
|
||||
&self,
|
||||
players: &[LevelPlayer],
|
||||
scratch: &mut SpawnScratch,
|
||||
) -> i32 {
|
||||
let candidates = &mut scratch.candidates;
|
||||
candidates.clear();
|
||||
candidates.reserve(289 * players.len());
|
||||
let seen = &mut scratch.seen;
|
||||
seen.clear();
|
||||
seen.reserve(289 * players.len());
|
||||
for p in players {
|
||||
let (pcx, pcz) = (
|
||||
p.position.x.floor() as i32 >> 4,
|
||||
@@ -471,7 +488,7 @@ impl<'a> Game<'a> {
|
||||
.iter()
|
||||
.any(|p| close_enough_for_spawning(p, (cx, cz)))
|
||||
});
|
||||
(count, candidates)
|
||||
count
|
||||
}
|
||||
/// `ServerChunkCache.tickChunks`: the spawn state over the loaded mobs,
|
||||
/// the categories under their global caps (creatures every 400 ticks),
|
||||
@@ -482,8 +499,9 @@ impl<'a> Game<'a> {
|
||||
if players.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (spawnable, mut chunks) = self.spawning_chunks(&players);
|
||||
for &key in &chunks {
|
||||
let mut scratch = std::mem::take(&mut self.spawn_scratch);
|
||||
let spawnable = self.spawning_chunks(&players, &mut scratch);
|
||||
for &key in &scratch.candidates {
|
||||
*self.world.inhabited.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
let spawn_mobs = self.ui.rules.bool("spawn_mobs");
|
||||
@@ -524,12 +542,12 @@ impl<'a> Game<'a> {
|
||||
Vec::new()
|
||||
};
|
||||
// Util.shuffle.
|
||||
for i in (1..chunks.len()).rev() {
|
||||
for i in (1..scratch.candidates.len()).rev() {
|
||||
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
chunks.swap(i, j);
|
||||
scratch.candidates.swap(i, j);
|
||||
}
|
||||
if !categories.is_empty() {
|
||||
for chunk in chunks {
|
||||
for chunk in scratch.candidates.drain(..) {
|
||||
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
|
||||
let spawned = l.game.finalize_spawned(r, &mut pending);
|
||||
if let Some(s) = &spawned {
|
||||
@@ -551,6 +569,7 @@ impl<'a> Game<'a> {
|
||||
}
|
||||
self.world.random = random;
|
||||
self.add_spawned(pending, false);
|
||||
self.spawn_scratch = scratch;
|
||||
}
|
||||
/// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a
|
||||
/// freshly generated chunk, from the biome at its corner column's top,
|
||||
|
||||
@@ -273,6 +273,7 @@ impl Game<'_> {
|
||||
s.horizon.hdr,
|
||||
s.horizon.hdr_paper_white as f32,
|
||||
s.horizon.hdr_peak as f32,
|
||||
&Self::options_path().with_file_name(horizon_client::graphics::HDR_MARKER),
|
||||
);
|
||||
}
|
||||
// Window.updateFullscreenIfChanged: only a changed option asks the
|
||||
|
||||
@@ -45,6 +45,14 @@ pub fn open_folder(dir: &Path) {
|
||||
}
|
||||
/// `Util.getPlatform().openUri`: the system browser on the link.
|
||||
pub fn open_uri(uri: &str) {
|
||||
// URLs need Windows' registered protocol handler, not Explorer's path
|
||||
// handling. ShellExecute also preserves the URL as one Unicode value,
|
||||
// without interpolating its query string into a command interpreter.
|
||||
#[cfg(windows)]
|
||||
if let Err(error) = open::that_detached(uri) {
|
||||
log::warn!("Horizon: could not open URI {uri}: {error}");
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
open_folder(Path::new(uri));
|
||||
}
|
||||
/// The resource root to load from, given the vanilla root, the selection and
|
||||
|
||||
@@ -960,7 +960,8 @@ impl Game<'_> {
|
||||
let amount =
|
||||
|base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32;
|
||||
if (*id == "instant_damage") != inverted {
|
||||
m.hurt(amount(6), Some(at), &mut self.world.random);
|
||||
let harm = m.potion_harm(amount(6));
|
||||
m.hurt(harm, Some(at), &mut self.world.random);
|
||||
} else {
|
||||
m.health = (m.health + amount(4)).min(m.kind.max_health);
|
||||
}
|
||||
@@ -1228,11 +1229,8 @@ impl Game<'_> {
|
||||
};
|
||||
let m = &mut self.mobs[i];
|
||||
if harm {
|
||||
m.hurt(
|
||||
(intensity * f64::from(6 << amp) + 0.5) as i32 as f32,
|
||||
Some([at.x, at.y, at.z]),
|
||||
&mut self.world.random,
|
||||
);
|
||||
let harm = m.potion_harm((intensity * f64::from(6 << amp) + 0.5) as i32 as f32);
|
||||
m.hurt(harm, Some([at.x, at.y, at.z]), &mut self.world.random);
|
||||
} else {
|
||||
m.health = (m.health + (intensity * f64::from(4 << amp) + 0.5) as i32 as f32)
|
||||
.min(m.kind.max_health);
|
||||
|
||||
@@ -700,7 +700,6 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let text = content.plain();
|
||||
log::info!("[chat] {text}");
|
||||
// addServerSystemMessage and addClientSystemMessage both carry
|
||||
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
|
||||
let indicator = (source != horizon_client::chat::ChatSource::Player)
|
||||
|
||||
@@ -623,6 +623,18 @@ impl Game<'_> {
|
||||
SculkKind::Shrieker => sculk::shrieker_can_listen(event),
|
||||
_ => sculk::is_vibration(event),
|
||||
};
|
||||
// VibrationSystem.User.isValidVibration: a player sneaking past a
|
||||
// sensor, whose step it ignores, is AVOID_VIBRATION (sensors only;
|
||||
// a shrieker cannot trigger it).
|
||||
if listens
|
||||
&& matches!(kind, SculkKind::Sensor | SculkKind::Calibrated)
|
||||
&& ctx.source == Some(Who::Player)
|
||||
&& !ctx.spectator
|
||||
&& ctx.stepping_carefully
|
||||
&& sculk::ignored_when_sneaking(event)
|
||||
{
|
||||
self.trigger_player("minecraft:avoid_vibration");
|
||||
}
|
||||
if !sculk::valid_vibration(listens, event, ctx) {
|
||||
return;
|
||||
}
|
||||
@@ -693,12 +705,15 @@ impl Game<'_> {
|
||||
fn warden_listen(&mut self, index: usize, event: &'static str, pos: [f64; 3], ctx: &Context) {
|
||||
let now = self.world.game_time;
|
||||
let (occludes, world) = (&self.sculk.tables.occludes, &self.world);
|
||||
warden::listen(&mut self.mobs[index], now, event, pos, ctx, |a, b| {
|
||||
let avoided = warden::listen(&mut self.mobs[index], now, event, pos, ctx, |a, b| {
|
||||
sculk::is_occluded(a, b, |p| {
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
occludes.get(state.0 as usize).copied().unwrap_or(false)
|
||||
})
|
||||
});
|
||||
if avoided {
|
||||
self.trigger_player("minecraft:avoid_vibration");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ticking ----
|
||||
@@ -1577,6 +1592,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `RedStoneOreBlock.useItemOn`: a right-click lights the ore whatever the
|
||||
/// hand holds. False (PASS) when the hand holds a block item, which goes on
|
||||
/// to be placed; true (SUCCESS) otherwise.
|
||||
pub(super) fn use_redstone_ore(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&horizon_client::items::ItemStack>,
|
||||
) -> Result<bool> {
|
||||
if !matches!(
|
||||
&*self.block_name(pos),
|
||||
"minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore"
|
||||
) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.attack_block(pos)?;
|
||||
let block_item = held.is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.block.is_some())
|
||||
});
|
||||
Ok(!block_item)
|
||||
}
|
||||
|
||||
fn tick_mob_sculk_events(&mut self) {
|
||||
let snapshot: Vec<(u32, [f64; 3], bool, bool, Species, Option<i64>)> = self
|
||||
.mobs
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
Mob, Who,
|
||||
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerSpawn},
|
||||
spawning,
|
||||
trial_spawner::TrialState,
|
||||
},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
@@ -64,14 +65,13 @@ impl Game<'_> {
|
||||
.unwrap_or_default();
|
||||
self.spawners.entity_types = Some(types);
|
||||
}
|
||||
/// The server tick of the spawner at `key`, if its block is still a
|
||||
/// spawner. Its client tick comes after every block entity's
|
||||
/// (`client_tick_spawners`).
|
||||
/// The server tick of the spawner at `key`. Its client tick comes after
|
||||
/// every block entity's (`client_tick_spawners`).
|
||||
///
|
||||
/// The caller only reaches this for a `BlockEntity::Spawner`, and changing
|
||||
/// the block removes or replaces that entity, so the block is not read
|
||||
/// again here.
|
||||
pub(super) fn tick_spawner(&mut self, key: Pos) -> Result<()> {
|
||||
let state = self.world.block(key.0, key.1, key.2);
|
||||
if &*self.res.facts().get(state).name != "minecraft:spawner" {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(BlockEntity::Spawner(mut spawner)) = self.world.block_entities.take(&key) else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -373,12 +373,11 @@ impl Game<'_> {
|
||||
/// clicked block has no spawner.
|
||||
pub(super) fn use_spawn_egg_on_spawner(&mut self, pos: [i32; 3], kind: &str) -> Option<bool> {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Spawner(_))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let trial = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Spawner(_)) => false,
|
||||
Some(BlockEntity::TrialSpawner(_)) => true,
|
||||
_ => return None,
|
||||
};
|
||||
if !self.ui.rules.bool("spawner_blocks_work") {
|
||||
self.system_message(horizon_client::text::Text::translate(
|
||||
"advMode.notEnabled.spawner",
|
||||
@@ -386,16 +385,26 @@ impl Game<'_> {
|
||||
));
|
||||
return Some(false);
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let mut random = std::mem::replace(
|
||||
&mut self.world.random,
|
||||
horizon_core::random::LegacyRandom::new(0),
|
||||
);
|
||||
if let Some(BlockEntity::Spawner(s)) = self.world.block_entities.get_mut(&key) {
|
||||
s.set_entity_id(kind, &mut random, &mut events);
|
||||
if trial {
|
||||
// TrialSpawnerBlockEntity.setEntityId: overrideEntityToSpawn, which
|
||||
// ends in setState(INACTIVE).
|
||||
if let Some(BlockEntity::TrialSpawner(t)) = self.world.block_entities.get_mut(&key) {
|
||||
t.override_entity(kind);
|
||||
}
|
||||
let ominous = self.trial_spawner_block(key).is_some_and(|(_, o)| o);
|
||||
let _ = self.set_trial_spawner_state(key, TrialState::Inactive, ominous);
|
||||
} else {
|
||||
let mut events = Vec::new();
|
||||
let mut random = std::mem::replace(
|
||||
&mut self.world.random,
|
||||
horizon_core::random::LegacyRandom::new(0),
|
||||
);
|
||||
if let Some(BlockEntity::Spawner(s)) = self.world.block_entities.get_mut(&key) {
|
||||
s.set_entity_id(kind, &mut random, &mut events);
|
||||
}
|
||||
self.world.random = random;
|
||||
let _ = self.apply_spawner_events(key, events, &HashMap::new());
|
||||
}
|
||||
self.world.random = random;
|
||||
let _ = self.apply_spawner_events(key, events, &HashMap::new());
|
||||
// sendBlockUpdated(3) and the player's block_change game event.
|
||||
self.spawner_block_updated(key);
|
||||
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
|
||||
|
||||
@@ -44,8 +44,16 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// `ServerPlayer.getKnownMovement` of the acting player: what they moved
|
||||
/// in the last tick. A guest's comes with their moves.
|
||||
/// in the last tick. A guest's comes with their moves. A rider's is its
|
||||
/// mount's move (`getKnownSpeed`: the vehicle's, or the move the driver's
|
||||
/// client sent for it), so a charge on horseback carries the horse's speed.
|
||||
fn known_player_movement(&self) -> Vec3 {
|
||||
if let Some(i) = self
|
||||
.riding
|
||||
.and_then(|v| self.mobs.iter().position(|m| m.id == v))
|
||||
{
|
||||
return self.mob_speed(i).scale(1.0 / 20.0);
|
||||
}
|
||||
if self.lan_acting.is_some() {
|
||||
return self.known_movement;
|
||||
}
|
||||
@@ -193,6 +201,9 @@ impl Game<'_> {
|
||||
}
|
||||
let who = actor.map_or(OWN, |conn| conn as i32);
|
||||
self.kinetic_hit_feedback(who, &kinetic, [feet.x, feet.y, feet.z]);
|
||||
// SPEAR_MOBS_TRIGGER: the living entities stabbed in this use.
|
||||
let stabbed = self.stabbed.keys().filter(|(who, _)| *who == actor).count();
|
||||
self.trigger_spear_mobs(stabbed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +250,8 @@ impl Game<'_> {
|
||||
self.mobs[index].knockback(0.4, xd, zd);
|
||||
let (strength, _) = self.extra_knockback(&kind, 0.0);
|
||||
self.mobs[index].knockback(f64::from(strength), xd, zd);
|
||||
// ...and the attacker stops sprinting.
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
let dismounted = dismounts && self.mobs[index].extra.mount.is_some();
|
||||
if dismounted {
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::{Game, GameLevel, Screen, dispenser::new_item_motion, storage::is_shu
|
||||
use horizon_client::{
|
||||
block_entity_item, block_entity_nbt,
|
||||
containers::{Banner, BlockEntity},
|
||||
furnace::FurnaceCooking,
|
||||
item_data,
|
||||
items::ItemStack,
|
||||
save,
|
||||
|
||||
@@ -1643,7 +1643,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let stack = self.slot_mut(i).clone();
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
|
||||
}
|
||||
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
|
||||
let (sx, sy) = self.slot_position(slot);
|
||||
|
||||
@@ -137,7 +137,9 @@ impl Game<'_> {
|
||||
horizon_world::nbt::Tag::Int(horizon_world::DATA_VERSION),
|
||||
),
|
||||
]);
|
||||
let _ = horizon_client::save::write_dat(&self.random_sequences_path(), tag);
|
||||
if let Err(e) = horizon_client::save::write_dat(&self.random_sequences_path(), tag) {
|
||||
log::warn!("the villager random sequences were not written: {e}");
|
||||
}
|
||||
}
|
||||
/// `AbstractVillager.addOffersFromTradeSet` with the world's random
|
||||
/// sequence of the set, at the merchant's position.
|
||||
@@ -783,7 +785,7 @@ impl Game<'_> {
|
||||
for i in slots {
|
||||
let (sx, sy) = self.slot_position(i);
|
||||
let stack = self.slot_mut(i).clone();
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
|
||||
}
|
||||
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
|
||||
let (sx, sy) = self.slot_position(slot);
|
||||
|
||||
@@ -272,7 +272,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// `setState` (and `removeOminous`): the block's state and ominous flag.
|
||||
fn set_trial_spawner_state(
|
||||
pub(super) fn set_trial_spawner_state(
|
||||
&mut self,
|
||||
key: Pos,
|
||||
state: TrialState,
|
||||
|
||||
@@ -225,7 +225,7 @@ impl Game<'_> {
|
||||
/// The `summon_entity` and `play_sound` effects of `channeling.json`: in a
|
||||
/// thunderstorm (`weather_check`) a spot that sees the sky (`can_see_sky`)
|
||||
/// gets a lightning bolt and the thunder sound.
|
||||
fn channel_lightning(&mut self, at: [f64; 3]) {
|
||||
fn channel_lightning(&mut self, at: [f64; 3], guest: Option<u32>) {
|
||||
// Level.isThundering: the thunder level, in dimensions with weather.
|
||||
if self.dim != super::Dim::Overworld || self.world.weather.levels(1.0).1 <= 0.9 {
|
||||
return;
|
||||
@@ -240,13 +240,17 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.spawn_bolt(at, false);
|
||||
// The summon effect's owner is the thrower: LightningBolt.setCause.
|
||||
if let Some(bolt) = self.bolts.last_mut() {
|
||||
bolt.cause = Some(guest);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:item.trident.thunder", Some(at), 5.0, 1.0);
|
||||
}
|
||||
/// `minecraft:hit_block` of Channeling (`AbstractArrow.hitBlockEnchantmentEffects`):
|
||||
/// the block the trident's path first hits, if it is a lightning rod, with
|
||||
/// the hit location clamped into the block (`BlockPos.clampLocationWithin`).
|
||||
fn channel_rod_hit(&mut self, from: Vec3, velocity: Vec3) {
|
||||
fn channel_rod_hit(&mut self, from: Vec3, velocity: Vec3, guest: Option<u32>) {
|
||||
let to = Vec3::new(
|
||||
from.x + velocity.x,
|
||||
from.y + velocity.y,
|
||||
@@ -279,11 +283,14 @@ impl Game<'_> {
|
||||
let edge = f64::from(1.0e-5f32);
|
||||
let clamp =
|
||||
|v: f64, origin: i32| v.clamp(f64::from(origin) + edge, f64::from(origin) + 1.0 - edge);
|
||||
self.channel_lightning([
|
||||
clamp(hit.location.x, hit.block.x),
|
||||
clamp(hit.location.y, hit.block.y),
|
||||
clamp(hit.location.z, hit.block.z),
|
||||
]);
|
||||
self.channel_lightning(
|
||||
[
|
||||
clamp(hit.location.x, hit.block.x),
|
||||
clamp(hit.location.y, hit.block.y),
|
||||
clamp(hit.location.z, hit.block.z),
|
||||
],
|
||||
guest,
|
||||
);
|
||||
}
|
||||
/// `TridentItem.releaseUsing`.
|
||||
pub(super) fn release_trident(&mut self, ticks: u32) {
|
||||
@@ -514,7 +521,7 @@ impl Game<'_> {
|
||||
if let Some(at) = hurt_at
|
||||
&& t.stack.enchantment_level("minecraft:channeling") > 0
|
||||
{
|
||||
self.channel_lightning(at);
|
||||
self.channel_lightning(at, t.guest);
|
||||
}
|
||||
if hurt_at.is_some()
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
@@ -558,7 +565,7 @@ impl Game<'_> {
|
||||
);
|
||||
if blocked {
|
||||
if t.stack.enchantment_level("minecraft:channeling") > 0 {
|
||||
self.channel_rod_hit(from, flight);
|
||||
self.channel_rod_hit(from, flight, t.guest);
|
||||
}
|
||||
t.in_ground = true;
|
||||
t.velocity = Vec3::ZERO;
|
||||
|
||||
@@ -240,6 +240,67 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `NearestItemSensor.doTick` for the villagers whose sensor is due: the
|
||||
/// nearest dropped item within 32 blocks that `Villager.wantsToPickUp`
|
||||
/// accepts and the villager sees, or none.
|
||||
pub(super) fn villager_item_sense(&mut self) {
|
||||
if self.dropped.is_empty() {
|
||||
for m in &mut self.mobs {
|
||||
if m.species() == Species::Villager && villager_brain::take_item_scan(m) {
|
||||
villager_brain::set_wanted_item(m, None);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let picks_up = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:villager_picks_up")
|
||||
.cloned();
|
||||
let mut due = Vec::new();
|
||||
for (i, m) in self.mobs.iter_mut().enumerate() {
|
||||
if m.species() == Species::Villager && villager_brain::take_item_scan(m) {
|
||||
due.push(i);
|
||||
}
|
||||
}
|
||||
if due.is_empty() {
|
||||
return;
|
||||
}
|
||||
let w = self.mob_world_base(&[]);
|
||||
let mut found = Vec::new();
|
||||
for i in due {
|
||||
let m = &self.mobs[i];
|
||||
let area = m.bounds().inflate(Vec3::new(32.0, 16.0, 32.0));
|
||||
let requested = villager::requested_items(m);
|
||||
let eye = Vec3::new(m.position.x, m.eye_y(), m.position.z);
|
||||
let mut best: Option<(f64, Vec3)> = None;
|
||||
for e in &self.dropped {
|
||||
let d = e.position.distance_squared(m.position);
|
||||
if best.is_some_and(|(b, _)| d >= b)
|
||||
|| !e.bounds().intersects(area)
|
||||
|| d >= 32.0 * 32.0
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let item = &e.stack.item;
|
||||
let wants = villager::food_points(item) > 0
|
||||
|| picks_up.as_ref().is_some_and(|t| t.contains(&**item))
|
||||
|| requested.contains(&&**item);
|
||||
let fits =
|
||||
villager::ext(m).is_none_or(|x| x.can_add(&e.stack, self.item_max_stack(item)));
|
||||
// ItemEntity's eye height is 0.2125.
|
||||
let at = Vec3::new(e.position.x, e.position.y + 0.2125, e.position.z);
|
||||
if wants && fits && w.line_of_sight(eye, at) {
|
||||
best = Some((d, e.position));
|
||||
}
|
||||
}
|
||||
found.push((i, best.map(|(_, p)| [p.x, p.y, p.z])));
|
||||
}
|
||||
for (i, at) in found {
|
||||
villager_brain::set_wanted_item(&mut self.mobs[i], at);
|
||||
}
|
||||
}
|
||||
/// `Mob.aiStep` item pickup for villagers (`InventoryCarrier`): food,
|
||||
/// seeds and what their profession wants, from within a block.
|
||||
pub(super) fn villager_pickups(&mut self) {
|
||||
|
||||
@@ -121,6 +121,13 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// An ender dragon part takes the point of damage too; a crystal is
|
||||
// not hit (`AbstractWindCharge.canHitEntity`).
|
||||
Who::Mob(id) if id >= super::dragon::SPECIAL => {
|
||||
if id < super::dragon::SPECIAL + 8 {
|
||||
self.hit_special(id, 1.0, p.owner == Some(Who::Player), false);
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
let source = Damage {
|
||||
kind: DamageKind::Thrown,
|
||||
@@ -570,11 +577,15 @@ impl Game<'_> {
|
||||
poses.yrot("wind", age * 16.0_f32.to_radians());
|
||||
let base = glam::Mat4::from_translation(at)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
|
||||
horizon_client::mobs::render::draw(
|
||||
horizon_client::mobs::render::draw_scrolled(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
horizon_client::mobs::render::scroll_offset(
|
||||
age,
|
||||
horizon_client::mobs::render::WIND_CHARGE_SCROLL,
|
||||
),
|
||||
0xFFFF_FFFF,
|
||||
light,
|
||||
0,
|
||||
|
||||
@@ -120,7 +120,8 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// `WitherSkull.onHitEntity` / `onHit`: 8 damage (healing the wither 5
|
||||
/// when it kills), withering for 10 seconds, then a power 1 blast.
|
||||
/// when it kills), withering on a hit (10 seconds on Normal, 40 on Hard,
|
||||
/// none on Easy), then a power 1 blast.
|
||||
pub(super) fn wither_skull_hit(
|
||||
&mut self,
|
||||
p: &Projectile,
|
||||
@@ -132,17 +133,24 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
};
|
||||
let amount = if owner.is_some() { 8.0 } else { 5.0 };
|
||||
let wither_ticks = match self.difficulty_id() {
|
||||
2 => 200,
|
||||
3 => 800,
|
||||
_ => 0,
|
||||
};
|
||||
match who {
|
||||
Some(Who::Player) => {
|
||||
if !self.creative {
|
||||
// WitherSkull.onHitEntity's source: the wither launched it.
|
||||
self.combat.projectile_owner = owner;
|
||||
self.mob_hits_player_kind(
|
||||
let hit = self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:wither_skull",
|
||||
);
|
||||
self.give_player_effect("minecraft:wither", 200, 1);
|
||||
if hit && wither_ticks > 0 {
|
||||
self.give_player_effect("minecraft:wither", wither_ticks, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Who::Mob(id)) => {
|
||||
@@ -154,8 +162,8 @@ impl Game<'_> {
|
||||
let killed = match self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
Some(m) => {
|
||||
let hit = m.damage(amount, source);
|
||||
if hit && m.alive() && m.species() != Species::Wither {
|
||||
m.add_effect("minecraft:wither", 200, 1);
|
||||
if hit && m.alive() && m.species() != Species::Wither && wither_ticks > 0 {
|
||||
m.add_effect("minecraft:wither", wither_ticks, 1);
|
||||
}
|
||||
hit && !m.alive()
|
||||
}
|
||||
|
||||
@@ -246,10 +246,14 @@ pub(super) fn preload_world_data(
|
||||
.spawn(move || {
|
||||
horizon_threads::behind_server();
|
||||
// Errors are left for the world opening to report.
|
||||
let _ = horizon_client::vanillagen::shared_data(
|
||||
if let Ok(data) = horizon_client::vanillagen::shared_data(
|
||||
&resources.join("worldgen"),
|
||||
registry,
|
||||
);
|
||||
) {
|
||||
// The Overworld's feature decoration, which every
|
||||
// seed shares: about 140 ms off opening the world.
|
||||
let _ = data.prepare_decoration("minecraft:normal", "minecraft:overworld");
|
||||
}
|
||||
let _ = horizon_client::structure_blocks::loot_data(&resources);
|
||||
let _ = super::lan::sync_data(&resources);
|
||||
});
|
||||
|
||||
@@ -539,6 +539,9 @@ pub struct Renderer {
|
||||
hdr: Option<hdr::Hdr>,
|
||||
/// The HDR output the window's surface can present.
|
||||
hdr_output: Option<hdr::Output>,
|
||||
/// The marker file of an HDR output that has not presented a frame yet
|
||||
/// (`graphics::HDR_MARKER`).
|
||||
hdr_marker: Option<std::path::PathBuf>,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
@@ -1966,7 +1969,7 @@ impl Renderer {
|
||||
// ulps covers that spread at any resolution while staying far below
|
||||
// the 0.001 a model's feet sit higher, so shadows never bleed onto
|
||||
// the feet.
|
||||
let shadow_blob_pipeline = entity_pipeline(
|
||||
let shadow_blob_pipeline = entity_pipeline_with(
|
||||
"Entity shadows",
|
||||
None,
|
||||
false,
|
||||
@@ -1978,6 +1981,8 @@ impl Renderer {
|
||||
},
|
||||
alpha_blend,
|
||||
false,
|
||||
"fs_entity_shadow",
|
||||
wgpu::ColorWrites::ALL,
|
||||
);
|
||||
// RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested. Drawn before
|
||||
// translucent terrain, it cannot write depth: the water behind a
|
||||
@@ -2644,6 +2649,7 @@ impl Renderer {
|
||||
world_post,
|
||||
hdr: None,
|
||||
hdr_output,
|
||||
hdr_marker: None,
|
||||
pack_post: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
@@ -3202,9 +3208,17 @@ impl Renderer {
|
||||
}
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) {
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32, marker: &std::path::Path) {
|
||||
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
|
||||
if output.is_some() != self.hdr.is_some() {
|
||||
// If the game dies before HDR output shows a frame, the marker is still there at
|
||||
// the next start, which then comes up with HDR off.
|
||||
if output.is_some() {
|
||||
let _ = std::fs::write(marker, "HDR output was being switched on\n");
|
||||
self.hdr_marker = Some(marker.to_owned());
|
||||
} else if let Some(marker) = self.hdr_marker.take() {
|
||||
let _ = std::fs::remove_file(marker);
|
||||
}
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.configure_surface();
|
||||
@@ -5231,6 +5245,12 @@ impl Renderer {
|
||||
startup_step(format_args!("presenting the first frame"));
|
||||
}
|
||||
self.queue.present(surface_frame);
|
||||
if self.hdr.is_some()
|
||||
&& let Some(marker) = self.hdr_marker.take()
|
||||
{
|
||||
let _ = std::fs::remove_file(marker);
|
||||
log::info!("Horizon renderer: HDR output presented its first frame");
|
||||
}
|
||||
if reconfigure && let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
&self.device,
|
||||
|
||||
@@ -25,12 +25,26 @@ pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
|
||||
/// A translucent item with an enchantment glint.
|
||||
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
|
||||
|
||||
/// The material of a quad by its overlay (`MATERIALS` order).
|
||||
fn material(layer: u32) -> usize {
|
||||
match layer {
|
||||
11 => 1,
|
||||
12 => 2,
|
||||
WATER_MASK_OVERLAY => 3,
|
||||
ENERGY_SWIRL_OVERLAY => 4,
|
||||
SHADOW_OVERLAY => 5,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 6,
|
||||
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EntityBatch {
|
||||
// Quad indices avoid copying vertices twice while grouping materials.
|
||||
// Keep initialized high-water storage; local counts delimit active entries.
|
||||
// Each bucket can hold all quads, removing per-quad Vec growth checks.
|
||||
buckets: [Vec<usize>; MATERIALS],
|
||||
// Runs of consecutive quads of one material (first quad, quad count): a
|
||||
// model's quads share one, so whole runs are copied at once. Cleared each
|
||||
// frame, keeping their storage.
|
||||
runs: [Vec<(usize, usize)>; MATERIALS],
|
||||
pub(super) combined: Vec<Vertex>,
|
||||
}
|
||||
|
||||
@@ -43,50 +57,29 @@ impl EntityBatch {
|
||||
see_through: &[Vertex],
|
||||
) -> [u32; MATERIALS] {
|
||||
let quads = entities.as_chunks::<4>().0;
|
||||
for bucket in &mut self.buckets {
|
||||
if bucket.len() < quads.len() {
|
||||
bucket.resize(quads.len(), 0);
|
||||
}
|
||||
for runs in &mut self.runs {
|
||||
runs.clear();
|
||||
}
|
||||
let mut counts = [0usize; MATERIALS];
|
||||
for (index, quad) in quads.iter().enumerate() {
|
||||
let material = match quad[0].layer {
|
||||
11 => 1,
|
||||
12 => 2,
|
||||
WATER_MASK_OVERLAY => 3,
|
||||
ENERGY_SWIRL_OVERLAY => 4,
|
||||
SHADOW_OVERLAY => 5,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 6,
|
||||
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
|
||||
_ => 0,
|
||||
};
|
||||
self.buckets[material][counts[material]] = index;
|
||||
counts[material] += 1;
|
||||
let mut start = 0;
|
||||
while start < quads.len() {
|
||||
let kind = material(quads[start][0].layer);
|
||||
let end = quads[start + 1..]
|
||||
.iter()
|
||||
.position(|quad| material(quad[0].layer) != kind)
|
||||
.map_or(quads.len(), |n| start + 1 + n);
|
||||
self.runs[kind].push((start, end - start));
|
||||
counts[kind] += end - start;
|
||||
start = end;
|
||||
}
|
||||
let entity_vertices = quads.len() * 4;
|
||||
self.combined.reserve(
|
||||
(entity_vertices + additive.len() + hand.len() + see_through.len())
|
||||
.saturating_sub(self.combined.len()),
|
||||
);
|
||||
self.combined.clear();
|
||||
self.combined
|
||||
.resize(entity_vertices, bytemuck::Zeroable::zeroed());
|
||||
if counts.contains(&quads.len()) {
|
||||
// Uniform materials are already ordered; copy the whole batch.
|
||||
self.combined.copy_from_slice(&entities[..entity_vertices]);
|
||||
} else {
|
||||
let mut offset = 0;
|
||||
for (bucket, &count) in self.buckets.iter().zip(&counts) {
|
||||
let bucket = &bucket[..count];
|
||||
let end = offset + count * 4;
|
||||
for (output, &index) in self.combined[offset..end]
|
||||
.as_chunks_mut::<4>()
|
||||
.0
|
||||
.iter_mut()
|
||||
.zip(bucket)
|
||||
{
|
||||
*output = quads[index];
|
||||
}
|
||||
offset = end;
|
||||
.reserve(entity_vertices + additive.len() + hand.len() + see_through.len());
|
||||
for runs in &self.runs {
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
}
|
||||
}
|
||||
self.combined.extend_from_slice(additive);
|
||||
@@ -174,12 +167,12 @@ mod tests {
|
||||
let hand = geometry(2, false);
|
||||
// Warm every bucket and the combined buffer to their maximum sizes.
|
||||
equivalent(&mut batch, &entities, &additive, &hand);
|
||||
let capacities = batch.buckets.each_ref().map(|b| b.capacity());
|
||||
let capacities = batch.runs.each_ref().map(|b| b.capacity());
|
||||
let capacity = batch.combined.capacity();
|
||||
let pointer = batch.combined.as_ptr();
|
||||
for length in [0, 1, 3, 4, 511, entities.len(), 17, 0, entities.len()] {
|
||||
equivalent(&mut batch, &entities[..length], &additive[..5], &hand[..3]);
|
||||
assert_eq!(batch.buckets.each_ref().map(|b| b.capacity()), capacities);
|
||||
assert_eq!(batch.runs.each_ref().map(|b| b.capacity()), capacities);
|
||||
assert_eq!(batch.combined.capacity(), capacity);
|
||||
assert_eq!(batch.combined.as_ptr(), pointer);
|
||||
}
|
||||
@@ -188,11 +181,11 @@ mod tests {
|
||||
// Grow again, then confirm empty/small frames retain the new storage.
|
||||
let larger = geometry(8192, true);
|
||||
equivalent(&mut batch, &larger, &additive, &hand);
|
||||
let capacities = batch.buckets.each_ref().map(|b| b.capacity());
|
||||
let capacities = batch.runs.each_ref().map(|b| b.capacity());
|
||||
let capacity = batch.combined.capacity();
|
||||
for entities in [&larger[..7], &larger[..], &[]] {
|
||||
equivalent(&mut batch, entities, &[], &[]);
|
||||
assert_eq!(batch.buckets.each_ref().map(|b| b.capacity()), capacities);
|
||||
assert_eq!(batch.runs.each_ref().map(|b| b.capacity()), capacities);
|
||||
assert_eq!(batch.combined.capacity(), capacity);
|
||||
}
|
||||
}
|
||||
@@ -255,9 +248,9 @@ mod tests {
|
||||
before[7] as f64 / after[7] as f64,
|
||||
batch.combined.capacity() * std::mem::size_of::<Vertex>()
|
||||
+ batch
|
||||
.buckets
|
||||
.runs
|
||||
.iter()
|
||||
.map(|b| b.capacity() * std::mem::size_of::<usize>())
|
||||
.map(|b| b.capacity() * std::mem::size_of::<(usize, usize)>())
|
||||
.sum::<usize>()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-graphics.txt";
|
||||
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
|
||||
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
|
||||
pub const HDR_MARKER: &str = "horizon-hdr-pending";
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
@@ -310,9 +313,17 @@ fn bool_text(v: bool) -> &'static str {
|
||||
}
|
||||
impl Graphics {
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
fs::read_to_string(dir.join(FILE))
|
||||
let mut g: Self = fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr {
|
||||
g.hdr = false;
|
||||
g.save(dir);
|
||||
log::warn!(
|
||||
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, in {FILE})"
|
||||
);
|
||||
}
|
||||
g
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let mut g = Self::default();
|
||||
@@ -588,6 +599,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preset_keeps_the_animated_textures_on() {
|
||||
// The Performance preset used to turn them off, which froze the fire,
|
||||
// the water, the lava and the portals (#2064).
|
||||
assert!(Graphics::default().animated_textures);
|
||||
for preset in Preset::ALL {
|
||||
let mut s = crate::settings::Settings::default();
|
||||
apply_preset(preset, &mut s);
|
||||
assert!(
|
||||
s.horizon.animated_textures,
|
||||
"{preset:?} turned the animated textures off"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_workers_leave_a_core_as_vanilla_does() {
|
||||
let g = Graphics::default();
|
||||
|
||||
@@ -801,6 +801,8 @@ impl HealthHud {
|
||||
/// nausea blits (their blending needs the screen-effect pipeline).
|
||||
pub fn paint(batch: &mut crate::hud::HudBatch, atlas: &crate::hud::HudAtlas, draws: &[GuiDraw]) {
|
||||
let mut stack = vec![IDENTITY];
|
||||
// One buffer for the sprite keys, instead of a string per drawn sprite.
|
||||
let mut name = String::new();
|
||||
for d in draws {
|
||||
let start = batch.vertices.len();
|
||||
let pose = *stack.last().unwrap_or(&IDENTITY);
|
||||
@@ -817,12 +819,14 @@ pub fn paint(batch: &mut crate::hud::HudBatch, atlas: &crate::hud::HudAtlas, dra
|
||||
} => {
|
||||
// Effect sprites belong to the mob_effect atlas directory;
|
||||
// the other blitSprite ids refer to GUI sprites.
|
||||
let name = if sprite.starts_with("mob_effect/") {
|
||||
sprite.to_string()
|
||||
name.clear();
|
||||
if sprite.starts_with("mob_effect/") {
|
||||
name.push_str(sprite);
|
||||
} else {
|
||||
format!("gui/sprites/{sprite}")
|
||||
};
|
||||
let Some(mut r) = atlas.sprites.get(&name).copied() else {
|
||||
name.push_str("gui/sprites/");
|
||||
name.push_str(sprite);
|
||||
}
|
||||
let Some(mut r) = atlas.sprites.get(name.as_str()).copied() else {
|
||||
continue;
|
||||
};
|
||||
if let Some([sw, sh, u, v]) = *region {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! `EffectsInInventory`: active effect panels beside survival and creative
|
||||
//! inventories, with compact icons when there is little room on the right.
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::{
|
||||
effects::EffectInstance,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
@@ -123,17 +125,19 @@ pub fn paint(
|
||||
cursor: [i32; 2],
|
||||
) -> Option<[(String, u32); 2]> {
|
||||
let mut tooltip = None;
|
||||
let mut icon = String::new();
|
||||
for panel in panels {
|
||||
background(batch, atlas, panel);
|
||||
let text_width = panel.width - 39;
|
||||
let clipped = atlas.font.width(&panel.name).ceil() as i32 > text_width;
|
||||
if text_width > 0 {
|
||||
let shown = if clipped {
|
||||
let shown: Cow<'_, str> = if clipped {
|
||||
let available = text_width as f32 - atlas.font.width("...");
|
||||
let count = atlas.font.fit(&panel.name, available);
|
||||
format!("{}...", panel.name.chars().take(count).collect::<String>())
|
||||
let text: String = panel.name.chars().take(count).collect();
|
||||
Cow::Owned(format!("{text}..."))
|
||||
} else {
|
||||
panel.name.clone()
|
||||
Cow::Borrowed(panel.name.as_str())
|
||||
};
|
||||
batch.text(
|
||||
atlas,
|
||||
@@ -152,9 +156,12 @@ pub fn paint(
|
||||
true,
|
||||
);
|
||||
}
|
||||
icon.clear();
|
||||
icon.push_str("mob_effect/");
|
||||
icon.push_str(&panel.icon.id);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
&format!("mob_effect/{}", panel.icon.id),
|
||||
&icon,
|
||||
(panel.x + 7) as f32,
|
||||
(panel.y + 7) as f32,
|
||||
18.0,
|
||||
|
||||
@@ -69,6 +69,7 @@ pub mod flatgen;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod friends_screen;
|
||||
pub mod furnace;
|
||||
pub mod game_mode_switcher;
|
||||
pub mod game_rules_screen;
|
||||
pub mod gfx;
|
||||
|
||||
@@ -538,6 +538,8 @@ pub enum DamageKind {
|
||||
Freeze,
|
||||
/// `fell_out_of_world`: below the world's floor.
|
||||
OutOfWorld,
|
||||
/// `cramming`: too many entities in one place (`maxEntityCramming`).
|
||||
Cramming,
|
||||
}
|
||||
/// A damage source: its type, the attacking entity and where it came from.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -616,6 +618,7 @@ impl Damage {
|
||||
| DamageKind::SonicBoom
|
||||
| DamageKind::Freeze
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::Cramming
|
||||
)
|
||||
}
|
||||
pub fn is_fire(&self) -> bool {
|
||||
@@ -642,6 +645,7 @@ impl Damage {
|
||||
| DamageKind::InWall
|
||||
| DamageKind::Freeze
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::Cramming
|
||||
)
|
||||
}
|
||||
pub fn is_projectile(&self) -> bool {
|
||||
@@ -980,6 +984,17 @@ pub enum MobEvent {
|
||||
count: u32,
|
||||
},
|
||||
Sound(&'static str, [f64; 3], f32, f32),
|
||||
/// `UseBonemeal`: the villager's bone meal grows the crop at `pos`
|
||||
/// (`BoneMealItem.growCrop`, then level event 1505).
|
||||
Bonemeal {
|
||||
pos: [i32; 3],
|
||||
},
|
||||
/// `RingBell`: the villager rings the meeting-point bell
|
||||
/// (`BellBlock.attemptToRing`: the bell shakes, the sound and the game
|
||||
/// event follow).
|
||||
RingBell {
|
||||
bell: [i32; 3],
|
||||
},
|
||||
/// `ChestBlockEntity.startOpen` / `stopOpen` by a copper golem: the
|
||||
/// chest's opener count goes up or down (interaction range 3).
|
||||
ChestOpen {
|
||||
@@ -1108,10 +1123,12 @@ pub enum MobEvent {
|
||||
/// The mob the `Explode` right after this comes from
|
||||
/// (`ServerExplosion.source`), and whether it is a charged creeper: the
|
||||
/// exploding creeper is already out of the mob list when the game applies
|
||||
/// the blast, so its victims' loot cannot look it up.
|
||||
/// the blast, so its victims' loot cannot look it up, nor the death
|
||||
/// message its name (`name`, `getDisplayName`).
|
||||
ExplosionSource {
|
||||
mob: u32,
|
||||
charged: bool,
|
||||
name: crate::text::Text,
|
||||
},
|
||||
/// The centre, the radius and the kind of the mob blowing up (it may be
|
||||
/// gone from the list by the time the blast is handled).
|
||||
@@ -1811,6 +1828,8 @@ pub struct MobWorld<'a> {
|
||||
pub mob_griefing: bool,
|
||||
/// The `mob_drops` game rule (`shouldDropLoot`).
|
||||
pub mob_drops: bool,
|
||||
/// The `max_entity_cramming` game rule.
|
||||
pub max_entity_cramming: i32,
|
||||
/// `getCurrentDifficultyAt` at the mob being ticked (near the player
|
||||
/// outside the mob tick).
|
||||
pub difficulty: spawning::LocalDifficulty,
|
||||
@@ -2088,6 +2107,18 @@ impl Mob {
|
||||
if epf > 0.0 {
|
||||
amount *= 1.0 - epf / 25.0;
|
||||
}
|
||||
// Witch.getDamageAfterMagicAbsorb: none from its own doing, else
|
||||
// 85% less from `#witch_resistant_to`.
|
||||
if self.species() == Species::Witch {
|
||||
if source.attacker == Some(Who::Mob(self.id)) {
|
||||
amount = 0.0;
|
||||
} else if matches!(
|
||||
source.kind,
|
||||
DamageKind::Magic | DamageKind::SonicBoom | DamageKind::Thorns
|
||||
) {
|
||||
amount *= 0.15;
|
||||
}
|
||||
}
|
||||
// Wolf.actuallyHurt: wolf armor takes the damage instead of the wolf.
|
||||
let absorbed = self.wolf_armor_absorbs(&source);
|
||||
let mut knock = true;
|
||||
@@ -2170,6 +2201,15 @@ impl Mob {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A splash or cloud's harming effect on this mob: the potion's
|
||||
/// `indirect_magic` is in `#witch_resistant_to`, so a witch takes 15%.
|
||||
pub fn potion_harm(&self, amount: f32) -> f32 {
|
||||
if self.species() == Species::Witch {
|
||||
amount * 0.15
|
||||
} else {
|
||||
amount
|
||||
}
|
||||
}
|
||||
/// `Wolf.canArmorAbsorb`: it wears wolf armor and the damage type is not
|
||||
/// in `#bypasses_wolf_armor` (drowning, magic, thorns, in a wall, out of
|
||||
/// the world and the untyped kills).
|
||||
@@ -2183,6 +2223,7 @@ impl Mob {
|
||||
| DamageKind::InWall
|
||||
| DamageKind::Thorns
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::Cramming
|
||||
| DamageKind::Generic
|
||||
)
|
||||
}
|
||||
|
||||
@@ -533,9 +533,9 @@ pub fn tick(
|
||||
true
|
||||
}
|
||||
/// `LivingEntity.aiStep` -> `Entity.applyEffectsFromBlocks` for the blocks
|
||||
/// that hurt or hold a mob: the cactus (`CactusBlock.entityInside`, 1) and the
|
||||
/// sweet berry bush (`SweetBerryBushBlock.entityInside`: stuck at 0.8, 0.75,
|
||||
/// 0.8, and a grown bush pricks for 1 when the mob moved at least 0.003 on X
|
||||
/// that hurt or hold a mob: the cactus (`CactusBlock.entityInside`, 1), the
|
||||
/// cobweb (stuck at 0.25, 0.05, 0.25) and the sweet berry bush
|
||||
/// (`SweetBerryBushBlock.entityInside`: stuck at 0.8, 0.75, 0.8, and a grown bush pricks for 1 when the mob moved at least 0.003 on X
|
||||
/// or Z this tick). The blocks are those the mob's box touches anywhere along
|
||||
/// its move this tick, from where it started to where it ended.
|
||||
fn effects_from_blocks(m: &mut Mob, w: &MobWorld) {
|
||||
@@ -562,6 +562,21 @@ fn effects_from_blocks(m: &mut Mob, w: &MobWorld) {
|
||||
"minecraft:cactus" => {
|
||||
m.damage(1.0, Damage::of(DamageKind::Cactus));
|
||||
}
|
||||
// WebBlock.entityInside -> makeStuckInBlock, which a
|
||||
// spider ignores for cobwebs and a wither always.
|
||||
"minecraft:cobweb"
|
||||
if !matches!(
|
||||
m.species(),
|
||||
Species::Spider | Species::CaveSpider | Species::Wither
|
||||
) =>
|
||||
{
|
||||
m.fall_distance = 0.0;
|
||||
m.stuck_multiplier = if m.has_effect("minecraft:weaving") {
|
||||
Vec3::new(0.5, 0.25, 0.5)
|
||||
} else {
|
||||
Vec3::new(0.25, f64::from(0.05_f32), 0.25)
|
||||
};
|
||||
}
|
||||
"minecraft:sweet_berry_bush"
|
||||
if !matches!(m.species(), Species::Fox | Species::Bee) =>
|
||||
{
|
||||
@@ -828,17 +843,28 @@ fn play_ambient(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
// Nautilus/ZombieNautilus.getAmbientSound: by age, under water or on land.
|
||||
Species::Nautilus | Species::ZombieNautilus => super::nautilus::sounds(m)[0],
|
||||
Species::Enderman if m.ai.target.is_some() => "entity.enderman.scream",
|
||||
Species::Wolf if m.ai.target.is_some() => "entity.wolf.growl",
|
||||
Species::Wolf if m.ai.target.is_some() => {
|
||||
super::finalize::variant_sound(m, "growl").unwrap_or("entity.wolf.growl")
|
||||
}
|
||||
Species::Wolf if m.random.next_int_bound(3).unwrap_or(1) == 0 => {
|
||||
if m.extra.tame && m.health < m.attrs.max_health / 2.0 {
|
||||
"entity.wolf.whine"
|
||||
let kind = if m.extra.tame && m.health < m.attrs.max_health / 2.0 {
|
||||
"whine"
|
||||
} else {
|
||||
"entity.wolf.pant"
|
||||
}
|
||||
"pant"
|
||||
};
|
||||
super::finalize::variant_sound(m, kind).unwrap_or("entity.wolf.pant")
|
||||
}
|
||||
Species::Parrot => super::parrot::ambient(m, w),
|
||||
Species::Cat if m.extra.tame && m.extra.in_love > 0 => "entity.cat.purr",
|
||||
Species::Cat if !m.extra.tame => "entity.cat.stray_ambient",
|
||||
// Cat.getAmbientSound: purr in love, purreow one time in four, else ambient.
|
||||
Species::Cat if m.extra.tame && m.extra.in_love > 0 => {
|
||||
super::finalize::variant_sound(m, "purr").unwrap_or("entity.cat.purr")
|
||||
}
|
||||
Species::Cat if m.extra.tame && m.random.next_int_bound(4).unwrap_or(1) == 0 => {
|
||||
super::finalize::variant_sound(m, "purreow").unwrap_or("entity.cat.purreow")
|
||||
}
|
||||
Species::Cat if !m.extra.tame => {
|
||||
super::finalize::variant_sound(m, "stray_ambient").unwrap_or("entity.cat.stray_ambient")
|
||||
}
|
||||
Species::PolarBear if m.is_baby() => "entity.polar_bear.ambient_baby",
|
||||
Species::Dolphin if !m.fluids.in_water => "entity.dolphin.ambient",
|
||||
Species::Guardian if !m.fluids.in_water => "entity.guardian.ambient_land",
|
||||
@@ -1058,7 +1084,8 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
&& m.species() != Species::Shulker
|
||||
&& !on_climbable(m, w)
|
||||
&& (m.species() != Species::Creaking || super::creaking::can_move(m));
|
||||
let mut targets: Vec<(Who, Vec3)> = Vec::new();
|
||||
// Each with whether it rides a vehicle (`Entity.isPassenger`).
|
||||
let mut targets: Vec<(Who, Vec3, bool)> = Vec::new();
|
||||
if let Some(p) = &w.player
|
||||
// Entity.push: passengers of the same vehicle never push each other.
|
||||
&& m.rider.is_none()
|
||||
@@ -1066,21 +1093,27 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
&& !p.spectator
|
||||
&& p.bounds.intersects(bounds)
|
||||
{
|
||||
targets.push((Who::Player, p.position));
|
||||
targets.push((Who::Player, p.position, p.passenger));
|
||||
}
|
||||
for o in w.others_near(bounds) {
|
||||
if o.id != m.id && o.alive && o.pushable && o.bounds.intersects(bounds) {
|
||||
targets.push((Who::Mob(o.id), o.position));
|
||||
targets.push((Who::Mob(o.id), o.position, o.passenger));
|
||||
}
|
||||
}
|
||||
// EntitySelector.pushableBy(this): the teams' collision rules.
|
||||
let own = w.teams.of(Who::Mob(m.id));
|
||||
targets.retain(|(who, _)| crate::scoreboard::pushable_by(own, w.teams.of(*who), true));
|
||||
// Cramming (maxEntityCramming 24).
|
||||
if targets.len() > 23 {
|
||||
let _ = m.random.next_int_bound(4);
|
||||
targets.retain(|(who, ..)| crate::scoreboard::pushable_by(own, w.teams.of(*who), true));
|
||||
// Cramming: a quarter of the ticks spent with `maxEntityCramming` or more
|
||||
// others around that are not riding anything hurts it by 6.
|
||||
let limit = w.max_entity_cramming;
|
||||
if limit > 0
|
||||
&& targets.len() as i64 > i64::from(limit) - 1
|
||||
&& m.random.next_int_bound(4).unwrap_or(1) == 0
|
||||
&& targets.iter().filter(|t| !t.2).count() as i64 > i64::from(limit) - 1
|
||||
{
|
||||
m.damage(6.0, Damage::of(DamageKind::Cramming));
|
||||
}
|
||||
for (who, at) in targets {
|
||||
for (who, at, _) in targets {
|
||||
// Warden.doPush: touching it angers it, then the push itself.
|
||||
if m.species() == Species::Warden {
|
||||
super::warden::do_push(m, w, who, events);
|
||||
@@ -1455,17 +1488,66 @@ pub fn voice_sounds(m: &Mob) -> [&'static str; 3] {
|
||||
};
|
||||
[ambient, m.kind.sounds[1], m.kind.sounds[2]]
|
||||
}
|
||||
// The sound variant's baby_sounds (classic sets, like the rest here).
|
||||
Species::Pig if m.is_baby() => [
|
||||
"entity.baby_pig.ambient",
|
||||
"entity.baby_pig.hurt",
|
||||
"entity.baby_pig.death",
|
||||
// Horse.getAmbientSound, getHurtSound and getDeathSound by age.
|
||||
Species::Horse if m.is_baby() => [
|
||||
"entity.baby_horse.ambient",
|
||||
"entity.baby_horse.hurt",
|
||||
"entity.baby_horse.death",
|
||||
],
|
||||
Species::Chicken if m.is_baby() => [
|
||||
"entity.baby_chicken.ambient",
|
||||
"entity.baby_chicken.hurt",
|
||||
"entity.baby_chicken.death",
|
||||
// Drowned, Guardian and ElderGuardian: the hurt and death sounds by
|
||||
// water or land.
|
||||
Species::Drowned if m.fluids.in_water => [
|
||||
"entity.drowned.ambient_water",
|
||||
"entity.drowned.hurt_water",
|
||||
"entity.drowned.death_water",
|
||||
],
|
||||
Species::Guardian if !m.fluids.in_water => [
|
||||
m.kind.sounds[0],
|
||||
"entity.guardian.hurt_land",
|
||||
"entity.guardian.death_land",
|
||||
],
|
||||
Species::ElderGuardian if !m.fluids.in_water => [
|
||||
m.kind.sounds[0],
|
||||
"entity.elder_guardian.hurt_land",
|
||||
"entity.elder_guardian.death_land",
|
||||
],
|
||||
// Slime and MagmaCube.getHurtSound and getDeathSound: tiny ones.
|
||||
Species::Slime if m.extra.size <= 1 => [
|
||||
m.kind.sounds[0],
|
||||
"entity.slime.hurt_small",
|
||||
"entity.slime.death_small",
|
||||
],
|
||||
Species::MagmaCube if m.extra.size <= 1 => [
|
||||
m.kind.sounds[0],
|
||||
"entity.magma_cube.hurt_small",
|
||||
"entity.magma_cube.death_small",
|
||||
],
|
||||
// Shulker.getHurtSound: a closed shell.
|
||||
Species::Shulker if m.extra.counter == 0 => [
|
||||
m.kind.sounds[0],
|
||||
"entity.shulker.hurt_closed",
|
||||
m.kind.sounds[2],
|
||||
],
|
||||
// Turtle.getHurtSound and getDeathSound by age.
|
||||
Species::Turtle if m.is_baby() => [
|
||||
m.kind.sounds[0],
|
||||
"entity.turtle.hurt_baby",
|
||||
"entity.turtle.death_baby",
|
||||
],
|
||||
// SkeletonHorse.getAmbientSound: its own under water.
|
||||
Species::SkeletonHorse if m.fluids.eye_in_water => [
|
||||
"entity.skeleton_horse.ambient_water",
|
||||
m.kind.sounds[1],
|
||||
m.kind.sounds[2],
|
||||
],
|
||||
// The mob's sound variant set, the baby one for a baby.
|
||||
Species::Pig | Species::Cow | Species::Chicken | Species::Wolf | Species::Cat => {
|
||||
use super::finalize::variant_sound as v;
|
||||
match (v(m, "ambient"), v(m, "hurt"), v(m, "death")) {
|
||||
(Some(a), Some(h), Some(d)) => [a, h, d],
|
||||
_ => m.kind.sounds,
|
||||
}
|
||||
}
|
||||
_ => m.kind.sounds,
|
||||
}
|
||||
}
|
||||
@@ -1514,13 +1596,12 @@ pub fn age_up(m: &mut Mob, seconds: i32, forced: bool) {
|
||||
}
|
||||
}
|
||||
/// `Animal.playEatingSound` (empty) and its overrides, as (sound, volume,
|
||||
/// pitch); `level` is the level's random. Pigs and cats use the classic sound
|
||||
/// set, like their other sounds here.
|
||||
/// pitch); `level` is the level's random. Pigs and cats play their sound
|
||||
/// variant's set.
|
||||
pub fn eating_sound(
|
||||
m: &mut Mob,
|
||||
level: &mut impl RandomSource,
|
||||
) -> Option<(&'static str, f32, f32)> {
|
||||
let baby = m.is_baby();
|
||||
// Mth.randomBetween(level.getRandom(), 0.8, 1.2).
|
||||
let mut between = || level.next_float() * 0.4 + 0.8;
|
||||
let (sound, volume, pitch, entity_sound) = match m.species() {
|
||||
@@ -1528,16 +1609,21 @@ pub fn eating_sound(
|
||||
Species::Armadillo | Species::Pig | Species::SulfurCube => {
|
||||
let sound = match m.species() {
|
||||
Species::Armadillo => "entity.armadillo.eat",
|
||||
Species::Pig if baby => "entity.baby_pig.eat",
|
||||
Species::Pig => "entity.pig.eat",
|
||||
Species::Pig => {
|
||||
super::finalize::variant_sound(m, "eat").unwrap_or("entity.pig.eat")
|
||||
}
|
||||
_ => "entity.small_sulfur_cube.eat",
|
||||
};
|
||||
let pitch = m.voice_pitch();
|
||||
(sound, sound_volume(m), pitch, true)
|
||||
}
|
||||
// playSound(sound, 1, 1).
|
||||
Species::Cat if baby => ("entity.baby_cat.eat", 1.0, 1.0, true),
|
||||
Species::Cat => ("entity.cat.eat", 1.0, 1.0, true),
|
||||
Species::Cat => (
|
||||
super::finalize::variant_sound(m, "eat").unwrap_or("entity.cat.eat"),
|
||||
1.0,
|
||||
1.0,
|
||||
true,
|
||||
),
|
||||
Species::Fox => ("entity.fox.eat", 1.0, 1.0, true),
|
||||
// level().playSound(null, this, sound, NEUTRAL, volume, pitch).
|
||||
Species::Frog => ("entity.frog.eat", 2.0, 1.0, false),
|
||||
@@ -2013,12 +2099,10 @@ pub fn step_sound(
|
||||
| S::ElderGuardian
|
||||
| S::Breeze => None,
|
||||
S::Armadillo => quiet("entity.armadillo.step"),
|
||||
S::Chicken if baby => quiet("entity.baby_chicken.step"),
|
||||
S::Chicken => quiet("entity.chicken.step"),
|
||||
S::Cow | S::Mooshroom => quiet("entity.cow.step"),
|
||||
S::Pig if baby => quiet("entity.baby_pig.step"),
|
||||
S::Pig => quiet("entity.pig.step"),
|
||||
S::Wolf => quiet("entity.wolf.step"),
|
||||
S::Chicken | S::Cow | S::Pig | S::Wolf => {
|
||||
super::finalize::variant_sound(m, "step").and_then(quiet)
|
||||
}
|
||||
S::Mooshroom => quiet("entity.cow.step"),
|
||||
S::Llama | S::TraderLlama => quiet("entity.llama.step"),
|
||||
S::Frog => quiet("entity.frog.step"),
|
||||
S::Goat => quiet("entity.goat.step"),
|
||||
@@ -2103,6 +2187,8 @@ pub fn swim_sound(m: &Mob) -> &'static str {
|
||||
Species::Nautilus => "entity.nautilus.swim",
|
||||
Species::ZombieNautilus => "entity.zombie_nautilus.swim",
|
||||
Species::Turtle => "entity.turtle.swim",
|
||||
// Monster.getSwimSound, and Hoglin's own override of it.
|
||||
s if s == Species::Hoglin || is_monster(s) => "entity.hostile.swim",
|
||||
_ => "entity.generic.swim",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,8 @@ impl Goal for Deliver {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `StayCloseToTarget`: back within 4 blocks of the deposit.
|
||||
/// `StayCloseToTarget.create(.., 4, 16, 2.25)`: acts only once the deposit is
|
||||
/// 16 blocks away or more, then walks until within 4.
|
||||
#[derive(Clone, Debug)]
|
||||
struct StayClose;
|
||||
impl Goal for StayClose {
|
||||
@@ -109,7 +110,19 @@ impl Goal for StayClose {
|
||||
let d = (p[0] - m.position.x).powi(2)
|
||||
+ (p[1] - m.position.y).powi(2)
|
||||
+ (p[2] - m.position.z).powi(2);
|
||||
d > 16.0
|
||||
d >= 256.0
|
||||
})
|
||||
}
|
||||
/// `MoveToTargetSink.reachedTarget`: the walk target it set is reached
|
||||
/// within a Manhattan block distance of 4 (`closeEnough`).
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
m.extra.point.is_none()
|
||||
&& deposit(m, cx).is_some_and(|p| {
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
(0..3)
|
||||
.map(|i| (p[i].floor() as i32 - at[i].floor() as i32).abs())
|
||||
.sum::<i32>()
|
||||
> 4
|
||||
})
|
||||
}
|
||||
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
|
||||
@@ -534,7 +534,7 @@ impl Goal for PufferfishPuff {
|
||||
cx.w.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.alive && !p.creative && area.intersects(p.bounds));
|
||||
// TARGETING_CONDITIONS: not other fish (not_scary_for_pufferfish) and alive.
|
||||
// TARGETING_CONDITIONS: not #not_scary_for_pufferfish, and alive.
|
||||
let mob = cx.w.others_near(area).any(|s| {
|
||||
s.id != m.id
|
||||
&& s.alive
|
||||
@@ -547,7 +547,13 @@ impl Goal for PufferfishPuff {
|
||||
| Species::Squid
|
||||
| Species::GlowSquid
|
||||
| Species::Dolphin
|
||||
| Species::Axolotl
|
||||
| Species::Turtle
|
||||
| Species::Guardian
|
||||
| Species::ElderGuardian
|
||||
| Species::Tadpole
|
||||
| Species::Nautilus
|
||||
| Species::ZombieNautilus
|
||||
| Species::SulfurCube
|
||||
)
|
||||
&& area.intersects(s.bounds)
|
||||
});
|
||||
|
||||
@@ -114,28 +114,40 @@ pub fn register(m: &mut Mob) {
|
||||
g.add(3, Tempt::new(1.25, tempt, false));
|
||||
}
|
||||
}
|
||||
/// `generateMaxHealth`, `generateSpeed`, `generateJumpStrength` and the coat.
|
||||
/// `randomizeAttributes` of each class: horses roll health, speed and jump,
|
||||
/// donkeys and mules only health (their jump stays 0.5), skeleton horses only
|
||||
/// the jump, zombie horses a smaller jump and their own speed.
|
||||
pub fn finalize(m: &mut Mob) {
|
||||
let species = m.species();
|
||||
let r = &mut m.random;
|
||||
let health =
|
||||
15.0 + r.next_int_bound(8).unwrap_or(0) as f32 + r.next_int_bound(9).unwrap_or(0) as f32;
|
||||
let speed =
|
||||
(0.45 + r.next_double() * 0.3 + r.next_double() * 0.3 + r.next_double() * 0.3) * 0.25;
|
||||
let jump = 0.4 + r.next_double() * 0.2 + r.next_double() * 0.2 + r.next_double() * 0.2;
|
||||
match m.species() {
|
||||
if matches!(species, Species::Horse | Species::Donkey | Species::Mule) {
|
||||
m.attrs.max_health = 15.0
|
||||
+ r.next_int_bound(8).unwrap_or(0) as f32
|
||||
+ r.next_int_bound(9).unwrap_or(0) as f32;
|
||||
}
|
||||
match species {
|
||||
Species::Horse => {
|
||||
m.attrs.max_health = health;
|
||||
m.attrs.speed = speed;
|
||||
m.extra.vector[0] = jump;
|
||||
m.extra.variant = m.random.next_int_bound(7).unwrap_or(0) as u8;
|
||||
m.extra.variant2 = m.random.next_int_bound(5).unwrap_or(0) as u8;
|
||||
m.attrs.speed =
|
||||
(0.45 + r.next_double() * 0.3 + r.next_double() * 0.3 + r.next_double() * 0.3)
|
||||
* 0.25;
|
||||
m.extra.vector[0] =
|
||||
0.4 + r.next_double() * 0.2 + r.next_double() * 0.2 + r.next_double() * 0.2;
|
||||
m.extra.variant = r.next_int_bound(7).unwrap_or(0) as u8;
|
||||
m.extra.variant2 = r.next_int_bound(5).unwrap_or(0) as u8;
|
||||
}
|
||||
Species::Donkey | Species::Mule => {
|
||||
// AbstractChestedHorse.randomizeAttributes: fixed speed, generated health.
|
||||
m.attrs.max_health = health;
|
||||
m.extra.vector[0] = jump;
|
||||
Species::Donkey | Species::Mule => m.extra.vector[0] = 0.5,
|
||||
Species::ZombieHorse => {
|
||||
// ZombieHorse.generateZombieHorseJumpStrength and ...Speed.
|
||||
let d = 0.066_666_666_666_666_67;
|
||||
m.extra.vector[0] =
|
||||
0.5 + r.next_double() * d + r.next_double() * d + r.next_double() * d;
|
||||
m.attrs.speed =
|
||||
(9.0 + r.next_double() + r.next_double() + r.next_double()) / f64::from(42.16f32);
|
||||
}
|
||||
_ => {
|
||||
m.extra.vector[0] =
|
||||
0.4 + r.next_double() * 0.2 + r.next_double() * 0.2 + r.next_double() * 0.2
|
||||
}
|
||||
_ => m.extra.vector[0] = jump,
|
||||
}
|
||||
m.health = m.attrs.max_health;
|
||||
}
|
||||
@@ -280,13 +292,17 @@ pub fn make_mad(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
m.extra.counter = 20;
|
||||
m.extra.flag = false;
|
||||
}
|
||||
// getAngrySound: null (silent) for the skeleton horse.
|
||||
let sound = match m.species() {
|
||||
Species::SkeletonHorse => return,
|
||||
Species::Donkey => "entity.donkey.angry",
|
||||
Species::Mule => "entity.mule.angry",
|
||||
Species::ZombieHorse => "entity.zombie_horse.angry",
|
||||
Species::Llama | Species::TraderLlama => "entity.llama.angry",
|
||||
_ if m.is_baby() => "entity.baby_horse.angry",
|
||||
_ => "entity.horse.angry",
|
||||
};
|
||||
events.push(MobEvent::Sound(sound, m.center(), 1.0, 1.0));
|
||||
m.voice(sound, events);
|
||||
}
|
||||
/// `RandomStandGoal`.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -312,10 +328,113 @@ impl Goal for RandomStand {
|
||||
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
m.extra.counter = 20;
|
||||
m.extra.flag = false;
|
||||
if m.species() == Species::Horse {
|
||||
cx.events
|
||||
.push(MobEvent::Sound("entity.horse.angry", m.center(), 1.0, 1.0));
|
||||
// playStandSound: getAmbientStandSound is the ambient sound, plain
|
||||
// playSound(sound) at volume and pitch 1.
|
||||
let sound = super::ai::voice_sounds(m)[0];
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
cx.events.push(MobEvent::Sound(sound, at, 1.0, 1.0));
|
||||
}
|
||||
}
|
||||
/// Horses that step with `AbstractHorse.playStepSound` (llamas and camels
|
||||
/// have their own).
|
||||
pub fn hoof_steps(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Horse
|
||||
| Species::Donkey
|
||||
| Species::Mule
|
||||
| Species::SkeletonHorse
|
||||
| Species::ZombieHorse
|
||||
)
|
||||
}
|
||||
/// `AbstractHorse.playStepSound` (and `Horse.playGallopSound`) on the block
|
||||
/// `pos`: silent on liquids; a ridden horse walks on wood for five steps,
|
||||
/// then gallops every third; unridden, wood or the plain step by age.
|
||||
/// Donkeys and mules never gallop (`canGallop`).
|
||||
pub fn play_step(
|
||||
m: &mut Mob,
|
||||
w: &MobWorld,
|
||||
pos: [i32; 3],
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
events: &mut Vec<MobEvent>,
|
||||
) {
|
||||
if matches!(
|
||||
&*w.facts.get(state).name,
|
||||
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let above = w.world.block(pos[0], pos[1] + 1, pos[2]);
|
||||
let sound_type = if &*w.facts.get(above).name == "minecraft:snow" {
|
||||
&w.facts.get(above).sounds
|
||||
} else {
|
||||
&w.facts.get(state).sounds
|
||||
};
|
||||
let (volume, pitch) = (sound_type.volume, sound_type.pitch);
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let baby = m.is_baby();
|
||||
let can_gallop = !matches!(m.species(), Species::Donkey | Species::Mule);
|
||||
if m.rider.is_some() && can_gallop {
|
||||
m.ai.steps.gallop += 1;
|
||||
let count = m.ai.steps.gallop;
|
||||
if count > 5 && count % 3 == 0 {
|
||||
events.push(MobEvent::Sound(
|
||||
"entity.horse.gallop",
|
||||
at,
|
||||
volume * 0.15,
|
||||
pitch,
|
||||
));
|
||||
// Horse.playGallopSound: now and then it breathes too.
|
||||
if m.species() == Species::Horse && m.random.next_int_bound(10).unwrap_or(1) == 0 {
|
||||
let breathe = if baby {
|
||||
"entity.baby_horse.breathe"
|
||||
} else {
|
||||
"entity.horse.breathe"
|
||||
};
|
||||
events.push(MobEvent::Sound(breathe, at, volume * 0.6, pitch));
|
||||
}
|
||||
} else if count <= 5 {
|
||||
events.push(MobEvent::Sound(
|
||||
"entity.horse.step_wood",
|
||||
at,
|
||||
volume * 0.15,
|
||||
pitch,
|
||||
));
|
||||
}
|
||||
} else if is_wood_sound_type(sound_type) {
|
||||
events.push(MobEvent::Sound(
|
||||
"entity.horse.step_wood",
|
||||
at,
|
||||
volume * 0.15,
|
||||
pitch,
|
||||
));
|
||||
} else {
|
||||
let step = if baby {
|
||||
"entity.baby_horse.step"
|
||||
} else {
|
||||
"entity.horse.step"
|
||||
};
|
||||
events.push(MobEvent::Sound(step, at, volume * 0.15, pitch));
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.isWoodSoundType`: WOOD, NETHER_WOOD, STEM, CHERRY_WOOD and
|
||||
/// BAMBOO_WOOD (not HARD_CROP, which only shares WOOD's step).
|
||||
fn is_wood_sound_type(sounds: &horizon_items::blocks::Sounds) -> bool {
|
||||
let step = sounds
|
||||
.step
|
||||
.strip_prefix("minecraft:")
|
||||
.unwrap_or(&sounds.step);
|
||||
let place = sounds
|
||||
.place
|
||||
.strip_prefix("minecraft:")
|
||||
.unwrap_or(&sounds.place);
|
||||
match step {
|
||||
"block.wood.step" => place == "block.wood.place",
|
||||
"block.nether_wood.step"
|
||||
| "block.stem.step"
|
||||
| "block.cherry_wood.step"
|
||||
| "block.bamboo_wood.step" => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// The mount takes its rider's input (`getRiddenInput`, `tickRidden`).
|
||||
@@ -323,6 +442,10 @@ pub fn ridden_step(m: &mut Mob) {
|
||||
let Some(r) = m.rider else {
|
||||
return;
|
||||
};
|
||||
// AbstractHorse.tickRidden: not pressing forward ends the gallop.
|
||||
if hoof_steps(m.species()) && r.forward <= 0.0 {
|
||||
m.ai.steps.gallop = 0;
|
||||
}
|
||||
// AbstractNautilus.tickRidden turns half way toward the rider.
|
||||
if super::nautilus::is_nautilus(m.species()) {
|
||||
return super::nautilus::ridden(m);
|
||||
|
||||
@@ -821,6 +821,22 @@ fn age_boundary_reached(m: &mut Mob) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `SpawnEggItem.spawnsEntity`: the spawn egg is for the mob's own kind, so
|
||||
/// it is used on it (`Mob.checkAndHandleImportantInteractions`).
|
||||
pub fn spawn_egg_is_for(egg: &str, kind: &str) -> bool {
|
||||
egg.strip_suffix("_spawn_egg") == Some(kind)
|
||||
}
|
||||
|
||||
/// `SpawnEggItem.spawnOffspringFromSpawnEgg`: the offspring of a spawn egg
|
||||
/// used on a mob of the same kind is a baby of that kind: it starts with
|
||||
/// the age of a baby and keeps the parent's variant and colour.
|
||||
pub fn spawn_egg_offspring(child: &mut Mob, species: Species, variant: u8, color: u8) {
|
||||
set_baby(child, true);
|
||||
child.extra.variant = variant;
|
||||
if species == Species::Sheep {
|
||||
child.extra.color = color;
|
||||
}
|
||||
}
|
||||
/// `setBaby`: AgeableMob through its age, zombies, piglins and zoglins.
|
||||
pub fn set_baby(m: &mut Mob, baby: bool) {
|
||||
let s = m.species();
|
||||
@@ -2046,6 +2062,46 @@ pub fn sound_variants(s: Species) -> &'static [&'static str] {
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
/// A sound of the mob's `*_sound_variant` set (`Pig.getSoundSet`,
|
||||
/// `Wolf.getSoundSet`, `Cat.getSoundSet`, ...): `kind` is the sound's key
|
||||
/// (`ambient`, `hurt`, `step`, `growl`, `purreow`...). Babies use the species'
|
||||
/// baby set (cows have none); an adult's set is `entity.<species>.*` for
|
||||
/// `classic` and `entity.<species>_<variant>.*` otherwise, except the
|
||||
/// footsteps that every variant shares (not the cow's). None for a species
|
||||
/// without sound variants.
|
||||
pub fn variant_sound(m: &Mob, kind: &str) -> Option<&'static str> {
|
||||
use std::{collections::HashMap, sync::Mutex};
|
||||
let species = m.species();
|
||||
let name = match species {
|
||||
Species::Pig => "pig",
|
||||
Species::Cow => "cow",
|
||||
Species::Chicken => "chicken",
|
||||
Species::Wolf => "wolf",
|
||||
Species::Cat => "cat",
|
||||
_ => return None,
|
||||
};
|
||||
let variant = m
|
||||
.extra
|
||||
.sound_variant
|
||||
.and_then(|i| sound_variants(species).get(i as usize))
|
||||
.copied()
|
||||
.unwrap_or("classic");
|
||||
let id = if m.is_baby() && species != Species::Cow {
|
||||
format!("entity.baby_{name}.{kind}")
|
||||
} else if variant == "classic" || (kind == "step" && species != Species::Cow) {
|
||||
format!("entity.{name}.{kind}")
|
||||
} else {
|
||||
format!("entity.{name}_{variant}.{kind}")
|
||||
};
|
||||
// A handful of ids in all: keep each one as a `&'static str`.
|
||||
static IDS: Mutex<Option<HashMap<String, &'static str>>> = Mutex::new(None);
|
||||
let mut ids = IDS.lock().ok()?;
|
||||
let ids = ids.get_or_insert_with(HashMap::new);
|
||||
Some(
|
||||
*ids.entry(id)
|
||||
.or_insert_with_key(|k| Box::leak(k.clone().into_boxed_str())),
|
||||
)
|
||||
}
|
||||
/// The registry name of the game's variant index.
|
||||
pub fn variant_name(s: Species, index: u8) -> &'static str {
|
||||
let i = index as usize;
|
||||
@@ -2098,6 +2154,39 @@ mod tests {
|
||||
assert_eq!(panda_variant(4, 4), 4);
|
||||
assert_eq!(panda_variant(6, 0), 6);
|
||||
}
|
||||
/// #2269: the offspring of a spawn egg used on a mob is a baby of that
|
||||
/// kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`), and only the mob's
|
||||
/// own kind takes the egg.
|
||||
#[test]
|
||||
fn a_spawn_egg_offspring_is_a_baby() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let kind = std::sync::Arc::new(data.types.get("minecraft:cow").unwrap().clone());
|
||||
let mut child = crate::mobs::Mob::new(
|
||||
2,
|
||||
kind,
|
||||
horizon_core::geometry::Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
assert!(!child.is_baby());
|
||||
spawn_egg_offspring(&mut child, Species::Cow, 3, 0);
|
||||
// The offspring is a baby of the mob's kind.
|
||||
assert!(child.is_baby());
|
||||
// It keeps the parent's variant.
|
||||
assert_eq!(child.extra.variant, 3);
|
||||
// The egg is for the mob's own kind.
|
||||
assert!(spawn_egg_is_for("minecraft:cow_spawn_egg", "minecraft:cow"));
|
||||
assert!(!spawn_egg_is_for(
|
||||
"minecraft:pig_spawn_egg",
|
||||
"minecraft:cow"
|
||||
));
|
||||
assert!(!spawn_egg_is_for("minecraft:stick", "minecraft:cow"));
|
||||
}
|
||||
#[test]
|
||||
fn reasons_round_trip() {
|
||||
for r in Reason::ALL {
|
||||
|
||||
@@ -1065,6 +1065,8 @@ pub struct MeleeAttack {
|
||||
raise_arm: i32,
|
||||
/// Ticks between attacks (20 unless set).
|
||||
pub interval: i32,
|
||||
/// Ticks between attacks for a baby, when they differ.
|
||||
pub baby_interval: Option<i32>,
|
||||
}
|
||||
impl MeleeAttack {
|
||||
pub fn new(speed: f64, follow_unseen: bool) -> Self {
|
||||
@@ -1079,6 +1081,7 @@ impl MeleeAttack {
|
||||
last_check: 0,
|
||||
raise_arm: 0,
|
||||
interval: 20,
|
||||
baby_interval: None,
|
||||
}
|
||||
}
|
||||
pub fn zombie(mut self) -> Self {
|
||||
@@ -1093,6 +1096,12 @@ impl MeleeAttack {
|
||||
self.interval = ticks;
|
||||
self
|
||||
}
|
||||
/// `MeleeAttack.create(adult)` for adults, `create(baby)` for babies.
|
||||
pub fn intervals(mut self, adult: i32, baby: i32) -> Self {
|
||||
self.interval = adult;
|
||||
self.baby_interval = Some(baby);
|
||||
self
|
||||
}
|
||||
}
|
||||
impl Goal for MeleeAttack {
|
||||
goal_clone!();
|
||||
@@ -1191,7 +1200,11 @@ impl Goal for MeleeAttack {
|
||||
cx,
|
||||
);
|
||||
} else if self.until_attack <= 0 && m.within_melee_range(&t) && m.sees(cx.w, who) {
|
||||
self.until_attack = adjusted(true, self.interval);
|
||||
let interval = match self.baby_interval {
|
||||
Some(baby) if m.is_baby() => baby,
|
||||
_ => self.interval,
|
||||
};
|
||||
self.until_attack = adjusted(true, interval);
|
||||
m.swing();
|
||||
super::ai::do_hurt_target(m, who, cx);
|
||||
}
|
||||
@@ -1893,6 +1906,10 @@ impl Goal for Breed {
|
||||
if tamable && (!m.extra.tame || m.extra.sitting) {
|
||||
return false;
|
||||
}
|
||||
// AbstractHorse.canMate is false and Mule keeps it: mules never breed.
|
||||
if species == Species::Mule {
|
||||
return false;
|
||||
}
|
||||
// TurtleBreedGoal.canUse: not while carrying an egg.
|
||||
if species == Species::Turtle && super::turtle::has_egg(m) {
|
||||
return false;
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::{
|
||||
anim::{Poses, State},
|
||||
goals::{
|
||||
Cx, Goal, HurtByTarget, LOOK, LookAtPlayer, MOVE, MeleeAttack, NearestTarget, Prey,
|
||||
RandomLookAround, Stroll, goal_clone,
|
||||
RandomLookAround, Stroll, goal_clone, reduced,
|
||||
},
|
||||
};
|
||||
use crate::projectiles::{Projectile, ProjectileKind};
|
||||
@@ -158,7 +158,8 @@ impl Goal for OfferFlower {
|
||||
self.ticks > 0
|
||||
}
|
||||
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
self.ticks = 400;
|
||||
// adjustedTickDelay(400): the goal ticks every other game tick.
|
||||
self.ticks = reduced(400);
|
||||
m.extra.counter = 400;
|
||||
// IronGolem.offerFlower(true): entity event 11.
|
||||
entity_event(m, 11, cx);
|
||||
|
||||
@@ -326,9 +326,17 @@ pub fn creeper_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
if m.swell >= 30 {
|
||||
m.swell = 30;
|
||||
let radius = if m.extra.charged { 6.0 } else { 3.0 };
|
||||
let name = match &m.custom_name {
|
||||
Some(name) => crate::text::Text::literal(name.clone()),
|
||||
None => crate::text::Text::translate(
|
||||
&format!("entity.{}", m.kind.id.replace(':', ".")),
|
||||
&[],
|
||||
),
|
||||
};
|
||||
events.push(MobEvent::ExplosionSource {
|
||||
mob: m.id,
|
||||
charged: m.extra.charged,
|
||||
name,
|
||||
});
|
||||
events.push(MobEvent::Explode(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
|
||||
@@ -542,12 +542,12 @@ fn shoot_crossbow(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
Some(Who::Mob(m.id)),
|
||||
);
|
||||
p.set_bonus(super::gear::arrow_bonus(m));
|
||||
// rangedAttackUncertainty at normal difficulty: 14 - 2 * 4.
|
||||
// rangedAttackUncertainty: 14 - 4 * difficulty.
|
||||
p.shoot(
|
||||
&mut crate::projectiles::own_random(m),
|
||||
[dx, dy, dz],
|
||||
1.6,
|
||||
6.0,
|
||||
(14 - i32::from(cx.w.difficulty.id) * 4) as f32,
|
||||
);
|
||||
cx.events.push(MobEvent::Projectile(p));
|
||||
let pitch = 1.0 / (m.random.next_float() * 0.5 + 1.8) + 0.53;
|
||||
|
||||
@@ -25,6 +25,8 @@ pub struct Steps {
|
||||
pub move_dist: f32,
|
||||
pub fly_dist: f32,
|
||||
pub next_step: f32,
|
||||
/// `AbstractHorse.gallopSoundCounter`.
|
||||
pub gallop: i32,
|
||||
}
|
||||
impl Default for Steps {
|
||||
fn default() -> Self {
|
||||
@@ -32,6 +34,7 @@ impl Default for Steps {
|
||||
move_dist: 0.0,
|
||||
fly_dist: 0.0,
|
||||
next_step: 1.0,
|
||||
gallop: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,7 +709,11 @@ impl Mob {
|
||||
&& (self.on_ground || climbable)
|
||||
&& !self.fluids_swimming();
|
||||
if produced {
|
||||
if let Some((sound, volume, pitch)) =
|
||||
if super::equine::hoof_steps(self.species()) {
|
||||
if !self.silent {
|
||||
super::equine::play_step(self, w, legacy, legacy_state, events);
|
||||
}
|
||||
} else if let Some((sound, volume, pitch)) =
|
||||
super::ai::step_sound(self, w, legacy, legacy_state)
|
||||
&& !self.silent
|
||||
{
|
||||
@@ -863,10 +870,13 @@ impl Mob {
|
||||
}
|
||||
if damage > 0.0 {
|
||||
let at = [self.position.x, self.position.y, self.position.z];
|
||||
let sound = if damage > 4.0 {
|
||||
"entity.generic.big_fall"
|
||||
} else {
|
||||
"entity.generic.small_fall"
|
||||
// Monster.getFallSounds: the hostile pair.
|
||||
let monster = super::ai::is_monster(self.species());
|
||||
let sound = match (monster, damage > 4.0) {
|
||||
(true, true) => "entity.hostile.big_fall",
|
||||
(true, false) => "entity.hostile.small_fall",
|
||||
(false, true) => "entity.generic.big_fall",
|
||||
(false, false) => "entity.generic.small_fall",
|
||||
};
|
||||
events.push(MobEvent::Sound(sound, at, 1.0, 1.0));
|
||||
self.damage(damage as f32, super::Damage::fall());
|
||||
|
||||
@@ -169,7 +169,8 @@ pub fn register_hoglin(m: &mut Mob) {
|
||||
let g = &mut m.ai.goals;
|
||||
g.add(0, Float);
|
||||
g.add(1, BabyPanic(super::goals::Panic::new(1.25)));
|
||||
g.add(2, MeleeAttack::new(1.0, true));
|
||||
// HoglinAi: MeleeAttack.create(40) for adults, 15 for babies.
|
||||
g.add(2, MeleeAttack::new(1.0, true).intervals(40, 15));
|
||||
g.add(3, super::goals::Breed::new(1.0));
|
||||
g.add(3, HoglinAvoidRepellent);
|
||||
g.add(4, super::goals::FollowParent::new(1.1));
|
||||
@@ -185,7 +186,8 @@ pub fn register_hoglin(m: &mut Mob) {
|
||||
pub fn register_zoglin(m: &mut Mob) {
|
||||
let g = &mut m.ai.goals;
|
||||
g.add(0, Float);
|
||||
g.add(2, MeleeAttack::new(1.0, true));
|
||||
// Zoglin.initMemories: MeleeAttack.create(40) for adults, 15 for babies.
|
||||
g.add(2, MeleeAttack::new(1.0, true).intervals(40, 15));
|
||||
g.add(5, Stroll::avoiding_water(0.4));
|
||||
g.add(6, LookAtPlayer::new(8.0));
|
||||
m.ai.targets.add(1, HurtByTarget::new());
|
||||
|
||||
@@ -20,12 +20,32 @@ pub type Atlas<'a> = &'a dyn Fn(&str) -> Option<[f32; 4]>;
|
||||
/// the model UV and reads `enchanted_glint_armor`.
|
||||
pub const ARMOR_GLINT: u32 = 13;
|
||||
|
||||
/// `RenderTypes.breezeWind`: the texture of the breeze's wind layer scrolls
|
||||
/// by 0.02 of the texture per tick.
|
||||
pub const BREEZE_WIND_SCROLL: f32 = 0.02;
|
||||
/// `WindChargeRenderer.xOffset`: the wind charge's texture scrolls by 0.03 of
|
||||
/// the texture per tick.
|
||||
pub const WIND_CHARGE_SCROLL: f32 = 0.03;
|
||||
/// The offset of a scrolling texture: `RenderTypes.breezeWind` takes the
|
||||
/// texture's age times the speed, modulo one.
|
||||
pub fn scroll_offset(age: f32, speed: f32) -> f32 {
|
||||
(age * speed) % 1.0
|
||||
}
|
||||
/// The vertex's U with the scroll: the texture matrix of
|
||||
/// `RenderTypes.breezeWind` moves the texture under the model, so the U
|
||||
/// moves by the offset and wraps around the texture.
|
||||
fn scroll_u(u: f32, scroll: f32) -> f32 {
|
||||
if scroll == 0.0 { u } else { (u + scroll) % 1.0 }
|
||||
}
|
||||
|
||||
/// One draw of a model with a texture.
|
||||
pub struct Layer {
|
||||
pub model: String,
|
||||
pub texture: String,
|
||||
pub color: u32,
|
||||
pub full_bright: bool,
|
||||
/// `RenderTypes.breezeWind`: the texture scrolls with the entity's age.
|
||||
pub scroll: f32,
|
||||
}
|
||||
/// DyeColor.getTextureDiffuseColor (RGB).
|
||||
pub const DYE: [u32; 16] = [
|
||||
@@ -437,6 +457,7 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
|
||||
texture: texture.into(),
|
||||
color,
|
||||
full_bright,
|
||||
scroll: 0.0,
|
||||
})
|
||||
};
|
||||
match m.species() {
|
||||
@@ -490,16 +511,17 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
|
||||
push(&mut v, "happy_ghast_harness#main", &texture, white, false);
|
||||
}
|
||||
}
|
||||
// BreezeWindLayer (the scroll is not reproduced: atlas sprites do
|
||||
// not wrap) and BreezeEyesLayer.
|
||||
// BreezeWindLayer and BreezeEyesLayer. The wind layer's texture
|
||||
// scrolls (`RenderTypes.breezeWind`): the offset is taken inside the
|
||||
// sprite, so the atlas does not have to wrap.
|
||||
S::Breeze => {
|
||||
push(
|
||||
&mut v,
|
||||
"breeze#wind",
|
||||
"entity/breeze/breeze_wind",
|
||||
white,
|
||||
false,
|
||||
);
|
||||
v.push(Layer {
|
||||
model: "breeze#wind".into(),
|
||||
texture: "entity/breeze/breeze_wind".into(),
|
||||
color: white,
|
||||
full_bright: false,
|
||||
scroll: scroll_offset(m.age as f32, BREEZE_WIND_SCROLL),
|
||||
});
|
||||
push(
|
||||
&mut v,
|
||||
"breeze#eyes",
|
||||
@@ -827,6 +849,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
|
||||
texture: format!("entity/equipment/{folder}/{asset}"),
|
||||
color: tint,
|
||||
full_bright: false,
|
||||
scroll: 0.0,
|
||||
});
|
||||
if asset == "leather" {
|
||||
v.push(Layer {
|
||||
@@ -834,6 +857,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
|
||||
texture: format!("entity/equipment/{folder}/leather_overlay"),
|
||||
color: 0xFFFF_FFFF,
|
||||
full_bright: false,
|
||||
scroll: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1046,6 +1070,23 @@ pub fn draw(
|
||||
light: u32,
|
||||
overlay: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
draw_scrolled(part, base, poses, uv, 0.0, color, light, overlay, out);
|
||||
}
|
||||
/// `draw` for a texture that scrolls: the vertex's U is moved by the offset
|
||||
/// and wraps inside the texture, as the texture matrix of
|
||||
/// `RenderTypes.breezeWind` does.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw_scrolled(
|
||||
part: &Part,
|
||||
base: glam::Mat4,
|
||||
poses: &Poses,
|
||||
uv: [f32; 4],
|
||||
scroll: f32,
|
||||
color: u32,
|
||||
light: u32,
|
||||
overlay: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
draw_normals(
|
||||
part,
|
||||
@@ -1053,6 +1094,7 @@ pub fn draw(
|
||||
ModelNormals::new(base),
|
||||
poses,
|
||||
uv,
|
||||
scroll,
|
||||
color,
|
||||
light,
|
||||
overlay,
|
||||
@@ -1066,6 +1108,7 @@ fn draw_normals(
|
||||
normals: ModelNormals,
|
||||
poses: &Poses,
|
||||
uv: [f32; 4],
|
||||
scroll: f32,
|
||||
color: u32,
|
||||
light: u32,
|
||||
overlay: u32,
|
||||
@@ -1103,7 +1146,7 @@ fn draw_normals(
|
||||
normal,
|
||||
pos: pos.to_array(),
|
||||
uv: [
|
||||
uv[0] + (uv[2] - uv[0]) * v[3],
|
||||
uv[0] + (uv[2] - uv[0]) * scroll_u(v[3], scroll),
|
||||
uv[1] + (uv[3] - uv[1]) * v[4],
|
||||
],
|
||||
layer: overlay,
|
||||
@@ -1115,7 +1158,7 @@ fn draw_normals(
|
||||
}
|
||||
}
|
||||
for c in &part.children {
|
||||
draw_normals(c, m, normals, poses, uv, color, light, overlay, out);
|
||||
draw_normals(c, m, normals, poses, uv, scroll, color, light, overlay, out);
|
||||
}
|
||||
}
|
||||
/// Render a mob with its variant model, texture and layers.
|
||||
@@ -1236,6 +1279,7 @@ fn render_visible(
|
||||
texture: texture.into(),
|
||||
color: ((a.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF,
|
||||
full_bright: true,
|
||||
scroll: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1277,11 +1321,12 @@ fn render_visible(
|
||||
None => overlay,
|
||||
};
|
||||
let start = out.len();
|
||||
draw(
|
||||
draw_scrolled(
|
||||
&part,
|
||||
base,
|
||||
&layer_poses,
|
||||
uv,
|
||||
layer.scroll,
|
||||
layer.color,
|
||||
light,
|
||||
overlay,
|
||||
@@ -1671,3 +1716,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scroll_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_scrolling_texture_wraps_around() {
|
||||
// RenderTypes.breezeWind: the breeze's wind layer's texture scrolls
|
||||
// by 0.02 of the texture per tick, the wind charge's by 0.03.
|
||||
assert_eq!(scroll_offset(0.0, BREEZE_WIND_SCROLL), 0.0);
|
||||
assert!((scroll_offset(25.0, BREEZE_WIND_SCROLL) - 0.5).abs() < 1e-6);
|
||||
assert!((scroll_offset(50.0, BREEZE_WIND_SCROLL) - 0.0).abs() < 1e-6);
|
||||
assert!((scroll_offset(50.0, WIND_CHARGE_SCROLL) - 0.5).abs() < 1e-6);
|
||||
assert!((scroll_offset(100.0, WIND_CHARGE_SCROLL) - 0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrolled_u_wraps_inside_the_texture() {
|
||||
// Without a scroll the U is the vertex's own.
|
||||
assert_eq!(scroll_u(0.25, 0.0), 0.25);
|
||||
assert_eq!(scroll_u(1.0, 0.0), 1.0);
|
||||
// With one, it moves and wraps around the texture.
|
||||
assert!((scroll_u(0.25, 0.5) - 0.75).abs() < 1e-6);
|
||||
assert!((scroll_u(0.75, 0.5) - 0.25).abs() < 1e-6);
|
||||
assert!((scroll_u(1.0, 0.5) - 0.5).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -593,3 +593,65 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
|
||||
p.xrot("head", s.x_rot.to_radians());
|
||||
p.yrot("head", (s.y_rot - 180.0).to_radians());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mobs::sensor_scan_tests::kind;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A shulker fixed to a block face.
|
||||
fn shulker() -> Mob {
|
||||
let mut kind = kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:shulker".into();
|
||||
k.species = Species::Shulker;
|
||||
k.max_head_y_rot = 180.0;
|
||||
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
m.extra.variant3 = 2;
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_body_stays_square_while_the_head_turns() {
|
||||
// Shulker.recreateFromPacket sets the body rotation to 0 and
|
||||
// Shulker.ShulkerBodyRotationControl.clientTick is empty, so the
|
||||
// shulker's body never turns: it stays square to the block it is
|
||||
// fixed to and only its head turns (#1661).
|
||||
let mut m = shulker();
|
||||
m.yaw = 90.0;
|
||||
m.head_yaw = 90.0;
|
||||
// It moved, so a body that follows the mob would turn with it.
|
||||
m.previous = Vec3::new(1.0, 0.0, 0.0);
|
||||
m.body_rotation_tick();
|
||||
assert_eq!(
|
||||
m.body_yaw, 0.0,
|
||||
"the shulker's body stays square to the block"
|
||||
);
|
||||
assert_eq!(m.previous_body_yaw, 0.0);
|
||||
// A mob that moves turns its body to where it goes.
|
||||
let mut other = Mob::new(2, kind(), Vec3::ZERO, 0.0);
|
||||
other.yaw = 90.0;
|
||||
other.previous = Vec3::new(1.0, 0.0, 0.0);
|
||||
other.body_rotation_tick();
|
||||
assert_eq!(other.body_yaw, 90.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_head_turns_to_its_target() {
|
||||
// ShulkerLookControl.clampHeadRotationToBody does nothing, so the
|
||||
// head is not clamped to the square body: it turns to the target.
|
||||
let mut m = shulker();
|
||||
m.ai.look.kind = LookKind::Shulker;
|
||||
for _ in 0..20 {
|
||||
// A goal keeps the look up to date every tick.
|
||||
m.ai.look.set_look_at([5.0, 0.0, 0.0], 10.0, 10.0);
|
||||
m.look_control_tick();
|
||||
}
|
||||
assert!(
|
||||
m.head_yaw.abs() > 45.0,
|
||||
"the head turns to the target, not back to the square body"
|
||||
);
|
||||
assert_eq!(m.body_yaw, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -23,61 +23,7 @@ use horizon_core::{
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
|
||||
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
|
||||
list.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let count = &e["count"];
|
||||
let (min, max, constant) = match count {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c if c["type"].as_str() == Some("minecraft:constant") => {
|
||||
let n = c["value"].as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c => (
|
||||
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
false,
|
||||
),
|
||||
};
|
||||
Some(SpawnEntry {
|
||||
entity: e["type"].as_str()?.to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min,
|
||||
max,
|
||||
constant,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `MobSpawnSettings.MobSpawnCost`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SpawnCost {
|
||||
pub energy_budget: f64,
|
||||
pub charge: f64,
|
||||
}
|
||||
/// The `spawn_costs` of a `natural_mob_spawns` value.
|
||||
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
|
||||
costs
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, c)| {
|
||||
(
|
||||
k.clone(),
|
||||
SpawnCost {
|
||||
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
|
||||
charge: c["charge"].as_f64().unwrap_or(0.0),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub use crate::biome::{SpawnCost, parse_costs, parse_entries};
|
||||
|
||||
/// `MobCategory`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
|
||||
@@ -168,6 +168,12 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
|
||||
Climate::Warm => 1,
|
||||
Climate::Cold => 2,
|
||||
};
|
||||
// `Registry.getRandom` of the `*_sound_variant` registry (pig, cow,
|
||||
// chicken, cat and wolf), drawn by their finalizeSpawn.
|
||||
let sounds = super::finalize::sound_variants(m.species()).len();
|
||||
if sounds > 0 {
|
||||
m.extra.sound_variant = Some(m.random.next_int_bound(sounds as i32).unwrap_or(0) as u8);
|
||||
}
|
||||
match m.species() {
|
||||
// Warden.finalizeSpawn: a minute before it digs back down.
|
||||
S::Warden => super::warden::finalize(m, false, &mut Vec::new()),
|
||||
@@ -301,6 +307,17 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
|
||||
}
|
||||
S::Fox | S::Panda | S::Dolphin => m.extra.pick_up_loot = true,
|
||||
S::TropicalFish => super::aquatic::finalize(m),
|
||||
// Salmon.finalizeSpawn: a weighted size (small 30, medium 50, large 15).
|
||||
S::Salmon => {
|
||||
let r = m.random.next_int_bound(95).unwrap_or(0);
|
||||
m.extra.variant = if r < 30 {
|
||||
0
|
||||
} else if r < 80 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
}
|
||||
S::Slime | S::MagmaCube => super::cube::finalize(m),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -1602,6 +1602,8 @@ impl Def {
|
||||
pub fn water_slowdown(&self) -> f32 {
|
||||
match self.species {
|
||||
Species::PolarBear => 0.98,
|
||||
// SkeletonHorse.getWaterSlowDown.
|
||||
Species::SkeletonHorse => 0.96,
|
||||
_ => 0.8,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,15 +529,14 @@ struct Beg {
|
||||
ticks: i32,
|
||||
}
|
||||
impl Beg {
|
||||
fn interesting(&self, m: &Mob, w: &MobWorld) -> bool {
|
||||
fn interesting(&self, w: &MobWorld) -> bool {
|
||||
let Some(p) = &w.player else {
|
||||
return false;
|
||||
};
|
||||
let held = |i: &Option<std::sync::Arc<str>>| {
|
||||
i.as_deref().is_some_and(|i| {
|
||||
(!m.extra.tame && i == "minecraft:bone")
|
||||
|| (m.extra.tame && self.food.iter().any(|f| f == i))
|
||||
})
|
||||
// `BegGoal.playerHoldingInteresting`: a bone or food, tame or not.
|
||||
i.as_deref()
|
||||
.is_some_and(|i| i == "minecraft:bone" || self.food.iter().any(|f| f == i))
|
||||
};
|
||||
p.alive && !p.spectator && (held(&p.main_hand) || held(&p.off_hand))
|
||||
}
|
||||
@@ -551,14 +550,14 @@ impl Goal for Beg {
|
||||
cx.w.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| m.distance_sqr(p.position) <= 64.0)
|
||||
&& self.interesting(m, cx.w)
|
||||
&& self.interesting(cx.w)
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
cx.w.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.alive && m.distance_sqr(p.position) <= 64.0)
|
||||
&& self.ticks > 0
|
||||
&& self.interesting(m, cx.w)
|
||||
&& self.interesting(cx.w)
|
||||
}
|
||||
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
|
||||
m.extra.flag = true;
|
||||
@@ -829,7 +828,8 @@ impl Goal for CatTempt {
|
||||
// Cat.tick: a wild cat being tempted begs every 100 ticks.
|
||||
if m.age.is_multiple_of(100) {
|
||||
cx.events.push(MobEvent::Sound(
|
||||
"entity.cat.beg_for_food",
|
||||
super::finalize::variant_sound(m, "beg_for_food")
|
||||
.unwrap_or("entity.cat.beg_for_food"),
|
||||
m.center(),
|
||||
1.0,
|
||||
1.0,
|
||||
@@ -1197,7 +1197,8 @@ pub fn cat_ai_step(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
cat_amounts(m);
|
||||
if (m.extra.flag || m.extra.relax_one) && m.age.is_multiple_of(5) {
|
||||
let volume = 0.6 + 0.4 * (m.random.next_float() - m.random.next_float());
|
||||
events.push(MobEvent::Sound("entity.cat.purr", m.center(), volume, 1.0));
|
||||
let sound = super::finalize::variant_sound(m, "purr").unwrap_or("entity.cat.purr");
|
||||
events.push(MobEvent::Sound(sound, m.center(), volume, 1.0));
|
||||
}
|
||||
}
|
||||
/// The lying and relaxing amounts of `Cat.tick` (also a client's, from the
|
||||
|
||||
@@ -106,6 +106,67 @@ impl TrialConfig {
|
||||
.unwrap_or(d.items_to_drop_when_ominous),
|
||||
})
|
||||
}
|
||||
/// `TrialSpawnerConfig.DIRECT_CODEC`'s encoding: the fields that differ
|
||||
/// from their defaults (`optionalFieldOf` with a default).
|
||||
pub fn encode(&self) -> Tag {
|
||||
let d = Self::default();
|
||||
let mut out = Vec::new();
|
||||
if self.spawn_range != d.spawn_range {
|
||||
out.push(("spawn_range", Tag::Int(self.spawn_range)));
|
||||
}
|
||||
for (key, value, default) in [
|
||||
("total_mobs", self.total_mobs, d.total_mobs),
|
||||
(
|
||||
"simultaneous_mobs",
|
||||
self.simultaneous_mobs,
|
||||
d.simultaneous_mobs,
|
||||
),
|
||||
(
|
||||
"total_mobs_added_per_player",
|
||||
self.total_mobs_added_per_player,
|
||||
d.total_mobs_added_per_player,
|
||||
),
|
||||
(
|
||||
"simultaneous_mobs_added_per_player",
|
||||
self.simultaneous_mobs_added_per_player,
|
||||
d.simultaneous_mobs_added_per_player,
|
||||
),
|
||||
] {
|
||||
if value != default {
|
||||
out.push((key, Tag::Float(value.to_bits())));
|
||||
}
|
||||
}
|
||||
if self.ticks_between_spawn != d.ticks_between_spawn {
|
||||
out.push(("ticks_between_spawn", Tag::Int(self.ticks_between_spawn)));
|
||||
}
|
||||
let weighted = |data: Tag, weight: i32| {
|
||||
Tag::compound(vec![("data", data), ("weight", Tag::Int(weight))])
|
||||
};
|
||||
if !self.spawn_potentials.is_empty() {
|
||||
let list = self.spawn_potentials.iter();
|
||||
out.push((
|
||||
"spawn_potentials",
|
||||
Tag::List(list.map(|(data, w)| weighted(data.encode(), *w)).collect()),
|
||||
));
|
||||
}
|
||||
if self.loot_tables_to_eject != d.loot_tables_to_eject {
|
||||
let list = self.loot_tables_to_eject.iter();
|
||||
out.push((
|
||||
"loot_tables_to_eject",
|
||||
Tag::List(
|
||||
list.map(|(id, w)| weighted(Tag::String(id.as_str().into()), *w))
|
||||
.collect(),
|
||||
),
|
||||
));
|
||||
}
|
||||
if self.items_to_drop_when_ominous != d.items_to_drop_when_ominous {
|
||||
out.push((
|
||||
"items_to_drop_when_ominous",
|
||||
Tag::String(self.items_to_drop_when_ominous.as_str().into()),
|
||||
));
|
||||
}
|
||||
Tag::compound(out)
|
||||
}
|
||||
/// `calculateTargetTotalMobs`.
|
||||
pub fn target_total_mobs(&self, additional_players: i32) -> i32 {
|
||||
(self.total_mobs + self.total_mobs_added_per_player * additional_players as f32).floor()
|
||||
@@ -487,6 +548,18 @@ impl TrialSpawner {
|
||||
self.next_spawn_data = None;
|
||||
self.reset_statistics();
|
||||
}
|
||||
/// `overrideEntityToSpawn` (a spawn egg's `setEntityId`): the data resets
|
||||
/// and both configs spawn only `id` (`withSpawning`), saved inline as
|
||||
/// `Holder.direct`. The caller then sets the state to `inactive`.
|
||||
pub fn override_entity(&mut self, id: &str) {
|
||||
self.reset();
|
||||
for slot in [&mut self.normal, &mut self.ominous] {
|
||||
let mut entity = Compound::new();
|
||||
entity.insert("id".into(), Tag::String(id.into()));
|
||||
slot.config.spawn_potentials = vec![(SpawnData::new(entity, None, None), 1)];
|
||||
slot.saved = Some(slot.config.encode());
|
||||
}
|
||||
}
|
||||
/// `getOrCreateNextSpawnData`: a potential at random, else empty data.
|
||||
fn next_or_create(
|
||||
&mut self,
|
||||
@@ -1030,6 +1103,37 @@ mod tests {
|
||||
assert!(s.save().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spawn_egg_overrides_both_configs_inline() {
|
||||
let breeze = TrialConfig {
|
||||
total_mobs: 2.0,
|
||||
..TrialConfig::default()
|
||||
};
|
||||
let registry = |_: &str| Some(breeze.clone());
|
||||
let mut s = TrialSpawner::load_with(
|
||||
&tag(
|
||||
r#"{normal_config:"minecraft:trial_chamber/breeze/normal",spawn_data:{entity:{}},total_mobs_spawned:3}"#,
|
||||
),
|
||||
®istry,
|
||||
);
|
||||
s.override_entity("minecraft:zombie");
|
||||
assert_eq!(s.next_spawn_data, None);
|
||||
assert_eq!(s.total_mobs_spawned, 0);
|
||||
for slot in [&s.normal, &s.ominous] {
|
||||
assert_eq!(slot.config.spawn_potentials.len(), 1);
|
||||
assert_eq!(
|
||||
slot.config.spawn_potentials[0].0.id().as_deref(),
|
||||
Some("minecraft:zombie")
|
||||
);
|
||||
let saved = slot.saved.as_ref().expect("saved inline");
|
||||
assert_eq!(TrialConfig::decode(saved).as_ref(), Some(&slot.config));
|
||||
}
|
||||
assert_eq!(
|
||||
s.normal.saved.as_ref().and_then(|t| t.get("total_mobs")),
|
||||
Some(&Tag::Float(2.0f32.to_bits()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_configs_resolve_and_are_saved_by_id() {
|
||||
let breeze = TrialConfig {
|
||||
|
||||
@@ -542,6 +542,47 @@ mod tests {
|
||||
assert_eq!(m.velocity.y, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_leg_animation_runs_at_vanilla_s_rate() {
|
||||
// LivingEntity.updateWalkAnimation turns the distance a tick into
|
||||
// min(distance * 4, 1), and TurtleModel.setupAnim swings the legs
|
||||
// by 8 * that speed, so the rate of the walk animation is the rate
|
||||
// of the walk. The turtle walks at its own pace, so the animation
|
||||
// runs at vanilla's rate instead of at the maximum (#1776).
|
||||
let mut m = strolling_turtle();
|
||||
let mut last = m.position;
|
||||
let mut distance = 0.0_f64;
|
||||
for _ in 0..40 {
|
||||
// Entity.tick keeps the position the tick started at.
|
||||
m.previous = last;
|
||||
// TurtleMoveControl.tick: the forward input.
|
||||
move_control(&mut m);
|
||||
// LivingEntity.travelInAir on grass: the friction influenced
|
||||
// speed is the input speed (grass' friction is 0.6, so it is
|
||||
// not above it), the move uses the motion as it is, and the
|
||||
// motion is then scaled by the friction (0.6) times the air
|
||||
// drag (0.91). The move itself needs a world, so the flat
|
||||
// ground is stepped here.
|
||||
m.move_relative(m.ai.speed, Vec3::new(0.0, 0.0, 1.0));
|
||||
m.position.x += m.velocity.x;
|
||||
m.position.z += m.velocity.z;
|
||||
let drag = f64::from(0.6_f32 * 0.91_f32);
|
||||
m.velocity.x *= drag;
|
||||
m.velocity.z *= drag;
|
||||
// LivingEntity.updateWalkAnimation.
|
||||
m.update_walk_animation();
|
||||
distance = (m.position.x - m.previous.x).hypot(m.position.z - m.previous.z);
|
||||
last = m.position;
|
||||
}
|
||||
// The ground speed: the input (0.06 + 0.125 * (0.25 - 0.06) =
|
||||
// 0.08375) over one minus the drag (0.546) = 0.18447 blocks a tick.
|
||||
assert!((distance - 0.083_75 / (1.0 - 0.546)).abs() < 1.0e-6);
|
||||
// min(distance * 4, 1) for that pace: 0.73789. The generic
|
||||
// MoveControl walked the turtle at the full 0.25, which put it at
|
||||
// 0.55066 blocks a tick and saturated the walk animation at 1.0.
|
||||
assert!((m.limb_speed - 0.737_885_5).abs() < 1.0e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn water_speed_is_cut_away_from_home_and_for_babies() {
|
||||
// updateSpeed in water near home: no cut, the lerp climbs towards
|
||||
|
||||
@@ -318,7 +318,11 @@ pub fn work_sound(profession: u8) -> Option<&'static str> {
|
||||
pub fn crop(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
matches!(
|
||||
w.block_name(p[0], p[1], p[2]),
|
||||
"minecraft:wheat" | "minecraft:carrots" | "minecraft:potatoes" | "minecraft:beetroots"
|
||||
"minecraft:wheat"
|
||||
| "minecraft:carrots"
|
||||
| "minecraft:potatoes"
|
||||
| "minecraft:beetroots"
|
||||
| "minecraft:torchflower_crop"
|
||||
)
|
||||
}
|
||||
/// `CropBlock.isMaxAge` for the block at `p`.
|
||||
@@ -326,6 +330,8 @@ pub fn ripe_crop(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
let max = match w.block_name(p[0], p[1], p[2]) {
|
||||
"minecraft:wheat" | "minecraft:carrots" | "minecraft:potatoes" => "7",
|
||||
"minecraft:beetroots" => "3",
|
||||
// TorchflowerCropBlock.getMaxAge is 2, past its age property (0-1):
|
||||
// the crop is never at max age (age 2 is the torchflower itself).
|
||||
_ => return false,
|
||||
};
|
||||
w.prop(p[0], p[1], p[2], "age") == Some(max)
|
||||
|
||||
@@ -204,6 +204,8 @@ pub struct Memories {
|
||||
pub nearest_hostile: Slot<Who>,
|
||||
pub hiding_place: Slot<[i32; 3]>,
|
||||
pub heard_bell_time: Slot<i64>,
|
||||
/// `NEAREST_VISIBLE_WANTED_ITEM`: where the item entity lies.
|
||||
pub nearest_wanted_item: Slot<[f64; 3]>,
|
||||
pub cant_reach_walk_target_since: Slot<i64>,
|
||||
pub golem_detected_recently: Slot<bool>,
|
||||
pub last_slept: Slot<i64>,
|
||||
@@ -236,6 +238,7 @@ impl Memories {
|
||||
self.nearest_hostile.tick();
|
||||
self.hiding_place.tick();
|
||||
self.heard_bell_time.tick();
|
||||
self.nearest_wanted_item.tick();
|
||||
self.cant_reach_walk_target_since.tick();
|
||||
self.golem_detected_recently.tick();
|
||||
self.last_slept.tick();
|
||||
@@ -322,6 +325,8 @@ pub struct Brain {
|
||||
baby: bool,
|
||||
/// Whether the nearest-bed sensor's batch cache is used (`NearestBedSensor`).
|
||||
bed_cache: Vec<([i32; 3], i64)>,
|
||||
/// `NearestItemSensor` is due; the game, which knows the items, answers.
|
||||
scan_items: bool,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
struct Entry {
|
||||
@@ -362,6 +367,7 @@ impl Brain {
|
||||
shuffle_seed,
|
||||
baby,
|
||||
bed_cache: Vec::new(),
|
||||
scan_items: false,
|
||||
};
|
||||
b.list = packages(baby, shuffle_seed, farmer);
|
||||
b
|
||||
@@ -556,7 +562,7 @@ fn tick_sensors(m: &mut Mob, w: &MobWorld, lr: &mut LegacyRandom) {
|
||||
match i {
|
||||
0 => sense_living(m, w),
|
||||
1 => sense_players(m, w),
|
||||
2 => {}
|
||||
2 => brain_mut(m).scan_items = true,
|
||||
3 => sense_bed(m, w, lr),
|
||||
4 => sense_hurt(m, w),
|
||||
5 => sense_hostiles(m, w),
|
||||
@@ -1245,7 +1251,16 @@ enum B {
|
||||
valid: Vec<[i32; 3]>,
|
||||
worked: i32,
|
||||
},
|
||||
UseBonemeal(Timed),
|
||||
UseBonemeal {
|
||||
t: Timed,
|
||||
next_work: i64,
|
||||
last_session: i64,
|
||||
worked: i32,
|
||||
crop: Option<[i32; 3]>,
|
||||
/// The next crop is picked on the next tick, when the level shows the
|
||||
/// age the meal gave (vanilla grows the crop at once).
|
||||
repick: bool,
|
||||
},
|
||||
WalkToBlockMemory {
|
||||
running: bool,
|
||||
which: PoiMem,
|
||||
@@ -1347,13 +1362,13 @@ impl B {
|
||||
| B::GoToPotentialJobSite(t)
|
||||
| B::DoNothing(t)
|
||||
| B::TradeWithVillager(t)
|
||||
| B::UseBonemeal(t)
|
||||
| B::Celebrate(t) => t.running,
|
||||
B::MoveToTargetSink { t, .. }
|
||||
| B::GiveGiftToHero { t, .. }
|
||||
| B::ShowTrades { t, .. }
|
||||
| B::WorkAtPoi { t, .. }
|
||||
| B::HarvestFarmland { t, .. }
|
||||
| B::UseBonemeal { t, .. }
|
||||
| B::JumpOnBed { t, .. }
|
||||
| B::MakeLove { t, .. }
|
||||
| B::SleepInBed { t, .. } => t.running,
|
||||
@@ -1679,17 +1694,38 @@ impl B {
|
||||
}
|
||||
true
|
||||
}
|
||||
B::UseBonemeal(t) => {
|
||||
B::UseBonemeal {
|
||||
t,
|
||||
next_work,
|
||||
last_session,
|
||||
worked,
|
||||
crop,
|
||||
repick,
|
||||
} => {
|
||||
let mm = mem(m);
|
||||
if mm.look_target.present() || mm.walk_target.present() {
|
||||
return false;
|
||||
}
|
||||
// Bone meal in the inventory is needed; checked every 10 ticks.
|
||||
let has = super::villager::ext(m)
|
||||
.is_some_and(|e| e.count_item("minecraft:bone_meal") > 0);
|
||||
let _ = t;
|
||||
let _ = has;
|
||||
false
|
||||
// Checked every 10 ticks, and not within 160 ticks of the last session.
|
||||
let age = i64::from(m.age);
|
||||
if age % 10 != 0 || (*last_session != 0 && *last_session + 160 > age) {
|
||||
return false;
|
||||
}
|
||||
if super::villager::ext(m).is_none_or(|e| e.count_item("minecraft:bone_meal") == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
*crop = pick_bonemeal_crop(m, cx);
|
||||
if crop.is_none() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx.lr, cx.now);
|
||||
bonemeal_target(m, *crop);
|
||||
m.extra.main_hand = Some("minecraft:bone_meal".into());
|
||||
*next_work = cx.now;
|
||||
*worked = 0;
|
||||
*repick = false;
|
||||
true
|
||||
}
|
||||
// -- OneShots.
|
||||
B::InteractWithDoor {
|
||||
@@ -1824,7 +1860,32 @@ impl B {
|
||||
},
|
||||
r,
|
||||
),
|
||||
B::GoToWantedItem(_) => false,
|
||||
B::GoToWantedItem(r) => one(
|
||||
|| {
|
||||
let mm = mem(m);
|
||||
if mm.walk_target.present() {
|
||||
return false;
|
||||
}
|
||||
let Some(at) = mm.nearest_wanted_item.value() else {
|
||||
return false;
|
||||
};
|
||||
// item.closerThan(body, 4); a villager can pick up loot.
|
||||
if m.position.distance_squared(Vec3::new(at[0], at[1], at[2])) >= 16.0 {
|
||||
return false;
|
||||
}
|
||||
let mm = mem(m);
|
||||
// The item's eye height is 0.2125.
|
||||
mm.look_target
|
||||
.set(Tracker::Point([at[0], at[1] + 0.2125, at[2]]));
|
||||
mm.walk_target.set(WalkTarget {
|
||||
target: Tracker::Point(at),
|
||||
speed: 0.5,
|
||||
close: 0,
|
||||
});
|
||||
true
|
||||
},
|
||||
r,
|
||||
),
|
||||
B::AcquirePoi {
|
||||
running,
|
||||
filter,
|
||||
@@ -2494,16 +2555,7 @@ impl B {
|
||||
if dist_sqr(bell, block_of(m.position)) < 9
|
||||
&& cx.w.block_name(bell[0], bell[1], bell[2]) == "minecraft:bell"
|
||||
{
|
||||
cx.events.push(MobEvent::SoundId(
|
||||
"minecraft:block.bell.use".into(),
|
||||
[
|
||||
f64::from(bell[0]) + 0.5,
|
||||
f64::from(bell[1]) + 0.5,
|
||||
f64::from(bell[2]) + 0.5,
|
||||
],
|
||||
2.0,
|
||||
1.0,
|
||||
));
|
||||
cx.events.push(MobEvent::RingBell { bell });
|
||||
}
|
||||
true
|
||||
},
|
||||
@@ -2649,17 +2701,61 @@ impl B {
|
||||
if cx.lr.next_int_bound(10).unwrap_or(0) != 0 {
|
||||
return false;
|
||||
}
|
||||
// Who chases whom is in the other kids' brains: chase the
|
||||
// first friend.
|
||||
if let Some(&kid) = friends.first() {
|
||||
let mm = mem(m);
|
||||
mm.interaction_target.set(kid);
|
||||
mm.look_target.set(Tracker::Entity(kid, true));
|
||||
mm.walk_target.set(WalkTarget {
|
||||
target: Tracker::Entity(kid, false),
|
||||
speed: 0.6,
|
||||
close: 1,
|
||||
});
|
||||
// What a friend chases is its INTERACTION_TARGET.
|
||||
let chasing = |who: Who| match who {
|
||||
Who::Mob(id) => {
|
||||
cx.w.seen(id)
|
||||
.and_then(|s| s.villager.as_ref())
|
||||
.and_then(|v| v.interaction_target)
|
||||
}
|
||||
Who::Player => None,
|
||||
};
|
||||
let me = Who::Mob(m.id);
|
||||
if !friends.iter().any(|&f| chasing(f) == Some(me)) {
|
||||
// The kid chased by the fewest (one to five) chasers, else any friend.
|
||||
let mut chased: Vec<(Who, i32)> = Vec::new();
|
||||
for &f in &friends {
|
||||
if let Some(c) = chasing(f) {
|
||||
match chased.iter_mut().find(|(k, _)| *k == c) {
|
||||
Some(e) => e.1 += 1,
|
||||
None => chased.push((c, 1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
let target = chased
|
||||
.iter()
|
||||
.filter(|(_, n)| *n > 0 && *n <= 5)
|
||||
.min_by_key(|(_, n)| *n)
|
||||
.map(|&(k, _)| k)
|
||||
.or_else(|| friends.first().copied());
|
||||
if let Some(kid) = target {
|
||||
let mm = mem(m);
|
||||
mm.interaction_target.set(kid);
|
||||
mm.look_target.set(Tracker::Entity(kid, true));
|
||||
mm.walk_target.set(WalkTarget {
|
||||
target: Tracker::Entity(kid, false),
|
||||
speed: 0.6,
|
||||
close: 1,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Chased: run to a spot inside the village.
|
||||
let villages = village_sections(m, cx.w);
|
||||
for _ in 0..10 {
|
||||
let Some(p) = m.land_random_pos(cx.w, 20, 8) else {
|
||||
continue;
|
||||
};
|
||||
let at = [
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
];
|
||||
let section = [at[0] >> 4, at[1] >> 4, at[2] >> 4];
|
||||
if sections_to_village(&villages, section) <= 1 {
|
||||
mem(m).walk_target.set(WalkTarget::point(p, 0.6, 0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
},
|
||||
@@ -2974,7 +3070,45 @@ impl B {
|
||||
});
|
||||
}
|
||||
}
|
||||
B::UseBonemeal(_) => self.do_stop(m, cx),
|
||||
B::UseBonemeal {
|
||||
t,
|
||||
next_work,
|
||||
worked,
|
||||
crop,
|
||||
repick,
|
||||
..
|
||||
} => {
|
||||
if *repick {
|
||||
*repick = false;
|
||||
*crop = pick_bonemeal_crop(m, cx);
|
||||
bonemeal_target(m, *crop);
|
||||
}
|
||||
// canStillUse: fewer than 80 work cycles and a crop to work on.
|
||||
if now > t.end || *worked >= 80 || crop.is_none() {
|
||||
self.do_stop(m, cx);
|
||||
return;
|
||||
}
|
||||
let Some(p) = *crop else { return };
|
||||
if now >= *next_work && closer_to_center(p, m.position, 1.0) {
|
||||
let e = super::villager::ext_mut(m);
|
||||
// BoneMealItem.growCrop: a CropBlock below max age takes the meal.
|
||||
if let Some(slot) = e
|
||||
.inventory
|
||||
.iter()
|
||||
.position(|s| s.count > 0 && &*s.item == "minecraft:bone_meal")
|
||||
&& bonemeal_valid(cx.w, p)
|
||||
{
|
||||
e.inventory[slot].count -= 1;
|
||||
if e.inventory[slot].count == 0 {
|
||||
e.inventory.remove(slot);
|
||||
}
|
||||
cx.events.push(MobEvent::Bonemeal { pos: p });
|
||||
*repick = true;
|
||||
*next_work = now + 40;
|
||||
}
|
||||
*worked += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -3062,7 +3196,13 @@ impl B {
|
||||
t.running = false;
|
||||
mem(m).breed_target.clear();
|
||||
}
|
||||
B::UseBonemeal(t) => t.running = false,
|
||||
B::UseBonemeal {
|
||||
t, last_session, ..
|
||||
} => {
|
||||
t.running = false;
|
||||
m.extra.main_hand = None;
|
||||
*last_session = i64::from(m.age);
|
||||
}
|
||||
B::WorkAtPoi { t, .. } => t.running = false,
|
||||
B::HarvestFarmland {
|
||||
t, next_ok, worked, ..
|
||||
@@ -3330,6 +3470,39 @@ fn harvest_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `UseBonemeal.validPos`: a `CropBlock` below its max age.
|
||||
fn bonemeal_valid(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
super::villager::crop(w, p) && !super::villager::ripe_crop(w, p)
|
||||
}
|
||||
/// `UseBonemeal.pickNextTarget`: one of the valid blocks around the villager,
|
||||
/// each equally likely (reservoir sampling with the level random).
|
||||
fn pick_bonemeal_crop(m: &Mob, cx: &mut Bx) -> Option<[i32; 3]> {
|
||||
let c = block_of(m.position);
|
||||
let mut result = None;
|
||||
let mut count = 0;
|
||||
for x in -1..=1 {
|
||||
for y in -1..=1 {
|
||||
for z in -1..=1 {
|
||||
let p = [c[0] + x, c[1] + y, c[2] + z];
|
||||
if bonemeal_valid(cx.w, p) {
|
||||
count += 1;
|
||||
if cx.lr.next_int_bound(count).unwrap_or(1) == 0 {
|
||||
result = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
/// `UseBonemeal.setCurrentCropAsTarget`.
|
||||
fn bonemeal_target(m: &mut Mob, crop: Option<[i32; 3]>) {
|
||||
if let Some(p) = crop {
|
||||
let mm = mem(m);
|
||||
mm.look_target.set(Tracker::Block(p));
|
||||
mm.walk_target.set(WalkTarget::block(p, 0.5, 1));
|
||||
}
|
||||
}
|
||||
/// The remembered POI positions a behaviour reads.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum PoiMem {
|
||||
@@ -3825,7 +3998,17 @@ fn packages(baby: bool, seed: i64, farmer: bool) -> Vec<Entry> {
|
||||
},
|
||||
if farmer { 2 } else { 5 },
|
||||
),
|
||||
(B::UseBonemeal(timed(60, 60)), if farmer { 4 } else { 7 }),
|
||||
(
|
||||
B::UseBonemeal {
|
||||
t: timed(60, 60),
|
||||
next_work: 0,
|
||||
last_session: 0,
|
||||
worked: 0,
|
||||
crop: None,
|
||||
repick: false,
|
||||
},
|
||||
if farmer { 4 } else { 7 },
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -4208,6 +4391,21 @@ pub fn woken(m: &mut Mob, now: i64) {
|
||||
mem(m).last_woken.set(now);
|
||||
}
|
||||
}
|
||||
/// `BellBlockEntity.updateEntities`: a bell rung within 32 blocks sets
|
||||
/// `HEARD_BELL_TIME` to the game time.
|
||||
pub fn heard_bell(m: &mut Mob, now: i64) {
|
||||
if brain(m).is_some() {
|
||||
mem(m).heard_bell_time.set(now);
|
||||
}
|
||||
}
|
||||
/// Whether `NearestItemSensor` is due for this villager (it is then reset).
|
||||
pub fn take_item_scan(m: &mut Mob) -> bool {
|
||||
brain(m).is_some() && std::mem::take(&mut brain_mut(m).scan_items)
|
||||
}
|
||||
/// `NearestItemSensor.doTick`: the nearest wanted item in sight, or none.
|
||||
pub fn set_wanted_item(m: &mut Mob, at: Option<[f64; 3]>) {
|
||||
mem(m).nearest_wanted_item.set_opt(at);
|
||||
}
|
||||
/// What other villagers see of this one's brain.
|
||||
pub fn potential_job_site(m: &Mob) -> Option<[i32; 3]> {
|
||||
mem_ref(m).and_then(|mm| mm.potential_job_site.value())
|
||||
|
||||
@@ -489,7 +489,8 @@ pub fn can_receive_vibration(m: &Mob, _now: i64, ctx: &Context) -> bool {
|
||||
}
|
||||
/// `VibrationSystem.Listener.handleGameEvent` for the warden: a free
|
||||
/// listener within 16 blocks of its eyes schedules what it can hear.
|
||||
/// `occluded` is `VibrationSystem.Listener.isOccluded`.
|
||||
/// `occluded` is `VibrationSystem.Listener.isOccluded`. Returns true when a
|
||||
/// sneaking player's step was avoided (AVOID_VIBRATION) instead.
|
||||
pub fn listen(
|
||||
m: &mut Mob,
|
||||
now: i64,
|
||||
@@ -497,23 +498,35 @@ pub fn listen(
|
||||
pos: [f64; 3],
|
||||
ctx: &Context,
|
||||
occluded: impl Fn([f64; 3], [f64; 3]) -> bool,
|
||||
) {
|
||||
) -> bool {
|
||||
let eye = [m.position.x, m.position.y + m.kind.eye_height, m.position.z];
|
||||
let Some(st) = m.extra.warden.as_ref() else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
if st.vibration.current.is_some() {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let block = |p: [f64; 3]| p.map(|v| i64::from(v.floor() as i32));
|
||||
let (source, listener) = (block(pos), block(eye));
|
||||
let d = [0, 1, 2].map(|i| source[i] - listener[i]);
|
||||
if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 256
|
||||
|| !sculk::valid_vibration(sculk::warden_can_listen(event), event, ctx)
|
||||
if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 256 {
|
||||
return false;
|
||||
}
|
||||
// isValidVibration: the warden can trigger avoid vibration.
|
||||
let listens = sculk::warden_can_listen(event);
|
||||
if listens
|
||||
&& ctx.source == Some(Who::Player)
|
||||
&& !ctx.spectator
|
||||
&& ctx.stepping_carefully
|
||||
&& sculk::ignored_when_sneaking(event)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if !sculk::valid_vibration(listens, event, ctx)
|
||||
|| !can_receive_vibration(m, i64::from(m.age), ctx)
|
||||
|| occluded(pos, eye)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let d = [pos[0] - eye[0], pos[1] - eye[1], pos[2] - eye[2]];
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
@@ -529,6 +542,7 @@ pub fn listen(
|
||||
now,
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `Level.gameEvent` for the mobs' own movement events from `from` on, as
|
||||
/// the mob AI probe has no sculk layer: every warden's listener hears them.
|
||||
|
||||
@@ -1257,6 +1257,19 @@ impl MotionWorld<'_, '_, '_> {
|
||||
if magma && !self.player.shift_key_down {
|
||||
self.environment_hurt("minecraft:hot_floor", 1.0)?;
|
||||
}
|
||||
// TurtleEggBlock.stepOn: a step that is not careful may break an egg.
|
||||
if !self.player.shift_key_down {
|
||||
let state = self.block(on);
|
||||
self.resources
|
||||
.landing
|
||||
.step_on(
|
||||
self,
|
||||
state,
|
||||
on,
|
||||
horizon_world::block_landing::LandingEntityKind::Player,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// SlimeBlock.stepOn: a slow walk on slime drags the horizontal
|
||||
// speed (the client moves its own player, so not the server's copy).
|
||||
if slime && !damage_only && !self.player.shift_key_down {
|
||||
|
||||
@@ -734,7 +734,7 @@ impl Screen {
|
||||
motd.resolve_legacy(),
|
||||
Ping::player_count(*players),
|
||||
format!(
|
||||
"server_list/ping_{}",
|
||||
"gui/sprites/server_list/ping_{}",
|
||||
match ms {
|
||||
0..150 => 5,
|
||||
150..300 => 4,
|
||||
@@ -748,7 +748,7 @@ impl Screen {
|
||||
Text::translate("multiplayer.status.cannot_connect", &[])
|
||||
.with_color(0xFF0000),
|
||||
Text::empty(),
|
||||
"server_list/unreachable".to_owned(),
|
||||
"gui/sprites/server_list/unreachable".to_owned(),
|
||||
),
|
||||
_ => {
|
||||
// The pinging bars bounce, offset by the row.
|
||||
@@ -757,7 +757,7 @@ impl Screen {
|
||||
(
|
||||
Text::translate("multiplayer.status.pinging", &[]),
|
||||
Text::empty(),
|
||||
format!("server_list/pinging_{}", frame + 1),
|
||||
format!("gui/sprites/server_list/pinging_{}", frame + 1),
|
||||
)
|
||||
}
|
||||
};
|
||||
@@ -778,15 +778,7 @@ impl Screen {
|
||||
);
|
||||
}
|
||||
let icon_x = cx + ROW_WIDTH - 4.0 - 15.0;
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
&format!("gui/sprites/{sprite}"),
|
||||
icon_x,
|
||||
cy,
|
||||
10.0,
|
||||
8.0,
|
||||
u32::MAX,
|
||||
);
|
||||
batch.sprite_scaled(atlas, &sprite, icon_x, cy, 10.0, 8.0, u32::MAX);
|
||||
let sw = atlas.font.width_text(&status).ceil();
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
|
||||
@@ -767,9 +767,10 @@ impl Projectile {
|
||||
}
|
||||
ProjectileKind::Potion { .. } => "item/splash_potion",
|
||||
ProjectileKind::LlamaSpit => "entity/llama/llama_spit",
|
||||
ProjectileKind::ShulkerBullet { .. } => "entity/shulker/spark",
|
||||
// Drawn as `WindChargeModel` by the game.
|
||||
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge => return None,
|
||||
// Drawn as `WindChargeModel` and `ShulkerBulletModel` by the game.
|
||||
ProjectileKind::WindCharge
|
||||
| ProjectileKind::BreezeWindCharge
|
||||
| ProjectileKind::ShulkerBullet { .. } => return None,
|
||||
ProjectileKind::WitherSkull { dangerous: false } => "entity/wither/wither",
|
||||
ProjectileKind::WitherSkull { dangerous: true } => "entity/wither/wither_invulnerable",
|
||||
// Drawn as their models by the game.
|
||||
|
||||
@@ -221,6 +221,10 @@ pub struct Streaming {
|
||||
results: mpsc::Receiver<Done>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
/// Ticks since the columns and protos that fell out of range were swept.
|
||||
scan_age: u8,
|
||||
/// The centre and radius the sweep was last reset for.
|
||||
scan_area: ((i32, i32), i32),
|
||||
generating: FxHashSet<(i32, i32)>,
|
||||
generation_scan: GenerationScan,
|
||||
/// Proto chunks: `None` while checked out to a decoration or save job.
|
||||
@@ -555,6 +559,8 @@ impl Streaming {
|
||||
results,
|
||||
threads,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
scan_area: ((i32::MIN, i32::MIN), i32::MIN),
|
||||
generating: Default::default(),
|
||||
generation_scan: GenerationScan::default(),
|
||||
protos: Default::default(),
|
||||
@@ -893,6 +899,13 @@ impl Streaming {
|
||||
return (uploads, Vec::new());
|
||||
}
|
||||
ExtraCover::refresh(&mut self.cover, &self.extra);
|
||||
// The sweeps below walk every loaded column and proto chunk. Run them
|
||||
// at once when the view moves and every eighth tick otherwise: a
|
||||
// column can stay a little past the edge without anyone noticing.
|
||||
let area = (self.center, self.radius);
|
||||
let sweep = area != self.scan_area || self.scan_age >= 8;
|
||||
self.scan_area = area;
|
||||
self.scan_age = if sweep { 0 } else { self.scan_age + 1 };
|
||||
self.requeue();
|
||||
self.relight_edits(world);
|
||||
let deferred = std::mem::take(&mut self.deferred);
|
||||
@@ -967,66 +980,69 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unload far columns.
|
||||
let far: Vec<_> = world
|
||||
.chunks
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&(x, z)| !self.remote && self.ranges().unloaded(x, z, self.decorates))
|
||||
.collect();
|
||||
for key in &far {
|
||||
self.decorated.remove(key);
|
||||
}
|
||||
let mut removed = Vec::with_capacity(far.len());
|
||||
for key in &far {
|
||||
if let Some(chunk) = world.chunks.remove(key) {
|
||||
removed.push(chunk);
|
||||
let mut removed = Vec::new();
|
||||
if sweep {
|
||||
// Unload far columns.
|
||||
let far: Vec<_> = world
|
||||
.chunks
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&(x, z)| !self.remote && self.ranges().unloaded(x, z, self.decorates))
|
||||
.collect();
|
||||
for key in &far {
|
||||
self.decorated.remove(key);
|
||||
}
|
||||
self.lit.remove(key);
|
||||
self.meshed.remove(key);
|
||||
self.lighting.remove(key);
|
||||
self.light_snapshots.remove(key);
|
||||
}
|
||||
if !far.is_empty() {
|
||||
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
|
||||
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
|
||||
self.waiting.retain(|k| !far.contains(&(k.0, k.2)));
|
||||
world
|
||||
.dirty_sections
|
||||
.retain_columns(|x, z| !far.contains(&(x, z)));
|
||||
}
|
||||
// Drop far proto chunks that are not checked out.
|
||||
let (center, r4) = (self.center, i64::from(self.radius + 4));
|
||||
let ranges = self.ranges();
|
||||
let far: Vec<(i32, i32)> = self
|
||||
.protos
|
||||
.iter()
|
||||
.filter(|&(&(x, z), p)| {
|
||||
p.is_some()
|
||||
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
|
||||
&& !ranges.in_extra(x, z, 4)
|
||||
&& !ranges.in_band(x, z, 4)
|
||||
&& !ranges
|
||||
.forced
|
||||
.iter()
|
||||
.any(|f| (x - f.0).abs() <= 3 && (z - f.1).abs() <= 3)
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
if !far.is_empty() {
|
||||
self.generation_scan.exhausted = false;
|
||||
}
|
||||
// Those decoration wrote to are saved first (see
|
||||
// `ChunkGenerator::save_protos`), and dropped once back.
|
||||
let mut written = Vec::new();
|
||||
for key in far {
|
||||
if self.written.remove(&key) {
|
||||
written.push(key);
|
||||
} else {
|
||||
self.protos.remove(&key);
|
||||
removed.reserve(far.len());
|
||||
for key in &far {
|
||||
if let Some(chunk) = world.chunks.remove(key) {
|
||||
removed.push(chunk);
|
||||
}
|
||||
self.lit.remove(key);
|
||||
self.meshed.remove(key);
|
||||
self.lighting.remove(key);
|
||||
self.light_snapshots.remove(key);
|
||||
}
|
||||
if !far.is_empty() {
|
||||
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
|
||||
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
|
||||
self.waiting.retain(|k| !far.contains(&(k.0, k.2)));
|
||||
world
|
||||
.dirty_sections
|
||||
.retain_columns(|x, z| !far.contains(&(x, z)));
|
||||
}
|
||||
// Drop far proto chunks that are not checked out.
|
||||
let (center, r4) = (self.center, i64::from(self.radius + 4));
|
||||
let ranges = self.ranges();
|
||||
let far: Vec<(i32, i32)> = self
|
||||
.protos
|
||||
.iter()
|
||||
.filter(|&(&(x, z), p)| {
|
||||
p.is_some()
|
||||
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
|
||||
&& !ranges.in_extra(x, z, 4)
|
||||
&& !ranges.in_band(x, z, 4)
|
||||
&& !ranges
|
||||
.forced
|
||||
.iter()
|
||||
.any(|f| (x - f.0).abs() <= 3 && (z - f.1).abs() <= 3)
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
if !far.is_empty() {
|
||||
self.generation_scan.exhausted = false;
|
||||
}
|
||||
// Those decoration wrote to are saved first (see
|
||||
// `ChunkGenerator::save_protos`), and dropped once back.
|
||||
let mut written = Vec::new();
|
||||
for key in far {
|
||||
if self.written.remove(&key) {
|
||||
written.push(key);
|
||||
} else {
|
||||
self.protos.remove(&key);
|
||||
}
|
||||
}
|
||||
self.save_checked_out(written);
|
||||
}
|
||||
self.save_checked_out(written);
|
||||
let (protos, finishing) = (&self.protos, &self.finishing);
|
||||
self.decorated.retain(|k| {
|
||||
protos.contains_key(k) || finishing.contains(k) || world.chunks.contains_key(k)
|
||||
|
||||
@@ -298,15 +298,23 @@ impl Title {
|
||||
options: WorldOptions::default(),
|
||||
}
|
||||
}
|
||||
/// WorldSelectionList.handleNewLevels: a singleplayer list that comes
|
||||
/// back empty (the last world deleted, say) opens a fresh Create New
|
||||
/// World, whose Cancel goes to the title screen.
|
||||
fn return_to_list(&mut self, folder: Option<&str>) {
|
||||
let worlds = list_worlds(&self.saves);
|
||||
*self.recent.borrow_mut() = None;
|
||||
if worlds.is_empty() {
|
||||
self.create_return_to_main = true;
|
||||
self.mode = Self::new_world();
|
||||
return;
|
||||
}
|
||||
let selected = folder.and_then(|folder| {
|
||||
worlds
|
||||
.iter()
|
||||
.position(|w| w.folder == folder && self.matches_search(w))
|
||||
});
|
||||
self.mode = Mode::Select { worlds, selected };
|
||||
*self.recent.borrow_mut() = None;
|
||||
}
|
||||
/// Deletes the world `Mode::Delete` asks about and goes back to the
|
||||
/// refreshed list; a failure (the world is open elsewhere, say) shows the
|
||||
|
||||
@@ -288,12 +288,37 @@ fn delete_requires_confirmation_and_both_cancel_paths_preserve_world() {
|
||||
assert_eq!((delete.x, delete.y, delete.w), (88.0, 146.0, 150.0));
|
||||
f.click("Delete");
|
||||
assert!(!f.title.saves.join("World").exists());
|
||||
assert!(matches!(&f.title.mode, Mode::Select { worlds, selected: None } if worlds.is_empty()));
|
||||
f.click("Back");
|
||||
// The last world gone, vanilla opens a fresh Create New World, not an empty list.
|
||||
assert!(matches!(&f.title.mode, Mode::Create { name, .. } if name == "New World"));
|
||||
f.click("Cancel");
|
||||
assert!(matches!(f.title.mode, Mode::Main));
|
||||
f.click("Singleplayer");
|
||||
assert!(matches!(f.title.mode, Mode::Create { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_of_two_worlds_keeps_the_list() {
|
||||
let mut f = Fixture::new();
|
||||
f.world("One", "One");
|
||||
f.world("Two", "Two");
|
||||
f.select();
|
||||
f.click("Delete");
|
||||
f.click("Delete");
|
||||
assert!(matches!(&f.title.mode, Mode::Select { worlds, .. } if worlds.len() == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_emptied_elsewhere_opens_create_world() {
|
||||
let mut f = Fixture::new();
|
||||
f.world("World", "World");
|
||||
f.title.enter_singleplayer();
|
||||
world_management::delete(&f.title.saves, "World").unwrap();
|
||||
f.title.worlds_changed();
|
||||
assert!(matches!(f.title.mode, Mode::Create { .. }));
|
||||
f.title.escape();
|
||||
assert!(matches!(f.title.mode, Mode::Main));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_zip_preserves_nested_files_excludes_lock_and_keeps_duplicate_backups() {
|
||||
let f = Fixture::new();
|
||||
@@ -600,7 +625,7 @@ fn world_management_buttons_sound_when_enabled() {
|
||||
assert_eq!(f.click_with_sound("Cancel"), (Action::None, true));
|
||||
assert_eq!(f.click_with_sound("Delete"), (Action::None, true));
|
||||
assert_eq!(f.click_with_sound("Delete"), (Action::None, true));
|
||||
assert!(matches!(f.title.mode, Mode::Select { .. }));
|
||||
assert!(matches!(f.title.mode, Mode::Create { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -355,7 +355,9 @@ impl World {
|
||||
false
|
||||
}
|
||||
/// The block entities to tick this tick, in order (`BlockEntities::tickers`).
|
||||
pub fn block_entity_tickers(&mut self) -> Vec<(crate::block_entities::Pos, u32)> {
|
||||
pub fn block_entity_tickers(
|
||||
&mut self,
|
||||
) -> std::sync::Arc<Vec<(crate::block_entities::Pos, u32)>> {
|
||||
let (chunks, air) = (&self.chunks, self.air);
|
||||
self.block_entities.tickers(|(x, y, z)| {
|
||||
chunks
|
||||
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user