mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 08:07:53 -10:00
Compare commits
128
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9755416a02 | ||
|
|
a5fcb49900 | ||
|
|
0ca3a5be98 | ||
|
|
8993772de0 | ||
|
|
6a261a00a5 | ||
|
|
f8b83eea51 | ||
|
|
88398e8bda | ||
|
|
6d3489d30b | ||
|
|
7a84c93843 | ||
|
|
cba52f4e5d | ||
|
|
9f1fa0a0a2 | ||
|
|
fe84cc07a1 | ||
|
|
d4e42cf726 | ||
|
|
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 | ||
|
|
28e3286e40 | ||
|
|
aef4186de4 | ||
|
|
b6c936f82b | ||
|
|
6960b65c62 | ||
|
|
5b13af2f75 | ||
|
|
557a827383 | ||
|
|
56f968e11c | ||
|
|
ae4cea18ef | ||
|
|
296802a618 | ||
|
|
a5d3ad165a | ||
|
|
2120ffedb4 | ||
|
|
04e6e21813 | ||
|
|
acee253e1c | ||
|
|
555dce8bb3 | ||
|
|
067163ded8 | ||
|
|
704cf97780 | ||
|
|
007d38fe49 | ||
|
|
43e2ad34c7 | ||
|
|
a6dbc6651f | ||
|
|
4bb3de29e7 | ||
|
|
786f67f77b | ||
|
|
797852c62b | ||
|
|
1f8f1055d5 | ||
|
|
0121466ed2 | ||
|
|
55b0545c11 | ||
|
|
5e14e309bc | ||
|
|
9e236c7914 | ||
|
|
2d7fc7f000 | ||
|
|
644a399389 | ||
|
|
9e31ce2f3d | ||
|
|
66d03b4d1c | ||
|
|
84d5574887 | ||
|
|
4118e026c3 | ||
|
|
51b374121b | ||
|
|
9042746704 | ||
|
|
795490271d | ||
|
|
9fe3ddfec3 | ||
|
|
7a62c6f4a7 | ||
|
|
e0bb0881e8 | ||
|
|
4b4950d7c4 | ||
|
|
e902b6fc18 | ||
|
|
5096b41100 | ||
|
|
46c84a8b24 | ||
|
|
ae82c1b693 | ||
|
|
501e543105 | ||
|
|
466fef4486 | ||
|
|
e34f01715c | ||
|
|
ad61c30f46 | ||
|
|
f35ee16412 | ||
|
|
5216403562 | ||
|
|
2aea6138b7 | ||
|
|
5b41d9ebf3 | ||
|
|
52f3dcc5ef | ||
|
|
7d99d23aef | ||
|
|
1feb28c045 | ||
|
|
7f6e8b634e | ||
|
|
2e69ac9886 | ||
|
|
c309d331ad | ||
|
|
7acfc655e8 | ||
|
|
d485c688d6 | ||
|
|
47ca0312df | ||
|
|
78c028321c | ||
|
|
64c174118a | ||
|
|
127434c46c | ||
|
|
7b40465f85 | ||
|
|
a34a41e1b5 | ||
|
|
9861a1ba04 | ||
|
|
d9ab241406 | ||
|
|
9b6faeb8cf | ||
|
|
e36cd17dd1 | ||
|
|
e6c1a91a65 | ||
|
|
8cfc13fe2c | ||
|
|
a7e3bcf79f | ||
|
|
62bb83857c | ||
|
|
de286f236f | ||
|
|
91d401aa8a | ||
|
|
a7d0586d95 | ||
|
|
c3fb1656e6 | ||
|
|
b98633b677 | ||
|
|
3c7942d2bd | ||
|
|
de89e758cc | ||
|
|
5f5a43fd54 | ||
|
|
c0bf77f3c3 | ||
|
|
86474679db | ||
|
|
560aa61e91 | ||
|
|
0d554de016 | ||
|
|
1db0eda4ae | ||
|
|
0f565f4e4c | ||
|
|
d8cc59dd55 | ||
|
|
beac63849c |
No files matched your search
@@ -6,3 +6,4 @@
|
||||
kacpherek # added by Ludovic, 2026-10-27
|
||||
rdbtCVS # added by Ludovic, 2026-10-27
|
||||
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
@@ -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'
|
||||
|
||||
@@ -39,6 +39,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
|
||||
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
|
||||
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
|
||||
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
|
||||
@@ -114,7 +115,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `window.rs`, `spans.rs` |
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
@@ -130,7 +131,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
|
||||
@@ -86,6 +86,9 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
|
||||
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
|
||||
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
|
||||
|
||||
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
|
||||
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
|
||||
|
||||
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
|
||||
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
|
||||
|
||||
|
||||
Generated
+18
-6
@@ -1370,7 +1370,6 @@ name = "horizon-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
@@ -1380,6 +1379,7 @@ dependencies = [
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
@@ -1422,6 +1422,18 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-input"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"horizon-client-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-theme",
|
||||
"png",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-command"
|
||||
version = "0.1.0"
|
||||
@@ -3194,9 +3206,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4374,14 +4386,14 @@ version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ members = [
|
||||
"crates/horizon-world",
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-client-input",
|
||||
"crates/horizon-command",
|
||||
"crates/horizon-items",
|
||||
"crates/horizon-server-files",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ludovic
|
||||
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -16,3 +16,20 @@ pub fn parse_identifier(raw: &str) -> Option<String> {
|
||||
});
|
||||
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
|
||||
/// `Identifier.CODEC` parsing an entity id: lowercase namespace and path
|
||||
/// characters, `minecraft` when the namespace is left out.
|
||||
pub fn normalize_id(s: &str) -> Option<String> {
|
||||
let (ns, path) = match s.split_once(':') {
|
||||
Some((ns, path)) => (if ns.is_empty() { "minecraft" } else { ns }, path),
|
||||
None => ("minecraft", s),
|
||||
};
|
||||
let ok = |c: char, slash: bool| {
|
||||
c.is_ascii_lowercase()
|
||||
|| c.is_ascii_digit()
|
||||
|| matches!(c, '_' | '-' | '.')
|
||||
|| slash && c == '/'
|
||||
};
|
||||
(ns.chars().all(|c| ok(c, false)) && path.chars().all(|c| ok(c, true)))
|
||||
.then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
@@ -22,3 +22,11 @@ pub fn number(tag: Option<&Tag>) -> Option<i64> {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Any numeric tag as f64, keeping a float's or double's fraction.
|
||||
pub fn float(tag: Option<&Tag>) -> Option<f64> {
|
||||
Some(match tag? {
|
||||
Tag::Float(b) => f64::from(f32::from_bits(*b)),
|
||||
Tag::Double(b) => f64::from_bits(*b),
|
||||
other => number(Some(other))? as f64,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "horizon-client-input"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
|
||||
|
||||
[dependencies]
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
winit = "0.30.13"
|
||||
# The window icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
png = "0.18"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
# The DualSense's trigger effects (the `controllers` feature).
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
|
||||
[features]
|
||||
controllers = ["dep:horizon-dualsense"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -145,6 +145,22 @@ const KEYS: &[(&str, KeyCode)] = &[
|
||||
("key.keyboard.period", KeyCode::Period),
|
||||
("key.keyboard.slash", KeyCode::Slash),
|
||||
("key.keyboard.caps.lock", KeyCode::CapsLock),
|
||||
("key.keyboard.world.1", KeyCode::IntlBackslash),
|
||||
("key.keyboard.international1", KeyCode::IntlRo),
|
||||
("key.keyboard.international3", KeyCode::IntlYen),
|
||||
("key.keyboard.lang1", KeyCode::Lang1),
|
||||
("key.keyboard.lang2", KeyCode::Lang2),
|
||||
("key.keyboard.lang3", KeyCode::Lang3),
|
||||
("key.keyboard.lang4", KeyCode::Lang4),
|
||||
("key.keyboard.lang5", KeyCode::Lang5),
|
||||
("key.keyboard.help", KeyCode::Help),
|
||||
("key.keyboard.select", KeyCode::Select),
|
||||
("key.keyboard.again", KeyCode::Again),
|
||||
("key.keyboard.undo", KeyCode::Undo),
|
||||
("key.keyboard.cut", KeyCode::Cut),
|
||||
("key.keyboard.copy", KeyCode::Copy),
|
||||
("key.keyboard.paste", KeyCode::Paste),
|
||||
("key.keyboard.find", KeyCode::Find),
|
||||
("key.keyboard.f1", KeyCode::F1),
|
||||
("key.keyboard.f2", KeyCode::F2),
|
||||
("key.keyboard.f3", KeyCode::F3),
|
||||
@@ -157,6 +173,18 @@ const KEYS: &[(&str, KeyCode)] = &[
|
||||
("key.keyboard.f10", KeyCode::F10),
|
||||
("key.keyboard.f11", KeyCode::F11),
|
||||
("key.keyboard.f12", KeyCode::F12),
|
||||
("key.keyboard.f13", KeyCode::F13),
|
||||
("key.keyboard.f14", KeyCode::F14),
|
||||
("key.keyboard.f15", KeyCode::F15),
|
||||
("key.keyboard.f16", KeyCode::F16),
|
||||
("key.keyboard.f17", KeyCode::F17),
|
||||
("key.keyboard.f18", KeyCode::F18),
|
||||
("key.keyboard.f19", KeyCode::F19),
|
||||
("key.keyboard.f20", KeyCode::F20),
|
||||
("key.keyboard.f21", KeyCode::F21),
|
||||
("key.keyboard.f22", KeyCode::F22),
|
||||
("key.keyboard.f23", KeyCode::F23),
|
||||
("key.keyboard.f24", KeyCode::F24),
|
||||
("key.keyboard.print.screen", KeyCode::PrintScreen),
|
||||
("key.keyboard.scroll.lock", KeyCode::ScrollLock),
|
||||
("key.keyboard.pause", KeyCode::Pause),
|
||||
@@ -188,6 +216,36 @@ const KEYS: &[(&str, KeyCode)] = &[
|
||||
("key.keyboard.keypad.0", KeyCode::Numpad0),
|
||||
("key.keyboard.keypad.period", KeyCode::NumpadDecimal),
|
||||
("key.keyboard.keypad.equal", KeyCode::NumpadEqual),
|
||||
("key.keyboard.keypad.comma", KeyCode::NumpadComma),
|
||||
("key.keyboard.keypad.backspace", KeyCode::NumpadBackspace),
|
||||
(
|
||||
"key.keyboard.keypad.left.parenthesis",
|
||||
KeyCode::NumpadParenLeft,
|
||||
),
|
||||
(
|
||||
"key.keyboard.keypad.right.parenthesis",
|
||||
KeyCode::NumpadParenRight,
|
||||
),
|
||||
("key.keyboard.keypad.hash", KeyCode::NumpadHash),
|
||||
(
|
||||
"key.keyboard.keypad.memory.store",
|
||||
KeyCode::NumpadMemoryStore,
|
||||
),
|
||||
(
|
||||
"key.keyboard.keypad.memory.recall",
|
||||
KeyCode::NumpadMemoryRecall,
|
||||
),
|
||||
(
|
||||
"key.keyboard.keypad.memory.clear",
|
||||
KeyCode::NumpadMemoryClear,
|
||||
),
|
||||
("key.keyboard.keypad.memory.add", KeyCode::NumpadMemoryAdd),
|
||||
(
|
||||
"key.keyboard.keypad.memory.subtract",
|
||||
KeyCode::NumpadMemorySubtract,
|
||||
),
|
||||
("key.keyboard.keypad.clear", KeyCode::NumpadClear),
|
||||
("key.keyboard.keypad.clear.entry", KeyCode::NumpadClearEntry),
|
||||
("key.keyboard.menu", KeyCode::ContextMenu),
|
||||
("key.keyboard.left.control", KeyCode::ControlLeft),
|
||||
("key.keyboard.left.shift", KeyCode::ShiftLeft),
|
||||
File renamed without changes.
@@ -0,0 +1,14 @@
|
||||
//! The client's input layer, below gameplay and screens: key bindings and their
|
||||
//! names, toggle keys, mouse settings, controllers, the clipboard and window
|
||||
//! options. horizon-client re-exports each module under its old path
|
||||
//! (`horizon_client::keybinds`, ...).
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod clipboard;
|
||||
pub mod gamepad;
|
||||
pub mod input;
|
||||
pub mod interaction_input;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod mouse;
|
||||
pub mod window;
|
||||
File renamed without changes.
File renamed without changes.
@@ -10,6 +10,7 @@ description = "Horizon native development client"
|
||||
[dependencies]
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
@@ -55,7 +56,6 @@ horizon-theme = { path = "../horizon-theme" }
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
gilrs = { version = "0.11", optional = true }
|
||||
@@ -90,7 +90,7 @@ default = ["audio", "controllers"]
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["dep:cpal"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense"]
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
|
||||
# (release.yml); the tests and probes don't need it. Content mods load without it.
|
||||
mods = ["horizon-mods/runtime"]
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -218,6 +218,8 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.finish();
|
||||
// `finish` grows the buffer by doubling: keep only the bytes in use.
|
||||
sprites.pixels.shrink_to_fit();
|
||||
Ok(Self {
|
||||
sprite_size: sprites.size,
|
||||
sprite_minimum_size: sprites.minimum_size,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -35,6 +35,25 @@ pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
|
||||
}
|
||||
|
||||
/// `SpawnEggItem.shouldPrintOpWarning` for a player with game master
|
||||
/// permission: a spawn egg carries data of an op-only entity type
|
||||
/// (`EntityTypes.OP_ONLY_CUSTOM_DATA`).
|
||||
pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
stack.item.ends_with("_spawn_egg")
|
||||
&& matches!(
|
||||
stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:entity_data"))
|
||||
.and_then(|data| data.get("id")),
|
||||
Some(Tag::String(id)) if matches!(
|
||||
id.to_string_lossy().as_str(),
|
||||
"minecraft:falling_block"
|
||||
| "minecraft:command_block_minecart"
|
||||
| "minecraft:spawner_minecart"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
|
||||
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
|
||||
/// spawners alike): the entity type a spawner item spawns.
|
||||
|
||||
@@ -97,6 +97,16 @@ pub fn diffuse(normal: Vec3, lights: (Vec3, Vec3)) -> f32 {
|
||||
pub fn vertex_light(packed: u8) -> u32 {
|
||||
(u32::from(packed >> 4) * 16) | ((u32::from(packed & 15) * 16) << 8)
|
||||
}
|
||||
/// `ChestRenderer.xmasTextures`: `SpecialDates.isExtendedChristmas` (24 to 26
|
||||
/// December in the local time zone), read once when the renderer is created.
|
||||
fn extended_christmas() -> bool {
|
||||
use chrono::Datelike;
|
||||
static XMAS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*XMAS.get_or_init(|| {
|
||||
let now = chrono::Local::now();
|
||||
now.month() == 12 && (24..=26).contains(&now.day())
|
||||
})
|
||||
}
|
||||
fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
|
||||
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
|
||||
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
|
||||
@@ -1173,11 +1183,25 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
Kind::Chest {
|
||||
block,
|
||||
texture,
|
||||
part,
|
||||
y_rot,
|
||||
..
|
||||
} => {
|
||||
// ChestRenderer.getChestMaterial: normal and trapped chests use the christmas
|
||||
// sprites on 24-26 December. Copper and ender chests keep theirs.
|
||||
let texture = if matches!(block.as_str(), "chest" | "trapped_chest")
|
||||
&& extended_christmas()
|
||||
{
|
||||
match part {
|
||||
ChestPart::Single => "entity/chest/christmas",
|
||||
ChestPart::Left => "entity/chest/christmas_left",
|
||||
ChestPart::Right => "entity/chest/christmas_right",
|
||||
}
|
||||
} else {
|
||||
*texture
|
||||
};
|
||||
let model = match part {
|
||||
ChestPart::Single => "chest",
|
||||
ChestPart::Left => "double_chest_left",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! `BrewingStandBlockEntity.serverTick`.
|
||||
pub use crate::chunk::block_entity::BrewingStand;
|
||||
use crate::{
|
||||
containers::Providers,
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
@@ -772,6 +772,27 @@ pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option:
|
||||
pose
|
||||
}
|
||||
|
||||
/// The spinning effect `GameRenderer.renderLevel` multiplies into the level's
|
||||
/// projection after the bob: a skew about a tilted axis, from a portal's or
|
||||
/// Nausea's intensity (already scaled by Distortion Effects) and the angle in
|
||||
/// degrees. The first-person hand keeps its own projection without it.
|
||||
pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4 {
|
||||
if intensity <= 0.0 {
|
||||
return glam::Mat4::IDENTITY;
|
||||
}
|
||||
let mut skew = 5.0 / (intensity * intensity + 5.0) - intensity * 0.04;
|
||||
skew *= skew;
|
||||
let axis = glam::Vec3::new(
|
||||
0.0,
|
||||
std::f32::consts::SQRT_2 / 2.0,
|
||||
std::f32::consts::SQRT_2 / 2.0,
|
||||
);
|
||||
let angle = angle_degrees * DEG_TO_RAD;
|
||||
glam::Mat4::from_axis_angle(axis, angle)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(1.0 / skew, 1.0, 1.0))
|
||||
* glam::Mat4::from_axis_angle(axis, -angle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//! count comparators read, the output it keeps, and the powered, automatic
|
||||
//! and condition flags `CommandBlock.tick` runs on.
|
||||
pub use crate::chunk::block_entity::CommandBlock;
|
||||
use crate::{
|
||||
save::{number, text},
|
||||
use horizon_client_core::{
|
||||
nbt_value::{number, text},
|
||||
scoreboard,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -108,7 +109,7 @@ impl CommandBlock {
|
||||
last_output: track_output
|
||||
.then(|| tag.get("LastOutput"))
|
||||
.flatten()
|
||||
.map(|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))),
|
||||
.map(|t| scoreboard::comp_text(&scoreboard::tag_to_json(t))),
|
||||
update_last_execution,
|
||||
last_execution: if update_last_execution {
|
||||
number(tag.get("LastExecution")).unwrap_or(-1)
|
||||
@@ -164,7 +165,7 @@ impl CommandBlock {
|
||||
pub fn name(&self) -> Text {
|
||||
self.custom_name.as_ref().map_or_else(
|
||||
|| Text::literal("@"),
|
||||
|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
|t| scoreboard::comp_text(&scoreboard::tag_to_json(t)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +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 serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
@@ -380,283 +376,7 @@ impl BlockEntity {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Evaluator for `context_int_provider` / `context_float_provider` values.
|
||||
pub struct Providers {
|
||||
ints: HashMap<String, Value>,
|
||||
floats: HashMap<String, Value>,
|
||||
}
|
||||
fn load_dir(dir: &Path, prefix: &str, out: &mut HashMap<String, Value>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
let name = p
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
if p.is_dir() {
|
||||
load_dir(&p, &format!("{prefix}{name}/"), out);
|
||||
} else if let Ok(bytes) = fs::read(&p)
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
out.insert(format!("minecraft:{prefix}{name}"), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Providers {
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let mut ints = HashMap::new();
|
||||
let mut floats = HashMap::new();
|
||||
load_dir(
|
||||
&root.join("data/minecraft/context_int_provider"),
|
||||
"",
|
||||
&mut ints,
|
||||
);
|
||||
load_dir(
|
||||
&root.join("data/minecraft/context_float_provider"),
|
||||
"",
|
||||
&mut floats,
|
||||
);
|
||||
Self { ints, floats }
|
||||
}
|
||||
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
|
||||
if depth > 16 {
|
||||
return 0.0;
|
||||
}
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
||||
Value::String(name) => table
|
||||
.get(name)
|
||||
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
|
||||
Value::Object(o) => {
|
||||
let f =
|
||||
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
|
||||
match o.get("type").and_then(Value::as_str).unwrap_or("") {
|
||||
"minecraft:div" => {
|
||||
let r = f("right");
|
||||
if r == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(f("left") / r).trunc()
|
||||
}
|
||||
}
|
||||
"minecraft:mul" => f("left") * f("right"),
|
||||
"minecraft:add" | "minecraft:sum" => f("left") + f("right"),
|
||||
"minecraft:sub" => f("left") - f("right"),
|
||||
"minecraft:constant" => f("value"),
|
||||
"minecraft:conditional" => {
|
||||
// The only condition in the pinned data: block/fast_cooking.
|
||||
let condition = o.get("condition").and_then(Value::as_str)
|
||||
== Some("minecraft:block/fast_cooking");
|
||||
if condition && fast {
|
||||
f("on_true")
|
||||
} else {
|
||||
f("on_false")
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
/// A plain `context_int_provider` value or reference (no context conditions).
|
||||
pub fn int_value(&self, v: &Value) -> i32 {
|
||||
Self::eval(v, false, &self.ints, 0) as i32
|
||||
}
|
||||
/// A plain `context_float_provider` value or reference; `default` when absent.
|
||||
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
|
||||
if v.is_null() {
|
||||
default
|
||||
} else {
|
||||
Self::eval(v, false, &self.floats, 0) as f32
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
ints: HashMap::new(),
|
||||
floats: HashMap::new(),
|
||||
}
|
||||
}
|
||||
pub fn burn_time(&self, items: &Items, stack: &ItemStack, fast: bool) -> i32 {
|
||||
items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.fuel.as_ref())
|
||||
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
|
||||
}
|
||||
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
|
||||
items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.fuel.as_ref())
|
||||
.map_or(1.0, |(_, speed)| {
|
||||
Self::eval(speed, fast, &self.floats, 0) as f32
|
||||
})
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
pub use crate::providers::Providers;
|
||||
|
||||
/// `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).
|
||||
|
||||
@@ -355,6 +355,19 @@ impl Creative {
|
||||
pub fn search_items(&self) -> &[ItemStack] {
|
||||
&self.search_items
|
||||
}
|
||||
/// Opening the screen: vanilla rebuilds CreativeModeInventoryScreen, whose
|
||||
/// init() makes a fresh search box and a zeroed scrollbar; only the tab
|
||||
/// persists (its static selectedTab). selectTab on the reopened screen
|
||||
/// takes the oldTab != tab branch, so it empties and refocuses the box.
|
||||
pub fn reopen(&mut self) {
|
||||
self.search.set_value("");
|
||||
self.release();
|
||||
self.scroll = 0.0;
|
||||
self.search.focused = self.tab().search;
|
||||
if self.search.focused {
|
||||
self.search.reset_blink();
|
||||
}
|
||||
}
|
||||
/// Indices into `search_items` that CreativeModeInventoryScreen.refreshSearchResults
|
||||
/// shows for `term`: the whole tab when empty, the tag tree after `#`, else
|
||||
/// the name tree, both over vanilla's suffix arrays (substring matches,
|
||||
@@ -575,6 +588,8 @@ pub enum DisplayClick {
|
||||
Nothing,
|
||||
/// SWAP: the hotbar slot gets a full stack of the item.
|
||||
Hotbar(usize, ItemStack),
|
||||
/// SWAP with button 40 (Key F): the off-hand gets a full stack of the item.
|
||||
Offhand(ItemStack),
|
||||
/// THROW: a copy of the item is dropped.
|
||||
Drop(ItemStack),
|
||||
}
|
||||
@@ -596,8 +611,11 @@ pub fn click_display(
|
||||
match input {
|
||||
Input::Swap => {
|
||||
return match shown {
|
||||
Some(s) => DisplayClick::Hotbar(button as usize, copy(s, max(s))),
|
||||
None => DisplayClick::Nothing,
|
||||
Some(s) if button == 40 => DisplayClick::Offhand(copy(s, max(s))),
|
||||
Some(s) if (0..9).contains(&button) => {
|
||||
DisplayClick::Hotbar(button as usize, copy(s, max(s)))
|
||||
}
|
||||
Some(_) | None => DisplayClick::Nothing,
|
||||
};
|
||||
}
|
||||
Input::Clone => {
|
||||
@@ -720,11 +738,15 @@ mod tests {
|
||||
display(Some(stack("ender_pearl", 9)), 1, Pickup).0,
|
||||
Some(stack("ender_pearl", 8))
|
||||
);
|
||||
// Number keys fill a hotbar slot, Q drops copies, pick block clones.
|
||||
// Number keys fill a hotbar slot, F fills off-hand, Q drops copies, pick block clones.
|
||||
assert_eq!(
|
||||
display(None, 3, Swap).1,
|
||||
DisplayClick::Hotbar(3, stack("stone", 64))
|
||||
);
|
||||
assert_eq!(
|
||||
display(None, 40, Swap).1,
|
||||
DisplayClick::Offhand(stack("stone", 64))
|
||||
);
|
||||
assert_eq!(
|
||||
display(None, 0, Throw).1,
|
||||
DisplayClick::Drop(stack("stone", 1))
|
||||
|
||||
@@ -29,6 +29,10 @@ impl EffectInstance {
|
||||
fn infinite(&self) -> bool {
|
||||
self.duration == -1
|
||||
}
|
||||
/// `MobEffectInstance.endsWithin`.
|
||||
pub fn ends_within(&self, ticks: i32) -> bool {
|
||||
!self.infinite() && self.duration <= ticks
|
||||
}
|
||||
fn shorter_than(&self, other: &Self) -> bool {
|
||||
!self.infinite() && (self.duration < other.duration || other.infinite())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -585,8 +586,8 @@ struct GameLevel<'w> {
|
||||
cart_boxes: Vec<([f64; 3], [f64; 3])>,
|
||||
/// The item frames a comparator reads through a conductor.
|
||||
item_frames: &'w [horizon_client::entities::ItemFrame],
|
||||
/// Each minecart's kind and comparator signal, in `cart_boxes` order.
|
||||
cart_signals: Vec<(minecarts::CartKind, i32)>,
|
||||
/// The minecarts, in `cart_boxes` order, for a detector rail's comparator.
|
||||
carts: &'w [minecarts::Minecart],
|
||||
/// Arrow and thrown trident boxes (wooden buttons).
|
||||
arrow_boxes: Vec<([f64; 3], [f64; 3])>,
|
||||
/// The other entities that keep blocks from being placed into them.
|
||||
@@ -670,15 +671,22 @@ impl EntityObstructionWorld for BuildingObstruction {
|
||||
}
|
||||
}
|
||||
impl GameLevel<'_> {
|
||||
/// The mobs that trigger plates and tripwires: living entities that don't
|
||||
/// ignore block triggers (`Entity.isIgnoringBlockTriggers`: bats; armor
|
||||
/// stands are not in `mobs`).
|
||||
/// The living entities that trigger plates and tripwires and don't ignore
|
||||
/// block triggers (`Entity.isIgnoringBlockTriggers`: bats and marker armor
|
||||
/// stands). Armor stands live apart from `mobs`.
|
||||
fn mob_trigger_boxes(&self) -> impl Iterator<Item = Aabb> + '_ {
|
||||
self.blockers
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed && m.species() != horizon_client::mobs::Species::Bat)
|
||||
.map(Mob::bounds)
|
||||
.chain(
|
||||
self.blockers
|
||||
.armor_stands
|
||||
.iter()
|
||||
.filter(|s| !s.marker)
|
||||
.map(|s| s.bounds()),
|
||||
)
|
||||
}
|
||||
/// The non-living entities that trigger plates and tripwires (boats,
|
||||
/// crystals, primed TNT, falling blocks, item entities, experience orbs and
|
||||
@@ -730,24 +738,36 @@ impl GameLevel<'_> {
|
||||
Vec3::new(x + 0.8, y + 0.8, z + 0.8),
|
||||
);
|
||||
let mut container = None;
|
||||
for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) {
|
||||
let cart = Aabb::new(
|
||||
for ((lo, hi), cart) in self.cart_boxes.iter().zip(self.carts) {
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(lo[0], lo[1], lo[2]),
|
||||
Vec3::new(hi[0], hi[1], hi[2]),
|
||||
);
|
||||
if !area.intersects(cart) {
|
||||
if !area.intersects(bounds) {
|
||||
continue;
|
||||
}
|
||||
match kind {
|
||||
match cart.kind {
|
||||
minecarts::CartKind::CommandBlock => return 0,
|
||||
minecarts::CartKind::Chest | minecarts::CartKind::Hopper => {
|
||||
container = container.or(Some(signal));
|
||||
// Counted here, for the cart a comparator reads, not for
|
||||
// every cart on every block tick.
|
||||
container = container.or_else(|| Some(self.cart_signal(cart)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
container.unwrap_or(0)
|
||||
}
|
||||
/// A container minecart's comparator signal: its open menu's slots while
|
||||
/// someone has it open, else its own.
|
||||
fn cart_signal(&self, cart: &minecarts::Minecart) -> i32 {
|
||||
let key = (i32::MAX - 15, minecarts::CART_KEY_Y, cart.id as i32);
|
||||
let slots = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Chest(open)) => open,
|
||||
_ => &cart.items,
|
||||
};
|
||||
minecarts::Minecart::container_signal(slots, self.items)
|
||||
}
|
||||
/// Every entity box that blocks building: the players who aren't
|
||||
/// spectators, the minecarts and the `Blockers` (a removed mob or a marker
|
||||
/// armor stand, whose box is empty, doesn't).
|
||||
@@ -1177,6 +1197,8 @@ struct WorldRest {
|
||||
end_environment: Environment,
|
||||
behaviours: Behaviours,
|
||||
player_arm: Option<horizon_client::mobs::Part>,
|
||||
/// The slim model's right arm (3 pixels wide).
|
||||
player_arm_slim: Option<horizon_client::mobs::Part>,
|
||||
player_type: Option<Arc<horizon_client::mobs::MobType>>,
|
||||
/// `SpinAttackEffectModel` (Riptide's spinning boxes).
|
||||
spin_attack: Option<horizon_client::mobs::Part>,
|
||||
@@ -1249,8 +1271,12 @@ impl Resources {
|
||||
fn threads(&self) -> usize {
|
||||
self.core.threads
|
||||
}
|
||||
fn player_arm(&self) -> &Option<horizon_client::mobs::Part> {
|
||||
&self.rest().player_arm
|
||||
fn player_arm(&self, slim: bool) -> &Option<horizon_client::mobs::Part> {
|
||||
if slim {
|
||||
&self.rest().player_arm_slim
|
||||
} else {
|
||||
&self.rest().player_arm
|
||||
}
|
||||
}
|
||||
fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
|
||||
&self.rest().player_type
|
||||
@@ -2841,7 +2867,6 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let cart_signals = self.cart_signals();
|
||||
let arrow_boxes = self
|
||||
.creatures
|
||||
.projectiles
|
||||
@@ -2885,7 +2910,7 @@ impl Game<'_> {
|
||||
let mut level = GameLevel {
|
||||
cart_boxes,
|
||||
item_frames: &self.item_frames,
|
||||
cart_signals,
|
||||
carts: &self.carts,
|
||||
arrow_boxes,
|
||||
blockers,
|
||||
dropped: &self.dropped,
|
||||
@@ -2912,7 +2937,13 @@ impl Game<'_> {
|
||||
behaviours: self.res.behaviours(),
|
||||
items: self.res.items(),
|
||||
recipes: self.res.recipes(),
|
||||
loot: horizon_client::structure_blocks::loot_data(&self.options.resources),
|
||||
// A client rolls no loot (LootTable.getRandomItems needs a
|
||||
// ServerLevel): it doesn't load the tables on its game thread.
|
||||
loot: if self.remote.is_some() {
|
||||
None
|
||||
} else {
|
||||
horizon_client::structure_blocks::loot_data(&self.options.resources)
|
||||
},
|
||||
registry: self.pack.assets.registry,
|
||||
rules,
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
@@ -5259,8 +5290,13 @@ impl Game<'_> {
|
||||
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
|
||||
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
|
||||
MotionEvent::HoneySlide {
|
||||
sound, particles, ..
|
||||
pos,
|
||||
sound,
|
||||
particles,
|
||||
} => {
|
||||
if self.world.game_time % 20 == 0 {
|
||||
self.trigger_honey_slide([pos.x, pos.y, pos.z]);
|
||||
}
|
||||
if sound {
|
||||
self.audio
|
||||
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
|
||||
@@ -5624,8 +5660,10 @@ impl Game<'_> {
|
||||
if self.lan_acting.is_some() {
|
||||
return;
|
||||
}
|
||||
// MouseHandler.grabMouse never captures an inactive window.
|
||||
if capture && !self.focused {
|
||||
// MouseHandler.grabMouse never captures an inactive window, and vanilla
|
||||
// grabs only on `setScreen(null)`: the pack prompt and server dialogs
|
||||
// are screens over the world that keep the pointer.
|
||||
if capture && (!self.focused || self.server_packs.prompt.is_some() || self.dialog_open()) {
|
||||
return;
|
||||
}
|
||||
// The container screen's press state belongs to the screen: a screen
|
||||
@@ -5694,17 +5732,22 @@ impl Game<'_> {
|
||||
fn apply_cursor_icon(&mut self) {
|
||||
use horizon_client::widgets::Cursor;
|
||||
let wanted = horizon_client::widgets::take_cursor();
|
||||
if wanted == self.cursor_icon {
|
||||
let effective = if self.settings.allow_cursor_changes {
|
||||
wanted
|
||||
} else {
|
||||
Cursor::Arrow
|
||||
};
|
||||
if effective == self.cursor_icon {
|
||||
return;
|
||||
}
|
||||
if let Some(window) = &self.window {
|
||||
window.set_cursor(match wanted {
|
||||
window.set_cursor(match effective {
|
||||
Cursor::Arrow => winit::window::CursorIcon::Default,
|
||||
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
|
||||
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
|
||||
});
|
||||
}
|
||||
self.cursor_icon = wanted;
|
||||
self.cursor_icon = effective;
|
||||
}
|
||||
/// LevelLoadingScreen.onClose -> Gui.setScreen(null) -> grabMouse.
|
||||
fn capture_loaded_world(&mut self) {
|
||||
@@ -6101,7 +6144,14 @@ impl Game<'_> {
|
||||
// Screen.extractBlurredBackground blurs the world and HUD, while the
|
||||
// screen's background, widgets, toasts and debug overlay stay sharp.
|
||||
batch.blur_at = batch.vertices.len() as u32;
|
||||
match self.screen {
|
||||
// PackConfirmScreen takes the open screen's place (it comes back once
|
||||
// the prompt is answered): the screen under the prompt isn't drawn.
|
||||
let screen = if self.server_packs.prompt.is_some() {
|
||||
Screen::Playing
|
||||
} else {
|
||||
self.screen
|
||||
};
|
||||
match screen {
|
||||
Screen::Paused if self.pause_menu => self.draw_pause_menu(&mut batch),
|
||||
// PauseScreen(false): only its title, at the top.
|
||||
Screen::Paused => {
|
||||
@@ -6141,9 +6191,11 @@ impl Game<'_> {
|
||||
// ToastManager.extractRenderState: F1 hides the toasts too.
|
||||
self.draw_toasts(&mut batch, !show_hud);
|
||||
self.draw_pack_toasts(&mut batch, !show_hud);
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
if self.server_packs.prompt.is_none() {
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
}
|
||||
batch
|
||||
}
|
||||
fn creative_inventory_open(&self) -> bool {
|
||||
@@ -6611,9 +6663,12 @@ impl Game<'_> {
|
||||
let catalog = self.res.items();
|
||||
let max = |s: &ItemStack| catalog.get(&s.item).map_or(64, |i| i.max_stack);
|
||||
match click_display(&mut self.carried, shown, max, button, input) {
|
||||
DisplayClick::Hotbar(i, stack) => self.inventory[i] = Some(stack),
|
||||
DisplayClick::Hotbar(i, stack) if i < self.inventory.len() => {
|
||||
self.inventory[i] = Some(stack);
|
||||
}
|
||||
DisplayClick::Offhand(stack) => self.offhand = Some(stack),
|
||||
DisplayClick::Drop(stack) => self.player_drop(stack),
|
||||
DisplayClick::Nothing => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Several clicks on one menu instance (a quick craft drag is three).
|
||||
@@ -10456,14 +10511,18 @@ impl Game<'_> {
|
||||
if hand == Hand::Off {
|
||||
continue;
|
||||
}
|
||||
let Some(arm) = self.res.player_arm().clone() else {
|
||||
let slim = self.player_model.as_ref().is_some_and(|m| m.slim);
|
||||
let Some(arm) = self.res.player_arm(slim).clone() else {
|
||||
continue;
|
||||
};
|
||||
// The player's own skin, as the inventory's model shows it.
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let Some(r) = self
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get("entity/player/wide/steve")
|
||||
.player_model
|
||||
.as_ref()
|
||||
.and_then(|m| m.skin.as_deref())
|
||||
.and_then(|skin| sprites.get(skin))
|
||||
.or_else(|| sprites.get("entity/player/wide/steve"))
|
||||
.copied()
|
||||
else {
|
||||
continue;
|
||||
@@ -11659,6 +11718,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.bob_matrix(camera_alpha as f32)
|
||||
};
|
||||
let spin = if title {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
self.spinning_matrix(camera_alpha as f32)
|
||||
};
|
||||
let terrain_distance = self.terrain_distance();
|
||||
let distance = (terrain_distance * 16) as f32;
|
||||
let depth_far =
|
||||
@@ -11666,7 +11730,7 @@ impl Game<'_> {
|
||||
self.view_frustum = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob, depth_far));
|
||||
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob * spin, depth_far));
|
||||
// Keep the buffer local while builders need mutable access to the game.
|
||||
let mut entities = std::mem::take(&mut self.entity_vertices);
|
||||
entities.clear();
|
||||
@@ -11749,12 +11813,24 @@ impl Game<'_> {
|
||||
pitch,
|
||||
fov_degrees,
|
||||
bob,
|
||||
spin,
|
||||
sky,
|
||||
distance,
|
||||
detail_distance: (self.streaming.radius * 16) as f32,
|
||||
depth_far,
|
||||
underwater,
|
||||
title,
|
||||
sky_blocked: self.potions.effects.get("blindness").is_some()
|
||||
|| self.potions.effects.get("darkness").is_some()
|
||||
|| matches!(
|
||||
horizon_client::fog::fog_type(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
[camera.x, camera.y, camera.z],
|
||||
self.forward(),
|
||||
),
|
||||
horizon_client::fog::FogType::Lava | horizon_client::fog::FogType::PowderSnow
|
||||
),
|
||||
skybox: self.dim == Dim::Overworld && self.settings.horizon.sky,
|
||||
end_sky: self.dim == Dim::End && self.settings.horizon.sky,
|
||||
end_sky_sprite: self
|
||||
@@ -12067,11 +12143,12 @@ impl Game<'_> {
|
||||
Screen::Playing => {
|
||||
// key.inventory while riding a tamed mount opens its inventory.
|
||||
if !self.open_ridden_mount_menu() {
|
||||
self.screen = if self.creative {
|
||||
Screen::Creative
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
self.creative_screen.reopen();
|
||||
} else {
|
||||
Screen::Inventory
|
||||
};
|
||||
self.screen = Screen::Inventory;
|
||||
}
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
@@ -12651,13 +12728,16 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
// Screens (books, signs) stay open instead of pausing.
|
||||
// Gui.setPauseScreen: an open screen (books, signs, the
|
||||
// pack prompt, a server dialog) stays instead of pausing.
|
||||
if self.screen == Screen::Playing
|
||||
&& self.player.is_some()
|
||||
&& !self.options.no_pause
|
||||
&& self.settings.file.bool("pauseOnLostFocus")
|
||||
&& self.book.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.server_packs.prompt.is_none()
|
||||
&& !self.dialog_open()
|
||||
{
|
||||
self.screen = Screen::Paused;
|
||||
}
|
||||
@@ -14511,6 +14591,8 @@ fn load_world_rest(
|
||||
player_type: perspective::player_type(root),
|
||||
player_arm: horizon_client::mobs::load_model(root, "minecraft:player#main")
|
||||
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
|
||||
player_arm_slim: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
|
||||
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
|
||||
spin_attack: horizon_client::mobs::load_model(root, "minecraft:spin_attack#main"),
|
||||
})
|
||||
}
|
||||
@@ -14648,6 +14730,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
pitch: 10.0,
|
||||
fov_degrees: 85.0,
|
||||
bob: glam::Mat4::IDENTITY,
|
||||
spin: glam::Mat4::IDENTITY,
|
||||
sky: horizon_client::environment::Sky::default(),
|
||||
distance: 12.0,
|
||||
detail_distance: 12.0,
|
||||
@@ -14655,6 +14738,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
underwater: false,
|
||||
cave_culling: false,
|
||||
title: true,
|
||||
sky_blocked: false,
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
|
||||
@@ -993,6 +993,42 @@ impl Game<'_> {
|
||||
block_ok && state_ok && adv::conditions(&c["player"], &pctx, w)
|
||||
});
|
||||
}
|
||||
/// `SlideDownBlockTrigger`: match the actual block and its state.
|
||||
pub(super) fn trigger_honey_slide(&mut self, pos: [i32; 3]) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:slide_down_block", move |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &ctx, w) && adv::block(c, pos, w)
|
||||
});
|
||||
}
|
||||
|
||||
/// `TargetBlockTrigger`: signal and projectile conditions are evaluated in
|
||||
/// the shooter's context, including distance from the shooter.
|
||||
pub(super) fn trigger_target_hit(&mut self, projectile: &EntityView, strength: i32) {
|
||||
let me = self.player_entity();
|
||||
let projectile = projectile.clone();
|
||||
self.trigger("minecraft:target_hit", move |c, w| {
|
||||
let player = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let target = adv::Entities {
|
||||
this: Some(&projectile),
|
||||
..player
|
||||
};
|
||||
adv::conditions(&c["player"], &player, w)
|
||||
&& adv::range_i(&c["signal_strength"], i64::from(strength))
|
||||
&& adv::conditions(&c["projectile"], &target, w)
|
||||
});
|
||||
}
|
||||
|
||||
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
|
||||
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
|
||||
@@ -563,13 +563,21 @@ impl Game<'_> {
|
||||
};
|
||||
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
|
||||
let scoping = self.scoping();
|
||||
let view = f64::from(self.settings.render_distance * 16);
|
||||
for (k, e) in &self.world.block_entities {
|
||||
let BlockEntity::Beacon(b) = e else { continue };
|
||||
// `getBeamSections` is empty while Levels is 0.
|
||||
if b.levels == 0 || b.sections.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
|
||||
let distance = (base - camera).truncate().length() as f32;
|
||||
// `BeaconRenderer.shouldRender`: horizontal distance under render distance × 16.
|
||||
let offset = base - camera;
|
||||
let horizontal = glam::DVec2::new(offset.x, offset.z).length();
|
||||
if horizontal >= view {
|
||||
continue;
|
||||
}
|
||||
let distance = horizontal as f32;
|
||||
let scale = if scoping {
|
||||
1.0
|
||||
} else {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -76,9 +76,11 @@ fn view_pages(data: Option<&StackData>) -> Vec<Text> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, page)| match tagged.then(|| &w.page_tags[i]) {
|
||||
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => {
|
||||
horizon_client::entity_nbt::name_text(tag)
|
||||
}
|
||||
// A component object, or a component list (`ComponentSerialization`
|
||||
// takes both; servers behind ViaVersion send the list form).
|
||||
Some(
|
||||
tag @ (horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::List(_)),
|
||||
) => horizon_client::entity_nbt::name_text(tag),
|
||||
_ => Text::literal(page),
|
||||
})
|
||||
.collect()
|
||||
@@ -137,6 +139,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
ClickEvent::OpenUrl(url) => self.click_url(url),
|
||||
ClickEvent::RunCommand(command) => self.click_command(&command),
|
||||
ClickEvent::SuggestCommand(command) => {
|
||||
// defaultHandleClickEvent: the chat screen takes it over.
|
||||
if self.player.is_some() {
|
||||
self.close_book();
|
||||
self.open_chat(&command);
|
||||
}
|
||||
}
|
||||
ClickEvent::CopyToClipboard(value) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,7 +859,7 @@ impl Game<'_> {
|
||||
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
|
||||
/// commands take it. (Vanilla asks first when the command fails to
|
||||
/// parse; here the server answers it.)
|
||||
fn click_command(&mut self, command: &str) {
|
||||
pub(super) fn click_command(&mut self, command: &str) {
|
||||
let command = command.strip_prefix('/').unwrap_or(command);
|
||||
if self.mod_command(command) {
|
||||
return;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2666,10 +2666,25 @@ impl Game<'_> {
|
||||
let hit = self.with_level(|b, level, ticks| {
|
||||
b.target_projectile_hit(level, ticks, block, location, face, arrow)
|
||||
});
|
||||
if hit.is_some() {
|
||||
if let Some(strength) = hit {
|
||||
let _ = self.process_level_events();
|
||||
if p.owner == Some(Who::Player) {
|
||||
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
|
||||
self.stats.custom("target_hit", 1);
|
||||
let kind = if p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
|
||||
{
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
p.kind.id()
|
||||
};
|
||||
let projectile = horizon_client::advancements::EntityView {
|
||||
kind: kind.to_owned(),
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
self.trigger_target_hit(&projectile, strength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Field of view (`Camera.tickFov`, `calculateFov`,
|
||||
//! `AbstractClientPlayer.getFieldOfViewModifier`), view and hurt bobbing
|
||||
//! (`GameRenderer.bobHurt`, `bobView`) and the spyglass (`SpyglassItem`,
|
||||
//! (`GameRenderer.bobHurt`, `bobView`), the portal and Nausea spin
|
||||
//! (`GameRenderer.renderLevel`) and the spyglass (`SpyglassItem`,
|
||||
//! `Hud.extractSpyglassOverlay`). The arithmetic is `horizon_client::camera`.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
camera::{
|
||||
BobState, CameraFluid, FovPlayer, HurtBob, UseItem, bob_matrix, field_of_view_modifier,
|
||||
spinning_effect_matrix,
|
||||
},
|
||||
fog::{FogType, fog_type},
|
||||
hud::HudBatch,
|
||||
@@ -26,6 +28,28 @@ impl Game<'_> {
|
||||
pub(super) fn fov_effect_scale(&self) -> f32 {
|
||||
self.settings.file.double("fovEffectScale") as f32
|
||||
}
|
||||
/// `options.screenEffectScale` (Distortion Effects).
|
||||
pub(super) fn screen_effect_scale(&self) -> f32 {
|
||||
self.settings.file.double("screenEffectScale") as f32
|
||||
}
|
||||
/// `LocalPlayer.getEffectBlendFactor(NAUSEA, partial)`: 0 without the effect.
|
||||
pub(super) fn nausea_blend(&self, partial: f32) -> f32 {
|
||||
if self.potions.effects.get("nausea").is_none() {
|
||||
return 0.0;
|
||||
}
|
||||
let fx = &self.screen_fx;
|
||||
fx.o_nausea + partial * (fx.nausea - fx.o_nausea)
|
||||
}
|
||||
/// The spin `GameRenderer.renderLevel` multiplies into the level's
|
||||
/// projection: the larger of the portal and Nausea intensities, scaled by
|
||||
/// Distortion Effects squared, turning by `LocalPlayer.getSpinningEffectAngle`.
|
||||
pub(super) fn spinning_matrix(&self, partial: f32) -> glam::Mat4 {
|
||||
let fx = &self.screen_fx;
|
||||
let portal = fx.o_portal + partial * (fx.portal - fx.o_portal);
|
||||
let scale = self.screen_effect_scale();
|
||||
let intensity = portal.max(self.nausea_blend(partial)) * (scale * scale);
|
||||
spinning_effect_matrix(intensity, fx.spinning_time + partial * fx.spinning_speed)
|
||||
}
|
||||
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
|
||||
fn fov_player(&self) -> Option<FovPlayer> {
|
||||
let p = self.player.as_ref()?;
|
||||
|
||||
@@ -429,7 +429,9 @@ impl Game<'_> {
|
||||
if let Some(id) = map {
|
||||
// ItemFrameRenderer: the map fills the face, in 90-degree steps.
|
||||
let depth = if f.invisible { 0.5 } else { 0.4375 };
|
||||
let item_light = if f.glow { 240 | 240 << 8 } else { packed };
|
||||
// A glow frame's map is 15728850 = 0xF000D2 (sky coordinate 240,
|
||||
// block coordinate 210), not full bright.
|
||||
let item_light = if f.glow { 240 | 210 << 8 } else { packed };
|
||||
let m = pose
|
||||
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
|
||||
* glam::DMat4::from_rotation_z(
|
||||
|
||||
@@ -82,11 +82,11 @@ fn civil(days: i64) -> (i64, u32, u32) {
|
||||
(yoe + era * 400 + i64::from(m <= 2), m, d)
|
||||
}
|
||||
/// `DateTimeFormatter.ofPattern(pattern).format(now)` for the month, day,
|
||||
/// year and time letters item models use (UTC: no time zone database).
|
||||
/// year and time letters item models use, in the system time zone
|
||||
/// (`LocalTime` without a `time_zone`).
|
||||
pub(super) fn local_time(pattern: &str) -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
let now = chrono::Local::now();
|
||||
let secs = now.timestamp() + i64::from(now.offset().local_minus_utc());
|
||||
let (y, mo, d) = civil(secs.div_euclid(86_400));
|
||||
let s = secs.rem_euclid(86_400);
|
||||
let (h, mi, se) = (s / 3600, s / 60 % 60, s % 60);
|
||||
|
||||
@@ -2607,6 +2607,9 @@ impl Game<'_> {
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
// LivingEntity.aiStep: a Riptide spin counts down on the server's
|
||||
// copy too, or it would stay in the spin pose (eye 0.4) for good.
|
||||
w.player.count_down_server_spin();
|
||||
// Player.tick ends with updatePlayerPose: a swimmer, or a player
|
||||
// crawling under a block, takes the short box its moves are checked with.
|
||||
let _ = w.update_player_pose();
|
||||
@@ -2706,8 +2709,15 @@ impl Game<'_> {
|
||||
// others (Player.playSound excludes the player, whose client
|
||||
// plays it), the entity event 53 everyone around.
|
||||
MotionEvent::HoneySlide {
|
||||
sound, particles, ..
|
||||
pos,
|
||||
sound,
|
||||
particles,
|
||||
} => {
|
||||
if self.world.game_time % 20 == 0 {
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.trigger_honey_slide([pos.x, pos.y, pos.z])
|
||||
});
|
||||
}
|
||||
let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position))
|
||||
else {
|
||||
continue;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -202,7 +202,7 @@ impl Minecart {
|
||||
self.velocity.x.hypot(self.velocity.z)
|
||||
}
|
||||
/// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`.
|
||||
fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
|
||||
pub(super) fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
|
||||
let mut total = 0.0f32;
|
||||
for s in slots.iter().flatten() {
|
||||
let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64);
|
||||
@@ -1029,22 +1029,6 @@ impl Game<'_> {
|
||||
}
|
||||
best
|
||||
}
|
||||
/// Each cart's kind and comparator signal, for the detector rail
|
||||
/// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open
|
||||
/// counts the items in that menu.
|
||||
pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> {
|
||||
self.carts
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
|
||||
let slots = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Chest(open)) => open,
|
||||
_ => &c.items,
|
||||
};
|
||||
(c.kind, Minecart::container_signal(slots, self.res.items()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
|
||||
pub(super) fn interact_cart(&mut self) -> bool {
|
||||
let Some((i, d)) = self.cart_hit() else {
|
||||
|
||||
@@ -95,7 +95,9 @@ impl Game<'_> {
|
||||
/// Capability `recipes`'s tables and known displays for this tick's
|
||||
/// snapshot; nothing while no running mod reads them.
|
||||
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<Recipes>>, Arc<[i32]>) {
|
||||
if !self.mods.mods.wants_recipes() {
|
||||
// The tables come from the world resources, which may still be
|
||||
// loading behind the title on a slow start.
|
||||
if !self.mods.mods.wants_recipes() || self.res.rest.get().is_none() {
|
||||
return (None, Arc::from([]));
|
||||
}
|
||||
if self.mods.recipes.is_none() {
|
||||
|
||||
@@ -158,7 +158,10 @@ pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
|
||||
ambient_interval: 80,
|
||||
flags: Default::default(),
|
||||
model: Arc::new(model),
|
||||
layers: Default::default(),
|
||||
// PlayerModel's slim arms, for slim skins.
|
||||
layers: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
|
||||
.map(|slim| [("player_slim#main".to_owned(), Arc::new(slim))].into())
|
||||
.unwrap_or_default(),
|
||||
texture: "entity/player/wide/steve".into(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Humanoid { zombie_arms: false },
|
||||
|
||||
@@ -213,7 +213,8 @@ pub(super) struct Remote {
|
||||
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
|
||||
show_death_screen: bool,
|
||||
player_type: Option<Arc<MobType>>,
|
||||
/// Inventory as last synchronised, for creative-mode slot updates.
|
||||
/// Inventory (then the off hand) as last synchronised, for creative-mode
|
||||
/// slot updates.
|
||||
synced: Vec<Option<ItemStack>>,
|
||||
decode_errors: u32,
|
||||
clock: Option<i32>,
|
||||
@@ -510,7 +511,7 @@ impl Game<'_> {
|
||||
death_message: None,
|
||||
show_death_screen: true,
|
||||
player_type,
|
||||
synced: vec![None; 36],
|
||||
synced: vec![None; 37],
|
||||
decode_errors: 0,
|
||||
clock: None,
|
||||
clock_rate: 1.0,
|
||||
@@ -699,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)
|
||||
@@ -3086,6 +3086,20 @@ impl Game<'_> {
|
||||
.collect();
|
||||
Some((players, r.online_mode, upside_down))
|
||||
}
|
||||
/// The signed-in account's `textures`, once the services answered.
|
||||
fn own_textures(&self) -> Option<horizon_client::skins::Textures> {
|
||||
use horizon_client::skins::{self, NameOrId, Resolve};
|
||||
let auth = horizon_client::auth::current()?;
|
||||
let state = self
|
||||
.skin_profiles
|
||||
.borrow_mut()
|
||||
.state(&NameOrId::Id(auth.uuid));
|
||||
let Resolve::Done(Some(profile)) = state else {
|
||||
return None;
|
||||
};
|
||||
let (value, signature) = profile.textures()?;
|
||||
Some(skins::unpack(&value, signature, &skins::TEXTURE_URIS))
|
||||
}
|
||||
/// `PlayerInfo.getSkin` for every player entity, including NPCs that are
|
||||
/// never in the tab list: resolve each profile's textures and give the
|
||||
/// level's player models their skin.
|
||||
@@ -3094,12 +3108,23 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let local = r.profile;
|
||||
let entries: Vec<(u128, horizon_client::skins::Textures)> = r
|
||||
let mut entries: Vec<(u128, horizon_client::skins::Textures)> = r
|
||||
.skin_textures
|
||||
.iter()
|
||||
.map(|(uuid, textures)| (*uuid, textures.clone()))
|
||||
.collect();
|
||||
// Minecraft.profileFuture: the signed-in account's own profile, fetched from the
|
||||
// services, gives the local player its skin where the server sends none (single
|
||||
// player's offline-mode server).
|
||||
if !entries.iter().any(|(u, t)| *u == local && t.skin.is_some())
|
||||
&& let Some(own) = self.own_textures()
|
||||
{
|
||||
entries.retain(|(u, _)| *u != local);
|
||||
entries.push((local, own));
|
||||
}
|
||||
let mut resolved: Vec<(u128, String)> = Vec::new();
|
||||
// PlayerSkin.model of each resolved skin: the textures' `slim`.
|
||||
let mut slim = std::collections::HashSet::new();
|
||||
for (uuid, textures) in entries {
|
||||
// PlayerInfo.createSkinLookup: only the local player's own skin
|
||||
// may be unsigned.
|
||||
@@ -3110,10 +3135,22 @@ impl Game<'_> {
|
||||
})
|
||||
};
|
||||
if let Some(skin) = skin {
|
||||
if textures.slim {
|
||||
slim.insert(uuid);
|
||||
}
|
||||
resolved.push((uuid, skin));
|
||||
}
|
||||
}
|
||||
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin));
|
||||
// PlayerInfo.getSkin: the downloaded skin and its model, else the
|
||||
// profile's default skin (DefaultPlayerSkin.get), slim or wide.
|
||||
let skin_of = |skins: &HashMap<u128, String>, uuid: u128| match skins.get(&uuid) {
|
||||
Some(skin) => (skin.clone(), slim.contains(&uuid)),
|
||||
None => {
|
||||
let skin = horizon_client::tab_list::default_skin(uuid);
|
||||
(skin.to_owned(), skin.contains("/slim/"))
|
||||
}
|
||||
};
|
||||
let Some((level, skins, own)) = self.remote.as_mut().map(|r| {
|
||||
r.skins = resolved.into_iter().collect();
|
||||
(
|
||||
@@ -3122,27 +3159,26 @@ impl Game<'_> {
|
||||
.map(|(id, uuid)| (*id as u32, *uuid))
|
||||
.collect::<Vec<_>>(),
|
||||
r.skins.clone(),
|
||||
r.skins.get(&r.profile).cloned(),
|
||||
r.profile,
|
||||
)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
for (id, uuid) in level {
|
||||
let Some(skin) = skins.get(&uuid) else {
|
||||
continue;
|
||||
};
|
||||
let (skin, is_slim) = skin_of(&skins, uuid);
|
||||
if let Some(mob) = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id && &*m.kind.id == "minecraft:player")
|
||||
{
|
||||
mob.skin = Some(skin.clone());
|
||||
mob.skin = Some(skin);
|
||||
mob.slim = is_slim;
|
||||
}
|
||||
}
|
||||
if let Some(skin) = own
|
||||
&& let Some(mob) = self.player_model.as_mut()
|
||||
{
|
||||
if let Some(mob) = self.player_model.as_mut() {
|
||||
let (skin, is_slim) = skin_of(&skins, own);
|
||||
mob.skin = Some(skin);
|
||||
mob.slim = is_slim;
|
||||
}
|
||||
// ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a
|
||||
// mannequin's DATA_PROFILE `textures` property resolves like a
|
||||
@@ -3192,7 +3228,7 @@ impl Game<'_> {
|
||||
(m.id, textures)
|
||||
})
|
||||
.collect();
|
||||
let mut resolved: Vec<(u32, Option<String>)> = Vec::new();
|
||||
let mut resolved: Vec<(u32, Option<String>, bool)> = Vec::new();
|
||||
for (id, textures) in mannequins {
|
||||
let skin = {
|
||||
let mut downloads = self.skin_downloads.borrow_mut();
|
||||
@@ -3200,15 +3236,17 @@ impl Game<'_> {
|
||||
downloads.state(kind, url)
|
||||
})
|
||||
};
|
||||
resolved.push((id, skin));
|
||||
let slim = skin.is_some() && textures.slim;
|
||||
resolved.push((id, skin, slim));
|
||||
}
|
||||
for (id, skin) in resolved {
|
||||
for (id, skin, slim) in resolved {
|
||||
let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s));
|
||||
if let Some(mob) = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id && &*m.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
mob.slim = slim && skin.is_some();
|
||||
mob.skin = skin;
|
||||
}
|
||||
}
|
||||
@@ -3592,6 +3630,7 @@ impl Game<'_> {
|
||||
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
// Gui.tick: the titles, the action bar and the held item's name.
|
||||
self.tick_hud();
|
||||
self.tick_subtitles();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let pushed = self.remote_pushes();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
@@ -4307,12 +4346,14 @@ impl Game<'_> {
|
||||
{
|
||||
out.push(packet);
|
||||
}
|
||||
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot).
|
||||
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot);
|
||||
// `synced[36]` is the off hand.
|
||||
let mut creative_slots = Vec::new();
|
||||
if self.creative {
|
||||
for i in 0..36 {
|
||||
if self.inventory[i] != r.synced[i] {
|
||||
r.synced[i] = self.inventory[i].clone();
|
||||
for i in 0..37 {
|
||||
let stack = self.inventory.get(i).unwrap_or(&self.offhand);
|
||||
if *stack != r.synced[i] {
|
||||
r.synced[i] = stack.clone();
|
||||
creative_slots.push(i);
|
||||
}
|
||||
}
|
||||
@@ -4321,8 +4362,13 @@ impl Game<'_> {
|
||||
self.send(packet);
|
||||
}
|
||||
for i in creative_slots {
|
||||
let slot = if i < 9 { 36 + i } else { i } as i16;
|
||||
let item = self.to_network(&self.inventory[i]);
|
||||
// InventoryMenu's slots: the hotbar is 36..=44, the off hand 45.
|
||||
let slot = match i {
|
||||
0..9 => 36 + i,
|
||||
36 => 45,
|
||||
_ => i,
|
||||
} as i16;
|
||||
let item = self.to_network(self.inventory.get(i).unwrap_or(&self.offhand));
|
||||
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot,
|
||||
item,
|
||||
@@ -4331,7 +4377,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// Inventory as the server last described it.
|
||||
fn remote_synced(&mut self) {
|
||||
let inventory = self.inventory.clone();
|
||||
let mut inventory = self.inventory.clone();
|
||||
inventory.push(self.offhand.clone());
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.synced = inventory;
|
||||
}
|
||||
@@ -5055,8 +5102,11 @@ impl Game<'_> {
|
||||
// countdown goes on past zero (LivingEntity.updateUsingItem completes
|
||||
// only on the server) until the server's USE_ITEM_COMPLETE.
|
||||
let remaining = consumable.ticks() - ticks as i32;
|
||||
if consumable.particles && consumable.should_emit(remaining) {
|
||||
self.eat_particles(&stack, 5);
|
||||
if consumable.should_emit(remaining) {
|
||||
// `emit_consume` plays the eat sound and spawns the bites (when the
|
||||
// item has consume particles), as `tick_used_item` does on the
|
||||
// player's own server.
|
||||
self.emit_consume(&consumable, &stack, 5);
|
||||
}
|
||||
self.using = Some((slot, ticks + 1));
|
||||
}
|
||||
@@ -5337,11 +5387,12 @@ impl Game<'_> {
|
||||
KeyCode::KeyE => {
|
||||
// The inventory opens without telling the server.
|
||||
self.recipe_ui.reset_screen();
|
||||
self.screen = if self.creative {
|
||||
Screen::Creative
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
self.creative_screen.reopen();
|
||||
} else {
|
||||
Screen::Inventory
|
||||
};
|
||||
self.screen = Screen::Inventory;
|
||||
}
|
||||
self.craft_size = 2;
|
||||
self.capture(false);
|
||||
Ok(true)
|
||||
|
||||
@@ -108,7 +108,11 @@ impl Game<'_> {
|
||||
rain,
|
||||
thunder,
|
||||
skybox: self.dim == Dim::Overworld,
|
||||
blindness: None,
|
||||
blindness: self
|
||||
.potions
|
||||
.effects
|
||||
.get("blindness")
|
||||
.map(|effect| (effect.duration, effect.duration == -1)),
|
||||
darkness: self.darkness_fog(),
|
||||
darken_world: self
|
||||
.boss_overlay
|
||||
@@ -140,7 +144,8 @@ impl Game<'_> {
|
||||
} else {
|
||||
63.0
|
||||
};
|
||||
camera.y < horizon
|
||||
self.dim == Dim::Overworld
|
||||
&& camera.y < horizon
|
||||
&& fog_type(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
@@ -209,8 +214,8 @@ impl Game<'_> {
|
||||
percent_frozen: p.percent_frozen(),
|
||||
partial,
|
||||
portal: [fx.o_portal, fx.portal],
|
||||
nausea: 0.0,
|
||||
screen_effect_scale: 1.0,
|
||||
nausea: self.nausea_blend(partial),
|
||||
screen_effect_scale: self.screen_effect_scale(),
|
||||
};
|
||||
let mut scope = self.scope_scale;
|
||||
let gui_overlays =
|
||||
@@ -247,6 +252,12 @@ impl Game<'_> {
|
||||
let brightness = self.eye_brightness(e);
|
||||
let inside_portal = self.client_portal_effect();
|
||||
self.screen_fx.tick(brightness, inside_portal);
|
||||
let nausea = self
|
||||
.potions
|
||||
.effects
|
||||
.get("nausea")
|
||||
.map(|e| !e.ends_within(60));
|
||||
self.screen_fx.tick_spinning(nausea);
|
||||
// LocalPlayer water vision and AtmosphericFogEnvironment rain fog.
|
||||
let in_water =
|
||||
fog_type(&self.world, self.res.facts(), e, self.forward()) == FogType::Water;
|
||||
|
||||
@@ -269,6 +269,8 @@ impl Game<'_> {
|
||||
open.requests.push((p.id, p.url, p.hash));
|
||||
open.required = p.required;
|
||||
open.prompt = text;
|
||||
// Gui.setScreen: KeyMapping.releaseAll, so no walking behind the prompt.
|
||||
self.keys.clear();
|
||||
self.capture(false);
|
||||
}
|
||||
/// `handleResourcePackPop`: one pack, or all of them.
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
Mob, Who,
|
||||
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerSpawn},
|
||||
spawning,
|
||||
trial_spawner::TrialState,
|
||||
},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
@@ -373,12 +374,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 +386,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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2059,9 +2059,16 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// ItemStack.OP_NBT_WARNING, for a player with game master permission.
|
||||
if !nested && self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
|
||||
for (key, fallback) in [
|
||||
// ItemStack.OP_NBT_WARNING: `BlockItem` and `SpawnEggItem`
|
||||
// `.shouldPrintOpWarning` for a player with game master permission;
|
||||
// a nested projectile has no player. The first line is bold.
|
||||
if !nested
|
||||
&& self.ui.registries.permission >= 2
|
||||
&& (item_info.is_some_and(|i| i.block.is_some())
|
||||
&& block_entity_item::op_warning(stack)
|
||||
|| block_entity_item::spawn_egg_op_warning(stack))
|
||||
{
|
||||
for (i, (key, fallback)) in [
|
||||
("item.op_warning.line1", "Warning:"),
|
||||
(
|
||||
"item.op_warning.line2",
|
||||
@@ -2071,12 +2078,15 @@ impl Game<'_> {
|
||||
"item.op_warning.line3",
|
||||
"Do not use unless you know the exact contents!",
|
||||
),
|
||||
] {
|
||||
push(
|
||||
&mut lines,
|
||||
self.translate(key, fallback),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let text = horizon_client::text::Text::legacy(&self.translate(key, fallback));
|
||||
lines.push((
|
||||
if i == 0 { text.bold() } else { text },
|
||||
rgba(255, 85, 85, 255),
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
lines
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -105,12 +105,9 @@ const COMPLETE_COLUMN: u32 = (1 << (crate::chunk::HEIGHT / 16)) - 1;
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
/// The world pass again after occlusion culling's test: it loads both
|
||||
/// targets (phase 2 draws over phase 1, reading the test's results).
|
||||
/// LevelRenderer: no sky inside lava or powder snow (sky fog ends within 2 blocks).
|
||||
/// `LevelRenderer.addSkyPass`: fluids and camera effects block the sky pass.
|
||||
fn sky_open(frame: &Frame) -> bool {
|
||||
match frame.fog {
|
||||
Some(f) => f.sky_end > 2.0,
|
||||
None => !frame.underwater,
|
||||
}
|
||||
!frame.sky_blocked
|
||||
}
|
||||
|
||||
/// The Overworld-style sky (disc, sunset, stars, void) draws this frame.
|
||||
@@ -222,6 +219,9 @@ pub struct Frame<'a> {
|
||||
/// The `GameRenderer.bobHurt`/`bobView` pose multiplied into the
|
||||
/// projection (`crate::camera::bob_matrix`).
|
||||
pub bob: Mat4,
|
||||
/// The spinning effect multiplied into the level's projection only
|
||||
/// (`crate::camera::spinning_effect_matrix`), not the hand's.
|
||||
pub spin: Mat4,
|
||||
pub sky: Sky,
|
||||
/// Render distance in blocks (fog end).
|
||||
pub distance: f32,
|
||||
@@ -235,6 +235,8 @@ pub struct Frame<'a> {
|
||||
pub cave_culling: bool,
|
||||
/// Title screen: only the dedicated panorama cube map and the HUD.
|
||||
pub title: bool,
|
||||
/// Lava, powder snow, blindness or darkness blocks the sky pass.
|
||||
pub sky_blocked: bool,
|
||||
/// Dimensions without a skybox (the Nether) clear to the fog colour.
|
||||
pub skybox: bool,
|
||||
/// The End: clear to black and draw the resident sky box behind the world.
|
||||
@@ -3418,7 +3420,7 @@ impl Renderer {
|
||||
frame.fov_degrees,
|
||||
frame.yaw,
|
||||
frame.pitch,
|
||||
frame.bob,
|
||||
frame.bob * frame.spin,
|
||||
frame.depth_far,
|
||||
)
|
||||
}
|
||||
@@ -4508,7 +4510,7 @@ impl Renderer {
|
||||
cull.bind_depth(&self.device, world_depth);
|
||||
}
|
||||
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
|
||||
let clear = if frame.title || (frame.end_sky && !frame.underwater) {
|
||||
let clear = if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
|
||||
wgpu::Color::BLACK
|
||||
} else {
|
||||
wgpu::Color {
|
||||
|
||||
@@ -361,8 +361,9 @@ impl NativePost {
|
||||
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
let projection =
|
||||
super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob;
|
||||
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
|
||||
* frame.bob
|
||||
* frame.spin;
|
||||
// A celestial body's direction at `angle` degrees (SkyRenderer: Y -90°, then X by the
|
||||
// angle, of straight up): overhead at 0, rising in the east (+x).
|
||||
let body = |angle: f32| {
|
||||
|
||||
@@ -287,7 +287,8 @@ impl FrameUniforms {
|
||||
aspect,
|
||||
0.05,
|
||||
frame.depth_far,
|
||||
) * frame.bob;
|
||||
) * frame.bob
|
||||
* frame.spin;
|
||||
let camera = frame.camera.as_vec3();
|
||||
let (previous_camera, previous_view, previous_projection) =
|
||||
self.previous
|
||||
|
||||
@@ -31,7 +31,6 @@ pub mod cauldron;
|
||||
pub mod chat;
|
||||
pub mod chunk;
|
||||
pub mod chunk_map;
|
||||
pub mod clipboard;
|
||||
pub mod clock;
|
||||
pub mod clouds;
|
||||
pub mod collisions;
|
||||
@@ -70,9 +69,9 @@ 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 gamepad;
|
||||
pub mod gfx;
|
||||
pub mod gizmos;
|
||||
pub mod gossip;
|
||||
@@ -86,17 +85,12 @@ pub mod hud;
|
||||
pub mod hud_layout;
|
||||
pub mod hud_overlays;
|
||||
pub mod hunger;
|
||||
pub mod input;
|
||||
pub mod interaction_input;
|
||||
pub mod inv_sort;
|
||||
pub mod inventory_effects;
|
||||
pub mod item_render;
|
||||
pub mod item_sync;
|
||||
pub mod item_tooltip;
|
||||
pub mod java_text;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod lan;
|
||||
pub mod landing;
|
||||
pub mod level_loading;
|
||||
@@ -116,7 +110,6 @@ pub mod mod_list;
|
||||
mod model_library;
|
||||
pub mod models;
|
||||
pub mod motion;
|
||||
pub mod mouse;
|
||||
pub mod moved_extras;
|
||||
pub mod multiplayer;
|
||||
pub mod music;
|
||||
@@ -141,6 +134,7 @@ pub mod portal_sections;
|
||||
pub mod pose;
|
||||
pub mod pregen;
|
||||
pub mod projectiles;
|
||||
pub mod providers;
|
||||
pub mod raid;
|
||||
pub mod random_tick_sections;
|
||||
pub mod recipe_book;
|
||||
@@ -187,7 +181,6 @@ pub mod vault;
|
||||
pub mod weather;
|
||||
pub mod widgets;
|
||||
pub mod win_screen;
|
||||
pub mod window;
|
||||
pub mod world;
|
||||
pub mod world_spawn;
|
||||
pub mod worldborder;
|
||||
@@ -203,6 +196,15 @@ pub use horizon_client_core::nbt_path;
|
||||
pub use horizon_client_core::scoreboard;
|
||||
pub use horizon_client_core::snbt;
|
||||
pub use horizon_client_core::text;
|
||||
pub use horizon_client_input::clipboard;
|
||||
pub use horizon_client_input::gamepad;
|
||||
pub use horizon_client_input::input;
|
||||
pub use horizon_client_input::interaction_input;
|
||||
pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
pub use horizon_client_input::keymapping;
|
||||
pub use horizon_client_input::mouse;
|
||||
pub use horizon_client_input::window;
|
||||
pub use horizon_command as command;
|
||||
pub use horizon_items::blocks;
|
||||
pub use horizon_items::bundle;
|
||||
|
||||
@@ -937,6 +937,8 @@ pub struct Mob {
|
||||
/// The skin texture of a player mob (`PlayerInfo.getSkin`), which
|
||||
/// replaces the type's default when its download is ready.
|
||||
pub skin: Option<String>,
|
||||
/// `PlayerSkin.model` is `SLIM`: the player model with 3-pixel arms.
|
||||
pub slim: bool,
|
||||
/// A mannequin's `DATA_PROFILE` (ResolvableProfile): the profile's
|
||||
/// `textures` property resolves to `skin` like a player's.
|
||||
pub profile: Option<horizon_protocol::entity_data::Profile>,
|
||||
@@ -1908,6 +1910,7 @@ impl Mob {
|
||||
rider: None,
|
||||
outbox: Vec::new(),
|
||||
skin: None,
|
||||
slim: false,
|
||||
profile: None,
|
||||
attrs: Attrs {
|
||||
speed: kind.speed,
|
||||
|
||||
@@ -405,3 +405,43 @@ impl Goal for Rolled {
|
||||
scared(m)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mobs::sensor_scan_tests::kind;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An armadillo on the ground.
|
||||
fn armadillo() -> Mob {
|
||||
let mut kind = kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:armadillo".into();
|
||||
k.species = Species::Armadillo;
|
||||
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
m.on_ground = true;
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hit_rolls_it_up_into_a_ball() {
|
||||
// Armadillo.actuallyHurt: a hit by a living thing curls it up and
|
||||
// keeps the danger in mind for 80 ticks, so the model shows the
|
||||
// shell instead of the armadillo (#2759).
|
||||
let mut m = armadillo();
|
||||
after_hurt(&mut m, &Damage::player([0.0, 0.0, 0.0]));
|
||||
assert_eq!(state(&m), ROLLING);
|
||||
// Armadillo.rollUp: the roll sound.
|
||||
assert!(
|
||||
m.outbox
|
||||
.iter()
|
||||
.any(|e| matches!(e, MobEvent::Sound(s, ..) if *s == "entity.armadillo.roll")),
|
||||
"the roll plays its sound"
|
||||
);
|
||||
// ArmadilloState.shouldHideInShell: the shell shows after five ticks.
|
||||
for _ in 0..6 {
|
||||
tick(&mut m);
|
||||
}
|
||||
assert!(hiding_in_shell(&m), "the model shows the shell");
|
||||
}
|
||||
}
|
||||
@@ -115,8 +115,9 @@ pub fn body(m: &Mob) -> (String, String) {
|
||||
}
|
||||
};
|
||||
if avatar(m) {
|
||||
// AvatarRenderer: PlayerModel's slim layer for a slim skin.
|
||||
return (
|
||||
"main".into(),
|
||||
if m.slim { "player_slim#main" } else { "main" }.into(),
|
||||
m.skin.clone().unwrap_or_else(|| m.kind.texture.clone()),
|
||||
);
|
||||
}
|
||||
@@ -784,7 +785,8 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
|
||||
/// `HumanoidArmorLayer`: the armour models of the mob's main model with the
|
||||
/// equipment textures (leather tinted, with its overlay).
|
||||
fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
|
||||
if super::equine::is_equine(m.species()) {
|
||||
// A player's or mannequin's armour is drawn by the game's armour layers.
|
||||
if super::equine::is_equine(m.species()) || avatar(m) {
|
||||
return;
|
||||
}
|
||||
let (model, _) = body(m);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -111,22 +111,7 @@ impl SpawnData {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Identifier.CODEC` parsing an entity id: lowercase namespace and path
|
||||
/// characters, `minecraft` when the namespace is left out.
|
||||
pub fn normalize_id(s: &str) -> Option<String> {
|
||||
let (ns, path) = match s.split_once(':') {
|
||||
Some((ns, path)) => (if ns.is_empty() { "minecraft" } else { ns }, path),
|
||||
None => ("minecraft", s),
|
||||
};
|
||||
let ok = |c: char, slash: bool| {
|
||||
c.is_ascii_lowercase()
|
||||
|| c.is_ascii_digit()
|
||||
|| matches!(c, '_' | '-' | '.')
|
||||
|| slash && c == '/'
|
||||
};
|
||||
(ns.chars().all(|c| ok(c, false)) && path.chars().all(|c| ok(c, true)))
|
||||
.then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
pub use horizon_client_core::identifier::normalize_id;
|
||||
|
||||
/// How much a slime's or magma cube's box is scaled by the size its data
|
||||
/// loads (`Slime.readAdditionalSaveData`: Size + 1, kept in 1..=127), which
|
||||
|
||||
@@ -1331,6 +1331,7 @@ impl Def {
|
||||
let mut v = vec![base];
|
||||
let extra: &[&str] = match self.species {
|
||||
Species::Pig => &["pig_baby", "cold_pig"],
|
||||
Species::Mannequin => &["player_slim"],
|
||||
Species::Cow => &[
|
||||
"cow_baby",
|
||||
"cold_cow",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -790,20 +790,6 @@ pub fn golem_detected(m: &mut Mob, now: i64) {
|
||||
// ---- Behaviours ---------------------------------------------------------
|
||||
|
||||
pub use crate::poi_sections::Poi;
|
||||
impl Poi {
|
||||
fn max_tickets(self) -> usize {
|
||||
match self {
|
||||
Poi::Meeting => 32,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
fn valid_range(self) -> i32 {
|
||||
match self {
|
||||
Poi::Meeting => 6,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The POI a block state is (`PoiTypes`): job site blocks, bed heads, bells.
|
||||
pub fn poi_at(w: &MobWorld, x: i32, y: i32, z: i32) -> Option<Poi> {
|
||||
poi_of_state(w, w.world.block(x, y, z))
|
||||
|
||||
@@ -1551,7 +1551,10 @@ impl MotionWorld<'_, '_, '_> {
|
||||
// Only the server broadcasts event 53 (`!level.isClientSide()`).
|
||||
let particles = !self.player.client_side
|
||||
&& random.next_int_bound(5).map_err(|e| format!("{e:?}"))? == 0;
|
||||
if sound || particles {
|
||||
// The server also checks the slide criterion every 20 game ticks,
|
||||
// including ticks without a sound or a particle event.
|
||||
let criterion = !self.player.client_side && self.scene.game_time % 20 == 0;
|
||||
if sound || particles || criterion {
|
||||
self.player.events.push(MotionEvent::HoneySlide {
|
||||
pos,
|
||||
sound,
|
||||
@@ -3511,6 +3514,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_server_copy_of_a_riptide_spin_counts_down_and_stands_again() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 10.0, 20.5)).unwrap();
|
||||
player.auto_spin_attack_ticks = 2;
|
||||
player.auto_spin_attack_dmg = 8.0;
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
w.update_player_pose().unwrap();
|
||||
assert_eq!(w.player.pose, crate::pose::Pose::SpinAttack);
|
||||
w.player.count_down_server_spin();
|
||||
w.update_player_pose().unwrap();
|
||||
assert_eq!(w.player.pose, crate::pose::Pose::SpinAttack);
|
||||
assert_eq!(w.player.auto_spin_attack_dmg, 8.0);
|
||||
w.player.count_down_server_spin();
|
||||
assert_eq!(w.player.auto_spin_attack_ticks, 0);
|
||||
assert_eq!(w.player.auto_spin_attack_dmg, 0.0);
|
||||
w.update_player_pose().unwrap();
|
||||
assert_eq!(w.player.pose, crate::pose::Pose::Standing);
|
||||
assert_eq!(w.player.dimensions.eye_height, 1.62);
|
||||
// Nothing to count once the spin is over.
|
||||
w.player.count_down_server_spin();
|
||||
assert_eq!(w.player.auto_spin_attack_ticks, 0);
|
||||
}
|
||||
#[test]
|
||||
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
|
||||
@@ -73,6 +73,17 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
|
||||
Opt::RotateWithMinecarts,
|
||||
Opt::HighContrastBlockOutlines,
|
||||
];
|
||||
|
||||
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
|
||||
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
|
||||
Opt::Sensitivity,
|
||||
Opt::WheelSensitivity,
|
||||
Opt::DiscreteScroll,
|
||||
Opt::InvertX,
|
||||
Opt::InvertY,
|
||||
Opt::AllowCursorChanges,
|
||||
];
|
||||
|
||||
impl Opt {
|
||||
/// Keep controls visible while their underlying systems are still missing.
|
||||
pub(crate) fn available(self) -> bool {
|
||||
@@ -89,7 +100,6 @@ impl Opt {
|
||||
| Opt::DistortionEffects
|
||||
| Opt::MonochromeLogo
|
||||
| Opt::RotateWithMinecarts
|
||||
| Opt::HighContrastBlockOutlines
|
||||
// Saved, but nothing in rendering reads them yet.
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
@@ -129,8 +139,10 @@ impl Opt {
|
||||
Opt::SimulationDistance => "options.simulationDistance",
|
||||
Opt::Sensitivity => "options.sensitivity",
|
||||
Opt::WheelSensitivity => "options.mouseWheelSensitivity",
|
||||
Opt::InvertX => "options.invertMouseX",
|
||||
Opt::InvertY => "options.invertMouseY",
|
||||
Opt::DiscreteScroll => "options.discrete_mouse_scroll",
|
||||
Opt::AllowCursorChanges => "options.allowCursorChanges",
|
||||
Opt::Volume(_) => "soundCategory.master",
|
||||
Opt::GuiScale => "options.guiScale",
|
||||
Opt::ViewBobbing => "options.viewBobbing",
|
||||
@@ -454,8 +466,10 @@ impl Opt {
|
||||
Opt::NarratorHotkey => on_off(s.file.bool("narratorHotkey")),
|
||||
Opt::RotateWithMinecarts => on_off(s.file.bool("rotateWithMinecart")),
|
||||
Opt::HighContrastBlockOutlines => on_off(s.file.bool("highContrastBlockOutline")),
|
||||
Opt::InvertX => on_off(s.invert_x),
|
||||
Opt::InvertY => on_off(s.invert_y),
|
||||
Opt::DiscreteScroll => on_off(s.discrete_scroll),
|
||||
Opt::AllowCursorChanges => on_off(s.allow_cursor_changes),
|
||||
Opt::ViewBobbing => on_off(s.view_bobbing),
|
||||
Opt::Vsync => on_off(s.vsync),
|
||||
Opt::Fullscreen => on_off(s.fullscreen),
|
||||
@@ -652,6 +666,7 @@ impl Opt {
|
||||
Opt::TextBackground
|
||||
| Opt::HideLightningFlashes
|
||||
| Opt::HideSplashTexts
|
||||
| Opt::HighContrastBlockOutlines
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts => {
|
||||
@@ -659,8 +674,10 @@ impl Opt {
|
||||
s.file
|
||||
.set(key, crate::options_file::V::Bool(!s.file.bool(key)));
|
||||
}
|
||||
Opt::InvertX => s.invert_x = !s.invert_x,
|
||||
Opt::InvertY => s.invert_y = !s.invert_y,
|
||||
Opt::DiscreteScroll => s.discrete_scroll = !s.discrete_scroll,
|
||||
Opt::AllowCursorChanges => s.allow_cursor_changes = !s.allow_cursor_changes,
|
||||
Opt::ViewBobbing => s.view_bobbing = !s.view_bobbing,
|
||||
Opt::Vsync => s.vsync = !s.vsync,
|
||||
Opt::Fullscreen => s.fullscreen = !s.fullscreen,
|
||||
@@ -1434,12 +1451,7 @@ impl OptionsUi {
|
||||
]));
|
||||
rows
|
||||
}
|
||||
Page::Mouse => pairs(&[
|
||||
Opt::Sensitivity,
|
||||
Opt::WheelSensitivity,
|
||||
Opt::DiscreteScroll,
|
||||
Opt::InvertY,
|
||||
]),
|
||||
Page::Mouse => pairs(MOUSE_OPTIONS),
|
||||
Page::Chat => pairs(&[
|
||||
Opt::ChatVisibility,
|
||||
Opt::ChatColors,
|
||||
@@ -2876,6 +2888,45 @@ mod tests {
|
||||
assert_ne!(s.text_background(0.0), chat_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_settings_match_java_26_3_option_order() {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Mouse);
|
||||
let mut options = Vec::new();
|
||||
for row in ui.list_rows(&Settings::default()) {
|
||||
if let Row::Pair(a, b) = row {
|
||||
options.push(a);
|
||||
if let Some(b) = b {
|
||||
options.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
options,
|
||||
vec![
|
||||
Opt::Sensitivity,
|
||||
Opt::WheelSensitivity,
|
||||
Opt::DiscreteScroll,
|
||||
Opt::InvertX,
|
||||
Opt::InvertY,
|
||||
Opt::AllowCursorChanges,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_settings_round_trip_with_java_defaults() {
|
||||
let mut settings = Settings::default();
|
||||
assert!(!settings.invert_x);
|
||||
assert!(settings.allow_cursor_changes);
|
||||
Opt::InvertX.cycle(&mut settings, false, [854, 480]);
|
||||
Opt::AllowCursorChanges.cycle(&mut settings, false, [854, 480]);
|
||||
let loaded = Settings::parse(&settings.serialize());
|
||||
assert!(loaded.invert_x);
|
||||
assert!(!loaded.allow_cursor_changes);
|
||||
assert!(!loaded.file.bool("allowCursorChanges"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessibility_has_all_options_and_disabled_placeholders() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -87,6 +87,22 @@ pub enum Poi {
|
||||
Home,
|
||||
Meeting,
|
||||
}
|
||||
impl Poi {
|
||||
/// `PoiType.maxTickets`.
|
||||
pub fn max_tickets(self) -> usize {
|
||||
match self {
|
||||
Poi::Meeting => 32,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
/// `PoiType.validRange`.
|
||||
pub fn valid_range(self) -> i32 {
|
||||
match self {
|
||||
Poi::Meeting => 6,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -78,6 +78,18 @@ impl Dimensions {
|
||||
}
|
||||
}
|
||||
impl PlayerMotion {
|
||||
/// `LivingEntity.aiStep` counting down `autoSpinAttackTicks` for a
|
||||
/// server's copy of a player, which runs no client tick: the spin ends
|
||||
/// after its ticks and its damage goes with it, so the pose (and the eye
|
||||
/// a bow or a trident is shot from) comes back to standing.
|
||||
pub fn count_down_server_spin(&mut self) {
|
||||
if self.auto_spin_attack_ticks > 0 {
|
||||
self.auto_spin_attack_ticks -= 1;
|
||||
if self.auto_spin_attack_ticks <= 0 {
|
||||
self.auto_spin_attack_dmg = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn dimensions_for_pose(&mut self, pose: Pose) -> Result<Dimensions> {
|
||||
Ok(pose.dimensions(self.attribute("minecraft:scale")? as f32))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Vanilla's `context_int_provider` and `context_float_provider` values (furnace
|
||||
//! fuel times, brewing times), evaluated from the data pack's JSON.
|
||||
use crate::items::{ItemStack, Items};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
/// Evaluator for `context_int_provider` / `context_float_provider` values.
|
||||
pub struct Providers {
|
||||
ints: HashMap<String, Value>,
|
||||
floats: HashMap<String, Value>,
|
||||
}
|
||||
fn load_dir(dir: &Path, prefix: &str, out: &mut HashMap<String, Value>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
let name = p
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
if p.is_dir() {
|
||||
load_dir(&p, &format!("{prefix}{name}/"), out);
|
||||
} else if let Ok(bytes) = fs::read(&p)
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
out.insert(format!("minecraft:{prefix}{name}"), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Providers {
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let mut ints = HashMap::new();
|
||||
let mut floats = HashMap::new();
|
||||
load_dir(
|
||||
&root.join("data/minecraft/context_int_provider"),
|
||||
"",
|
||||
&mut ints,
|
||||
);
|
||||
load_dir(
|
||||
&root.join("data/minecraft/context_float_provider"),
|
||||
"",
|
||||
&mut floats,
|
||||
);
|
||||
Self { ints, floats }
|
||||
}
|
||||
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
|
||||
if depth > 16 {
|
||||
return 0.0;
|
||||
}
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
||||
Value::String(name) => table
|
||||
.get(name)
|
||||
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
|
||||
Value::Object(o) => {
|
||||
let f =
|
||||
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
|
||||
match o.get("type").and_then(Value::as_str).unwrap_or("") {
|
||||
"minecraft:div" => {
|
||||
let r = f("right");
|
||||
if r == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(f("left") / r).trunc()
|
||||
}
|
||||
}
|
||||
"minecraft:mul" => f("left") * f("right"),
|
||||
"minecraft:add" | "minecraft:sum" => f("left") + f("right"),
|
||||
"minecraft:sub" => f("left") - f("right"),
|
||||
"minecraft:constant" => f("value"),
|
||||
"minecraft:conditional" => {
|
||||
// The only condition in the pinned data: block/fast_cooking.
|
||||
let condition = o.get("condition").and_then(Value::as_str)
|
||||
== Some("minecraft:block/fast_cooking");
|
||||
if condition && fast {
|
||||
f("on_true")
|
||||
} else {
|
||||
f("on_false")
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
/// A plain `context_int_provider` value or reference (no context conditions).
|
||||
pub fn int_value(&self, v: &Value) -> i32 {
|
||||
Self::eval(v, false, &self.ints, 0) as i32
|
||||
}
|
||||
/// A plain `context_float_provider` value or reference; `default` when absent.
|
||||
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
|
||||
if v.is_null() {
|
||||
default
|
||||
} else {
|
||||
Self::eval(v, false, &self.floats, 0) as f32
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
ints: HashMap::new(),
|
||||
floats: HashMap::new(),
|
||||
}
|
||||
}
|
||||
pub fn burn_time(&self, items: &Items, stack: &ItemStack, fast: bool) -> i32 {
|
||||
items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.fuel.as_ref())
|
||||
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
|
||||
}
|
||||
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
|
||||
items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.fuel.as_ref())
|
||||
.map_or(1.0, |(_, speed)| {
|
||||
Self::eval(speed, fast, &self.floats, 0) as f32
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -105,14 +105,7 @@ pub fn chunks_with_block_entities<'a>(
|
||||
}
|
||||
|
||||
use horizon_client_core::nbt_value::string;
|
||||
pub use horizon_client_core::nbt_value::{number, text};
|
||||
pub(crate) fn float(tag: Option<&Tag>) -> Option<f64> {
|
||||
Some(match tag? {
|
||||
Tag::Float(b) => f64::from(f32::from_bits(*b)),
|
||||
Tag::Double(b) => f64::from_bits(*b),
|
||||
other => number(Some(other))? as f64,
|
||||
})
|
||||
}
|
||||
pub use horizon_client_core::nbt_value::{float, number, text};
|
||||
/// Insert or replace a key of a compound tag.
|
||||
pub(crate) fn put(tag: &mut Tag, key: &str, value: Tag) {
|
||||
if let Tag::Compound(map) = tag {
|
||||
|
||||
@@ -69,6 +69,12 @@ pub struct ScreenEffects {
|
||||
pub vignette_brightness: f32,
|
||||
pub portal: f32,
|
||||
pub o_portal: f32,
|
||||
/// Nausea's `MobEffectInstance.BlendState` factor and the previous tick's.
|
||||
pub nausea: f32,
|
||||
pub o_nausea: f32,
|
||||
/// `LocalPlayer.spinningEffectTime` and `spinningEffectSpeed` (degrees).
|
||||
pub spinning_time: f32,
|
||||
pub spinning_speed: f32,
|
||||
}
|
||||
impl Default for ScreenEffects {
|
||||
fn default() -> Self {
|
||||
@@ -76,6 +82,10 @@ impl Default for ScreenEffects {
|
||||
vignette_brightness: 1.0,
|
||||
portal: 0.0,
|
||||
o_portal: 0.0,
|
||||
nausea: 0.0,
|
||||
o_nausea: 0.0,
|
||||
spinning_time: 0.0,
|
||||
spinning_speed: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +115,28 @@ impl ScreenEffects {
|
||||
};
|
||||
self.portal = (self.portal + step).clamp(0.0, 1.0);
|
||||
}
|
||||
/// `LocalPlayer.tickSpinningEffect` and Nausea's blend. `nausea` is `None`
|
||||
/// without the effect, else whether it is past its blend-out point
|
||||
/// (`!endsWithin(60)`, `MobEffects.NAUSEA`).
|
||||
pub fn tick_spinning(&mut self, nausea: Option<bool>) {
|
||||
self.o_nausea = self.nausea;
|
||||
match nausea {
|
||||
// BlendState.tick: fades in over 150 ticks and out over 20.
|
||||
Some(active) => {
|
||||
let (target, duration) = if active { (1.0, 150.0) } else { (0.0, 20.0) };
|
||||
self.nausea += (target - self.nausea).clamp(-1.0 / duration, 1.0 / duration);
|
||||
}
|
||||
// The blend state belongs to the instance, so a new one starts at 0.
|
||||
None => self.nausea = 0.0,
|
||||
}
|
||||
if self.portal > 0.0 || self.nausea > 0.0 {
|
||||
self.spinning_speed =
|
||||
(self.portal * 20.0 + self.nausea * 7.0) / (self.portal + self.nausea);
|
||||
self.spinning_time += self.spinning_speed;
|
||||
} else {
|
||||
self.spinning_speed = 0.0;
|
||||
}
|
||||
}
|
||||
pub fn build(&self, input: &ScreenInputs) -> Vec<ScreenQuad> {
|
||||
let mut out = Vec::new();
|
||||
let sprite = |name: &str| input.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `VibrationSelector`) shared by sculk sensors, shriekers and wardens, the
|
||||
//! sculk block entities and the per-player warden spawn tracker.
|
||||
pub use crate::chunk::block_entity::{Data, Info, SculkEntity, SculkKind, Selector};
|
||||
use crate::mobs::Who;
|
||||
use horizon_client_core::who::Who;
|
||||
use horizon_core::{
|
||||
geometry::{Direction, Vec3, traverse_blocks},
|
||||
position::BlockPos,
|
||||
@@ -315,7 +315,9 @@ fn info_to_nbt(info: &Info) -> Tag {
|
||||
])
|
||||
}
|
||||
fn info_from_nbt(tag: &Tag) -> Option<Info> {
|
||||
let event = intern(&crate::save::text(tag.get("game_event"))?)?;
|
||||
let event = intern(&horizon_client_core::nbt_value::text(
|
||||
tag.get("game_event"),
|
||||
)?)?;
|
||||
let distance = match tag.get("distance") {
|
||||
Some(Tag::Float(f)) => f32::from_bits(*f),
|
||||
_ => 0.0,
|
||||
|
||||
@@ -167,6 +167,8 @@ pub struct Settings {
|
||||
/// Every option in its stored form (the typed fields above mirror it).
|
||||
pub file: OptionsFile,
|
||||
pub invert_x: bool,
|
||||
/// Allow widgets to replace the default mouse cursor icon.
|
||||
pub allow_cursor_changes: bool,
|
||||
pub toggle_attack: bool,
|
||||
pub toggle_use: bool,
|
||||
/// Taps of the sprint key counted as double taps (sprintWindow).
|
||||
@@ -222,6 +224,7 @@ impl Default for Settings {
|
||||
invert_y: false,
|
||||
mouse_wheel_sensitivity: 1.0,
|
||||
discrete_scroll: false,
|
||||
allow_cursor_changes: true,
|
||||
toggle_crouch: false,
|
||||
toggle_sprint: false,
|
||||
auto_jump: false,
|
||||
@@ -408,6 +411,7 @@ impl Settings {
|
||||
invert_y: f.bool("invertYMouse"),
|
||||
mouse_wheel_sensitivity: f.double("mouseWheelSensitivity") as f32,
|
||||
discrete_scroll: f.bool("discrete_mouse_scroll"),
|
||||
allow_cursor_changes: f.bool("allowCursorChanges"),
|
||||
toggle_crouch: f.bool("toggleCrouch"),
|
||||
toggle_sprint: f.bool("toggleSprint"),
|
||||
toggle_attack: f.bool("toggleAttack"),
|
||||
@@ -497,6 +501,7 @@ impl Settings {
|
||||
self.mouse_wheel_sensitivity,
|
||||
);
|
||||
put_b(&mut f, "discrete_mouse_scroll", self.discrete_scroll);
|
||||
put_b(&mut f, "allowCursorChanges", self.allow_cursor_changes);
|
||||
put_b(&mut f, "toggleCrouch", self.toggle_crouch);
|
||||
put_b(&mut f, "toggleSprint", self.toggle_sprint);
|
||||
put_b(&mut f, "toggleAttack", self.toggle_attack);
|
||||
@@ -616,8 +621,10 @@ pub enum Opt {
|
||||
SimulationDistance,
|
||||
Sensitivity,
|
||||
WheelSensitivity,
|
||||
InvertY,
|
||||
DiscreteScroll,
|
||||
InvertX,
|
||||
InvertY,
|
||||
AllowCursorChanges,
|
||||
Volume(usize),
|
||||
GuiScale,
|
||||
ViewBobbing,
|
||||
|
||||
@@ -17,6 +17,8 @@ pub enum Signature {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Textures {
|
||||
pub skin: Option<String>,
|
||||
/// The skin's `metadata.model` is `slim` (`PlayerModelType.SLIM`).
|
||||
pub slim: bool,
|
||||
pub cape: Option<String>,
|
||||
pub elytra: Option<String>,
|
||||
pub signed: bool,
|
||||
@@ -101,6 +103,13 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture
|
||||
if !allowed {
|
||||
return empty;
|
||||
}
|
||||
if kind == "SKIN" {
|
||||
out.slim = texture
|
||||
.get("metadata")
|
||||
.and_then(|m| m.get("model"))
|
||||
.and_then(|m| m.as_str())
|
||||
== Some("slim");
|
||||
}
|
||||
let slot = match kind.as_str() {
|
||||
"SKIN" => &mut out.skin,
|
||||
"CAPE" => &mut out.cape,
|
||||
@@ -809,6 +818,21 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// `MinecraftProfileTexture.getMetadata("model")`: `slim` marks a slim
|
||||
/// skin; no metadata is the wide (classic) model.
|
||||
#[test]
|
||||
fn a_skins_model_comes_from_its_metadata() {
|
||||
use super::{Signature, TEXTURE_URIS, unpack};
|
||||
let slim = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2FiYyIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19";
|
||||
let wide = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2FiYyJ9fX0=";
|
||||
let t = unpack(slim, Signature::Signed, &TEXTURE_URIS);
|
||||
assert_eq!(
|
||||
t.skin.as_deref(),
|
||||
Some("https://textures.minecraft.net/texture/abc")
|
||||
);
|
||||
assert!(t.slim);
|
||||
assert!(!unpack(wide, Signature::Signed, &TEXTURE_URIS).slim);
|
||||
}
|
||||
#[test]
|
||||
fn profiles_parse_like_resolvable_profiles() {
|
||||
use super::{NameOrId, Signature, parse_profile};
|
||||
|
||||
@@ -48,7 +48,9 @@ impl VaultConfig {
|
||||
let Tag::Compound(_) = tag else {
|
||||
return None;
|
||||
};
|
||||
let range = |k: &str, default: f64| crate::save::float(tag.get(k)).unwrap_or(default);
|
||||
let range = |k: &str, default: f64| {
|
||||
horizon_client_core::nbt_value::float(tag.get(k)).unwrap_or(default)
|
||||
};
|
||||
let config = Self {
|
||||
loot_table: identifier(tag.get("loot_table"))
|
||||
.unwrap_or_else(|| DEFAULT_LOOT_TABLE.into()),
|
||||
@@ -81,7 +83,7 @@ impl VaultConfig {
|
||||
);
|
||||
}
|
||||
if let Some(key) = &self.key_item {
|
||||
out.insert("key_item".into(), crate::save::item_to_nbt(key));
|
||||
out.insert("key_item".into(), horizon_items::item_nbt::item_to_nbt(key));
|
||||
}
|
||||
if let Some(table) = &self.override_loot_table_to_display {
|
||||
out.insert(
|
||||
@@ -181,7 +183,7 @@ impl Default for Vault {
|
||||
/// An `Identifier` (`minecraft` when the namespace is left out).
|
||||
fn identifier(t: Option<&Tag>) -> Option<String> {
|
||||
match t? {
|
||||
Tag::String(s) => crate::mobs::spawner::normalize_id(&s.to_string_lossy()),
|
||||
Tag::String(s) => horizon_client_core::identifier::normalize_id(&s.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -189,11 +191,12 @@ fn identifier(t: Option<&Tag>) -> Option<String> {
|
||||
fn stack(tag: &Tag) -> Option<ItemStack> {
|
||||
let id = identifier(tag.get("id"))?;
|
||||
if let Some(count) = tag.get("count")
|
||||
&& !crate::save::number(Some(count)).is_some_and(|c| (1..=99).contains(&c))
|
||||
&& !horizon_client_core::nbt_value::number(Some(count))
|
||||
.is_some_and(|c| (1..=99).contains(&c))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut item = crate::save::item_from_nbt(tag)?;
|
||||
let mut item = horizon_items::item_nbt::item_from_nbt(tag)?;
|
||||
item.item = id.as_str().into();
|
||||
(&*item.item != "minecraft:air").then_some(item)
|
||||
}
|
||||
@@ -266,7 +269,8 @@ impl Vault {
|
||||
let get = |k: &str| server.and_then(|s| s.get(k));
|
||||
v.rewarded_players = uuids(get("rewarded_players")).unwrap_or_default();
|
||||
v.state_updating_resumes_at =
|
||||
crate::save::number(get("state_updating_resumes_at")).unwrap_or(0);
|
||||
horizon_client_core::nbt_value::number(get("state_updating_resumes_at"))
|
||||
.unwrap_or(0);
|
||||
v.items_to_eject = match get("items_to_eject") {
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
@@ -281,8 +285,9 @@ impl Vault {
|
||||
let get = |k: &str| shared.and_then(|s| s.get(k));
|
||||
v.display_item = get("display_item").and_then(stack);
|
||||
v.connected_players = uuids(get("connected_players")).unwrap_or_default();
|
||||
v.connected_particles_range = crate::save::float(get("connected_particles_range"))
|
||||
.unwrap_or(DEFAULT_DEACTIVATION_RANGE);
|
||||
v.connected_particles_range =
|
||||
horizon_client_core::nbt_value::float(get("connected_particles_range"))
|
||||
.unwrap_or(DEFAULT_DEACTIVATION_RANGE);
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -305,7 +310,7 @@ impl Vault {
|
||||
let items = self
|
||||
.items_to_eject
|
||||
.iter()
|
||||
.map(crate::save::item_to_nbt)
|
||||
.map(horizon_items::item_nbt::item_to_nbt)
|
||||
.collect();
|
||||
server.insert("items_to_eject".into(), Tag::List(items));
|
||||
}
|
||||
@@ -325,7 +330,10 @@ impl Vault {
|
||||
fn update_shared(&self) -> Tag {
|
||||
let mut out = Compound::new();
|
||||
if let Some(item) = &self.display_item {
|
||||
out.insert("display_item".into(), crate::save::item_to_nbt(item));
|
||||
out.insert(
|
||||
"display_item".into(),
|
||||
horizon_items::item_nbt::item_to_nbt(item),
|
||||
);
|
||||
}
|
||||
if !self.connected_players.is_empty() {
|
||||
out.insert(
|
||||
|
||||
@@ -43,6 +43,8 @@ pub struct Index {
|
||||
pub enum Kind {
|
||||
/// A `.hmod`.
|
||||
Mod,
|
||||
/// A source-only native mixin bundle, compiled into an isolated game build.
|
||||
NativeMod,
|
||||
/// A `.hpack`: registry mods played together as a profile.
|
||||
Modpack,
|
||||
/// A resource pack (`.zip` with `pack.mcmeta`) made or tested for Horizon.
|
||||
@@ -57,6 +59,7 @@ impl Kind {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Kind::Mod => "mod",
|
||||
Kind::NativeMod => "native-mod",
|
||||
Kind::Modpack => "modpack",
|
||||
Kind::ResourcePack => "resourcepack",
|
||||
Kind::Shader => "shader",
|
||||
@@ -68,6 +71,7 @@ impl Kind {
|
||||
pub fn extension(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Kind::Mod => Some("hmod"),
|
||||
Kind::NativeMod => Some("hnmod"),
|
||||
Kind::Modpack => Some("hpack"),
|
||||
Kind::ResourcePack => Some("zip"),
|
||||
Kind::Shader => Some(horizon_shader_api::EXTENSION),
|
||||
@@ -80,6 +84,7 @@ impl From<String> for Kind {
|
||||
fn from(name: String) -> Kind {
|
||||
match name.as_str() {
|
||||
"mod" => Kind::Mod,
|
||||
"native-mod" => Kind::NativeMod,
|
||||
"modpack" => Kind::Modpack,
|
||||
"resourcepack" => Kind::ResourcePack,
|
||||
"shader" => Kind::Shader,
|
||||
@@ -552,6 +557,7 @@ impl Facts {
|
||||
/// this build doesn't know are refused: their checks come with their formats.
|
||||
pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
|
||||
match kind {
|
||||
Kind::NativeMod => Ok(native::Bundle::read(path)?.manifest.facts()),
|
||||
Kind::Mod => {
|
||||
let Package { manifest: m, wasm } = Package::read(path)?;
|
||||
crate::Content::read(path, &m)?;
|
||||
@@ -656,7 +662,7 @@ fn check_resource_pack(path: &Path) -> Result<String, String> {
|
||||
/// formats.
|
||||
pub fn open(kind: &Kind) -> Result<(), String> {
|
||||
match kind {
|
||||
Kind::Mod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()),
|
||||
Kind::Mod | Kind::NativeMod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()),
|
||||
Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")),
|
||||
}
|
||||
}
|
||||
@@ -666,6 +672,7 @@ fn names(caps: &[Capability]) -> String {
|
||||
format!("[{}]", names.join(", "))
|
||||
}
|
||||
|
||||
pub mod native;
|
||||
pub mod registry;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Source-only native mixin bundles. Reading a bundle never executes its code.
|
||||
use super::{Facts, valid_id};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, io::Read, path::Path};
|
||||
|
||||
pub const MANIFEST: &str = "horizon-native.json";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
pub schema: u32,
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub game: String,
|
||||
/// Exact source revisions tested together, never mutable tags.
|
||||
pub horizon: String,
|
||||
pub mixins: String,
|
||||
}
|
||||
|
||||
pub fn revision(value: &str) -> bool {
|
||||
value.len() == 40
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if self.schema != 1
|
||||
|| !valid_id(&self.id)
|
||||
|| self.version.is_empty()
|
||||
|| self.game.is_empty()
|
||||
|| !revision(&self.horizon)
|
||||
|| !revision(&self.mixins)
|
||||
{
|
||||
return Err(
|
||||
"invalid native manifest: schema 1, id, version, game and exact revisions required"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Carries the exact native compatibility contract in the index's API field.
|
||||
pub fn api(&self) -> String {
|
||||
format!("native-1/{}/{}", self.horizon, self.mixins)
|
||||
}
|
||||
|
||||
pub fn facts(&self) -> Facts {
|
||||
Facts {
|
||||
id: Some(self.id.clone()),
|
||||
version: Some(self.version.clone()),
|
||||
api: self.api(),
|
||||
game: Some(self.game.clone()),
|
||||
..Facts::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Bundle {
|
||||
pub manifest: Manifest,
|
||||
/// Validated relative paths; only TOML and Rust beneath `mixins/<id>/`.
|
||||
pub files: BTreeMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Bundle {
|
||||
pub fn read(path: &Path) -> Result<Self, String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
if zip.len() > 512 {
|
||||
return Err("native bundle has too many entries".into());
|
||||
}
|
||||
let mut files = BTreeMap::new();
|
||||
let mut total = 0u64;
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
let name = entry.name().to_owned();
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if entry
|
||||
.unix_mode()
|
||||
.is_some_and(|m| m & 0o170000 != 0 && m & 0o170000 != 0o100000)
|
||||
|| name.contains('\\')
|
||||
|| name
|
||||
.split('/')
|
||||
.any(|p| p.is_empty() || p == "." || p == "..")
|
||||
|| name.starts_with('/')
|
||||
|| name.contains(':')
|
||||
{
|
||||
return Err(format!("invalid native bundle path: {name}"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
(&mut entry)
|
||||
.take(super::MAX_FILE + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
total += bytes.len() as u64;
|
||||
if total > super::MAX_FILE {
|
||||
return Err("native bundle expands beyond 64 MiB".into());
|
||||
}
|
||||
if files.insert(name, bytes).is_some() {
|
||||
return Err("duplicate native bundle path".into());
|
||||
}
|
||||
}
|
||||
let manifest: Manifest =
|
||||
serde_json::from_slice(files.get(MANIFEST).ok_or("missing horizon-native.json")?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
manifest.check()?;
|
||||
let prefix = format!("mixins/{}/", manifest.id);
|
||||
for name in files.keys().filter(|n| n.as_str() != MANIFEST) {
|
||||
if !name.starts_with(&prefix) || !(name.ends_with(".rs") || name.ends_with(".toml")) {
|
||||
return Err(format!("unexpected native source: {name}"));
|
||||
}
|
||||
}
|
||||
let source = files
|
||||
.get(&format!("{prefix}horizon-mixin.toml"))
|
||||
.ok_or("missing horizon-mixin.toml")?;
|
||||
let source: toml::Value =
|
||||
toml::from_str(std::str::from_utf8(source).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if source
|
||||
.get("mod")
|
||||
.and_then(|v| v.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
!= Some(&manifest.id)
|
||||
|| source
|
||||
.get("mod")
|
||||
.and_then(|v| v.get("version"))
|
||||
.and_then(|v| v.as_str())
|
||||
!= Some(&manifest.version)
|
||||
|| source
|
||||
.get("compatibility")
|
||||
.and_then(|v| v.get("horizon_revision"))
|
||||
.and_then(|v| v.as_str())
|
||||
!= Some(&manifest.horizon)
|
||||
{
|
||||
return Err("native manifest and mixin identity/revision differ".into());
|
||||
}
|
||||
Ok(Self { manifest, files })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn manifest() -> Manifest {
|
||||
Manifest {
|
||||
schema: 1,
|
||||
id: "veinminer".into(),
|
||||
version: "0.3.0".into(),
|
||||
game: "26.3".into(),
|
||||
horizon: "a".repeat(40),
|
||||
mixins: "b".repeat(40),
|
||||
}
|
||||
}
|
||||
fn read(name: &str, extras: &[(&str, &[u8])], mixin: &str) -> Result<Bundle, String> {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("native-bundle-{name}-{}.hnmod", std::process::id()));
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file(MANIFEST, options).unwrap();
|
||||
zip.write_all(&serde_json::to_vec(&manifest()).unwrap())
|
||||
.unwrap();
|
||||
zip.start_file("mixins/veinminer/horizon-mixin.toml", options)
|
||||
.unwrap();
|
||||
zip.write_all(mixin.as_bytes()).unwrap();
|
||||
for (name, bytes) in extras {
|
||||
zip.start_file(*name, options).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let result = Bundle::read(&path);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
result
|
||||
}
|
||||
fn mixin() -> String {
|
||||
format!(
|
||||
"[mod]\nid = \"veinminer\"\nversion = \"0.3.0\"\n[compatibility]\nhorizon_revision = \"{}\"\n",
|
||||
"a".repeat(40)
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn source_bundle_carries_exact_compatibility_into_index_facts() {
|
||||
let bundle = read(
|
||||
"valid",
|
||||
&[("mixins/veinminer/hook.rs", b"fn hook() {}")],
|
||||
&mixin(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bundle.manifest, manifest());
|
||||
let facts = bundle.manifest.facts();
|
||||
assert_eq!(
|
||||
facts.api,
|
||||
format!("native-1/{}/{}", "a".repeat(40), "b".repeat(40))
|
||||
);
|
||||
assert_eq!(facts.id.as_deref(), Some("veinminer"));
|
||||
assert!(facts.capabilities.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn rejects_path_traversal_and_non_source_payloads() {
|
||||
for (i, name) in [
|
||||
"../escape.rs",
|
||||
"/absolute.rs",
|
||||
"mixins/other/hook.rs",
|
||||
"mixins/veinminer/code.dll",
|
||||
"mixins\\veinminer\\hook.rs",
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
assert!(read(&format!("bad-path-{i}"), &[(name, b"bad")], &mixin()).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rejects_missing_or_mismatched_mixin_identity_without_panicking() {
|
||||
assert!(read("missing-mod", &[], "[unrelated]\nvalue = 1").is_err());
|
||||
assert!(
|
||||
read(
|
||||
"wrong-revision",
|
||||
&[],
|
||||
&mixin().replace(&"a".repeat(40), &"c".repeat(40))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(read("wrong-version", &[], &mixin().replace("0.3.0", "0.2.0")).is_err());
|
||||
assert!(
|
||||
Manifest {
|
||||
horizon: "nightly".into(),
|
||||
..manifest()
|
||||
}
|
||||
.check()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -632,9 +632,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -950,14 +950,14 @@ version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
@@ -265,7 +265,7 @@ impl App {
|
||||
/// The address requests are counted under, for the rate limits only: kept in memory, never
|
||||
/// stored or logged. Behind a proxy (Railway) the TCP peer is the proxy, so it is the proxy's
|
||||
/// `X-Real-IP`, else the last `X-Forwarded-For` entry: the one the proxy added, which a client
|
||||
/// can't choose (the first one it can).
|
||||
/// can't choose (the first one it can). An IPv6 client counts by its /64.
|
||||
fn client(&self, headers: &HeaderMap, peer: Option<SocketAddr>) -> IpAddr {
|
||||
let header = |name: &str| headers.get(name)?.to_str().ok();
|
||||
let forwarded = self
|
||||
@@ -284,9 +284,11 @@ impl App {
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
forwarded
|
||||
.or(peer.map(|p| p.ip()))
|
||||
.unwrap_or(IpAddr::from([0, 0, 0, 0]))
|
||||
rate_key(
|
||||
forwarded
|
||||
.or(peer.map(|p| p.ip()))
|
||||
.unwrap_or(IpAddr::from([0, 0, 0, 0])),
|
||||
)
|
||||
}
|
||||
|
||||
/// The day an install's newest row came in, if it has any left.
|
||||
@@ -339,6 +341,22 @@ fn take<K: Hash + Eq>(buckets: &Mutex<HashMap<K, (f64, Instant)>>, per_hour: f64
|
||||
true
|
||||
}
|
||||
|
||||
/// The address a client's requests count under: IPv4 (an IPv4-mapped IPv6 address as IPv4 too),
|
||||
/// or an IPv6 address's /64, which one home or host holds whole, so addresses rotated inside it
|
||||
/// don't get fresh buckets.
|
||||
fn rate_key(ip: IpAddr) -> IpAddr {
|
||||
match ip {
|
||||
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
|
||||
Some(v4) => IpAddr::V4(v4),
|
||||
None => {
|
||||
let s = v6.segments();
|
||||
IpAddr::V6(std::net::Ipv6Addr::new(s[0], s[1], s[2], s[3], 0, 0, 0, 0))
|
||||
}
|
||||
},
|
||||
v4 => v4,
|
||||
}
|
||||
}
|
||||
|
||||
fn peer(req: &Request) -> Option<SocketAddr> {
|
||||
req.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
@@ -580,3 +598,23 @@ pub fn router(app: Arc<App>) -> Router {
|
||||
.route("/healthz", get(|| async { "ok" }))
|
||||
.with_state(app)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ipv6_rates_count_by_64() {
|
||||
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
|
||||
// Rotated inside a /64: one key. Another /64: another key.
|
||||
assert_eq!(rate_key(ip("2001:db8::1")), rate_key(ip("2001:db8::99")));
|
||||
assert_ne!(rate_key(ip("2001:db8::1")), rate_key(ip("2001:db8:1::1")));
|
||||
// IPv4-mapped counts as the IPv4 it is.
|
||||
assert_eq!(
|
||||
rate_key(ip("::ffff:203.0.113.7")),
|
||||
IpAddr::from([203, 0, 113, 7])
|
||||
);
|
||||
// Plain IPv4 unchanged.
|
||||
assert_eq!(rate_key(ip("203.0.113.8")), IpAddr::from([203, 0, 113, 8]));
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,21 @@ async fn an_address_is_rate_limited() {
|
||||
s.send(from("203.0.113.9, 198.51.100.7", None)).await.0,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
// Two IPv6 addresses of one /64 share a bucket; another /64 doesn't.
|
||||
for forwarded in ["2001:db8::1", "2001:db8::2", "2001:db8::3"] {
|
||||
assert_eq!(
|
||||
s.send(from(forwarded, None)).await.0,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
s.send(from("2001:db8::ffff:ffff", None)).await.0,
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
assert_eq!(
|
||||
s.send(from("2001:db8:1::1", None)).await.0,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -25,7 +25,7 @@ Select/Start). No second windowing or event stack: gilrs is polled once per
|
||||
loop iteration next to winit's events.
|
||||
|
||||
- The pure logic (mapping table, deadzones and curves, button edges, the
|
||||
settings file, glyph choice) lives in `crates/horizon-client/src/gamepad.rs`
|
||||
settings file, glyph choice) lives in `crates/horizon-client-input/src/gamepad.rs`
|
||||
and is unit-tested. The game owns a thin gilrs backend and applies actions.
|
||||
- Controller actions go through the same path a keyboard key takes after
|
||||
rebinding: an action names a vanilla key mapping (`key.jump`, `key.attack`,
|
||||
|
||||
@@ -158,7 +158,9 @@ of the main action. The game's menus keep `LIGHT` and `DARK`.
|
||||
|
||||
- **Pixel type for what names and what you act on.** Headings, navigation, buttons and the
|
||||
wordmark use Pixelify Sans (OFL; Medium, SemiBold, Bold); paragraphs and numbers stay in Geist
|
||||
and Geist Mono so long text reads easily.
|
||||
and Geist Mono so long text reads easily. The bundled copies add the two uppercase Cyrillic
|
||||
letters upstream leaves out, О and П: the Latin O's glyph and the lowercase п lifted to that
|
||||
weight's cap height, so a Russian heading stays in the pixel face.
|
||||
- **Blocks, not rounded cards.** Every surface is square: a 2 px dark outline, a 2 px light bevel
|
||||
along the top and a darker lip along the bottom. The main action is the violet face with its own
|
||||
bevel and a 4 px lip; it sinks a pixel when pressed. Code and fields are sunk wells.
|
||||
|
||||
@@ -71,6 +71,18 @@ Run a dedicated server here; type commands into its console.
|
||||
|
||||
MCP: `server`, `server_command`, `server_log`, `server_stop`.
|
||||
|
||||
## `horizon-launcher native-build`
|
||||
|
||||
Native mods (experimental, docs/native-mods.md): compile mixin bundles on the Horizon revision they pin, with the launcher's own Rust, as the version the "Native mods" instance plays. Not sandboxed: the mods' code runs with your rights.
|
||||
|
||||
horizon-launcher native-build [FOLDER]
|
||||
|
||||
- `FOLDER`: A mod folder (horizon-mixin.toml, or an unpacked .hnmod), or a folder of them (default: the native mods installed and turned on in the instance)
|
||||
- `--instance`: The instance whose installed native mods to build, and which then plays the build (default: the selected one; the game's own home builds into the Native mods instance)
|
||||
- `--trust-native-code`: You read the mods' source and trust it with full access to this computer
|
||||
|
||||
MCP: none: the player's switch.
|
||||
|
||||
## `horizon-launcher notes`
|
||||
|
||||
Show a release's patch notes (default: the newest).
|
||||
@@ -137,7 +149,7 @@ MCP: `account_offline`.
|
||||
|
||||
## `horizon-launcher account forget-offline`
|
||||
|
||||
Forget one of your offline names.
|
||||
Forget one of your offline names; its skin and cape go with it.
|
||||
|
||||
horizon-launcher account forget-offline <NAME>
|
||||
|
||||
@@ -763,6 +775,30 @@ Turn off a mod the browser installed; it stays installed.
|
||||
|
||||
MCP: `mods_disable`.
|
||||
|
||||
## `horizon-launcher rust status`
|
||||
|
||||
Whether it is installed, its version and whether this computer can link with it.
|
||||
|
||||
horizon-launcher rust status
|
||||
|
||||
MCP: `rust_status`.
|
||||
|
||||
## `horizon-launcher rust install`
|
||||
|
||||
Download the pinned toolchain from static.rust-lang.org (once), checked by SHA-256.
|
||||
|
||||
horizon-launcher rust install
|
||||
|
||||
MCP: `rust_install`.
|
||||
|
||||
## `horizon-launcher rust remove`
|
||||
|
||||
Delete the toolchain and the native builds' Cargo home.
|
||||
|
||||
horizon-launcher rust remove
|
||||
|
||||
MCP: `rust_remove`.
|
||||
|
||||
## `horizon-launcher servers list`
|
||||
|
||||
The servers, their folder and whether they run.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Native mods (experimental)
|
||||
|
||||
Native mods are **mixin bundles** that change the game's own Rust source before it is compiled, with
|
||||
[horizon-mixins](https://github.com/horizon-rust/horizon-mixins) (MIT, by levzzz5154, made for Horizon). A bundle
|
||||
is a `horizon-mixin.toml` and a few `.rs` helpers that hook into named functions of the game (HEAD, BEFORE_CALL,
|
||||
RETURN, MODIFY_ARG, OVERWRITE...; the tool's README lists them). A port such as Veinminer is three HEAD hooks and
|
||||
one module. Start a mod from [horizon-native-mod-template](https://github.com/horizon-rust/horizon-native-mod-template).
|
||||
|
||||
Native mods are native code with the player's rights, not sandboxed. The launcher offers them off by default, behind
|
||||
a warning, reviewed before the registry lists one, and in a game of their own, built for the instance they are installed in.
|
||||
|
||||
## Pinned to a Nightly
|
||||
|
||||
A native mod builds on one exact Horizon commit: `[compatibility] horizon_revision` in its `horizon-mixin.toml`,
|
||||
and `horizon` in the `horizon-native.json` of its registry file (`.hnmod`, `horizon_mods::index::native`). Mods
|
||||
pin a **Nightly's commit** (or a release's). Every Nightly ships its source and keeps its commit for good as the
|
||||
tag `nightly-source/<date>-<short>` ([release-flow.md](release-flow.md#nightly-channel)), so a pinned mod still
|
||||
builds after its Nightly is replaced. All the mods of one build must pin the same commit; when they don't, the
|
||||
launcher says which mod pins which, and the player installs versions made for the same Nightly. A bundle with no
|
||||
pin builds on the commit of the installed version the build starts from.
|
||||
|
||||
## What the launcher does
|
||||
|
||||
In the window: install a native mod from the Mods page, turn on Settings → Game → **Native mods (experimental)**,
|
||||
then **Build**. The launcher names the mods, warns in red, and builds only from "I trust these mods: build"; the
|
||||
row shows each step, the error if one fails, and the build the instance plays. From the command line:
|
||||
`horizon-launcher native-build [FOLDER] [--instance NAME] --trust-native-code`. FOLDER is a bundle, an unpacked
|
||||
`.hnmod`, or a folder of them; without it, the native mods turned on in the instance. It is not an MCP tool:
|
||||
trusting native code is the player's decision, never an agent's.
|
||||
|
||||
1. **Rust.** The launcher's own toolchain ([`rust_toolchain`](../launcher/src/rust_toolchain.rs)): Rust 1.99.1's
|
||||
official `rustc`, `cargo` and `rust-std` (and `rust-mingw` on Windows), each pinned by SHA-256, downloaded once
|
||||
from static.rust-lang.org into `<launcher data>/rust/`, with its own Cargo home `<launcher data>/cargo/`. No
|
||||
rustup, nothing on the system; the player's own Rust is never used. Windows uses the `windows-gnu` host, which
|
||||
links without Visual Studio. The linker is the system's on macOS (the Command Line Tools: the launcher asks for
|
||||
`xcode-select --install`) and Linux (`cc`). `horizon-launcher rust status|install|remove`.
|
||||
2. **horizon-mixins**, at the commit the mods name (their `.hnmod`'s `mixins`), else `native_mods::MIXINS_REV`,
|
||||
built once with that cargo (`cargo install --locked --git … --rev …`) into `native/tools/<commit>/`.
|
||||
3. **The source** of the pinned commit: GitHub's archive of it, once per commit (`native/source/<commit>/`, about
|
||||
63 MB unpacked), with the commit in `.horizon-revision`. Never the player's own checkout. Links in the
|
||||
archive are left out: the tool refuses them and nothing needs them to build.
|
||||
4. **Mixins.** The bundles are copied first, so the build and its key use that copy whatever happens to the
|
||||
folder meanwhile. `horizon-mixins apply` stages the modded source; the launcher copies only the files that
|
||||
changed into `native/stage/`, so Cargo, which goes by file times, recompiles only the crates the mods touch.
|
||||
5. **Compile**: `cargo build --locked --profile release --features horizon-client/mods -p horizon-client`, the
|
||||
release's own command (game-build.yml), into `native/target/`. Cargo downloads the locked crates from
|
||||
crates.io (Horizon has no git dependencies). The log is `native/build.log`; a failure shows its last lines (a
|
||||
horizon-mixins diagnostic in one line).
|
||||
6. **Install**: a new version, `native-<commit>-<key>`, a copy of the starting version's folder (resources
|
||||
included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and a `native-mods.json`
|
||||
saying which commit, mods, horizon-mixins and Rust made it. The instance the mods are installed in plays it (a
|
||||
folder built from the command line, or mods of the default home, go to the **Native mods** instance).
|
||||
|
||||
## Cache and disk
|
||||
|
||||
The key hashes the commit, the horizon-mixins commit, Rust's version, the profile and features, and every file of
|
||||
every bundle. The same set on the same commit is never built twice. A change gives a new build, and
|
||||
`native/target/` stays, so a rebuild compiles only `horizon-client` and what the mods touched.
|
||||
|
||||
Measured 2026-10-28 on the Mac mini (M4 Pro, 14 cores) with Veinminer 0.3.0 on Nightly `83f15fa`,
|
||||
`CARGO_BUILD_JOBS=8` while other agents' builds ran (upper bounds):
|
||||
|
||||
| What | Size, time |
|
||||
|---|---|
|
||||
| Rust 1.99.1 (`<launcher data>/rust/`) | 453 MB; 58 s to download and unpack |
|
||||
| horizon-mixins (`native/tools/`) | 7 MB; built once in 19 s |
|
||||
| Crates in the launcher's Cargo home (`<launcher data>/cargo/`) | 289 MB |
|
||||
| Source of one commit, and the stage | 64 MB each |
|
||||
| First build: release profile, the whole game | 9 min 10 s of compiling, 9 min 41 s in all; `native/target/` 1.5 GB |
|
||||
| Rebuild after a helper's code changed | 2 min 46 s of compiling (only `horizon-client`), 2 min 53 s in all |
|
||||
| A change horizon-mixins ignores (a comment) | nothing compiled; 31 s, mostly the version folder's copy |
|
||||
|
||||
Only the native builds an instance plays are kept (`native_mods::prune`); `native/target/` stays for the next one.
|
||||
|
||||
## Trust
|
||||
|
||||
- **Off by default.** The command needs `--trust-native-code`; the window shows a warning in red before every
|
||||
build: the mods run with full access to the computer, and their ids and versions. The Mods page says who made
|
||||
each one, and "Not made or endorsed by the Horizon project" for any mod not Horizon's own.
|
||||
- **The registry** (horizon-rust/horizon-mods) lists them as their own kind, `native-mod`, only after a maintainer
|
||||
has read the source: the `.hnmod` *is* the source (only `.rs` and `.toml`, checked without running anything),
|
||||
pinned by SHA-256 like every registry file. An update is reviewed again; `revoked.toml` takes one down.
|
||||
- **Isolation of data.** Native mods run only in the instance built for them, with its own worlds, options and logs.
|
||||
- What this does not do: sandbox the compiled game. horizon-mixins' checks stop mistakes, not a malicious helper
|
||||
([security notes](https://github.com/horizon-rust/horizon-mixins/blob/main/docs/security.md)).
|
||||
|
||||
## Multiplayer
|
||||
|
||||
- **Single player and LAN host:** the mods run in the host's game and its integrated server, so a server-side
|
||||
hook applies to every player of the world.
|
||||
- **Joining a server:** only client-side hooks do anything; the protocol is unchanged.
|
||||
- **Dedicated servers:** not built yet; the same pipeline can build the server (`--no-default-features`).
|
||||
|
||||
## Left to do
|
||||
|
||||
- Rebuild after the mods or the Nightly change (asking first, as it compiles for minutes).
|
||||
- A prebuilt horizon-mixins from its CI instead of building it on the player's computer.
|
||||
- Windows and Linux: the toolchains are pinned, but a full game build on them is unproven.
|
||||
@@ -40,7 +40,7 @@ start it again and the chunks already saved load instead of generating.
|
||||
(`DecorationOrder`), and this makes a task decorate in the same order however its jobs are timed and
|
||||
shared among the workers: the same seed gives the same chunks.
|
||||
- Between server ticks the server waits for the workers' results and hands out the next jobs at once
|
||||
(`Streaming::wait`, `pump_pregen`), instead of once a tick: a chunk passes through five jobs
|
||||
(`Streaming::wait`, `pump_chunks`), instead of once a tick: a chunk passes through five jobs
|
||||
(generate, decorate, finish, light, save), each needing its neighbours' earlier ones.
|
||||
|
||||
The chunks of the band's outer ring are finished (FULL) but not lit, as at the edge of what a player has
|
||||
|
||||
+43
-8
@@ -119,10 +119,39 @@ ones only there.
|
||||
|
||||
## Merging
|
||||
|
||||
Pull requests merge with a merge commit. The orchestrator sessions review a pull request, then turn on
|
||||
auto-merge (`gh pr merge <n> --auto --merge`): GitHub merges it once the required checks pass. Branch
|
||||
protection on main requires `verify`. Branches don't have to be up to date with main: two sessions merge in
|
||||
parallel, and main's own `checks` run catches the rare semantic conflict.
|
||||
Pull requests merge with a merge commit, by hand: never `gh pr merge --auto`. Branch protection on main
|
||||
requires `verify` for everyone, and Ludovic's review for the files `.github/CODEOWNERS` lists (what builds, signs
|
||||
and ships releases). Branches don't have to be up to date with main: main's own `checks` run catches the rare
|
||||
semantic conflict.
|
||||
|
||||
**Who merges.** The Horizon Team (GitHub team `horizon-team`: kacpherek, levzzz5154, rdbtCVS, Online5277) and
|
||||
Ludovic. The orchestrator's agents prove only the risky pull requests (below), so a contributor's ordinary pull
|
||||
request doesn't wait for them.
|
||||
|
||||
**An ordinary pull request merges on CI.** A Team member:
|
||||
|
||||
1. reads the whole diff (for a fork, before adding `run-parity`: that runs the fork's code on a maintainer's machine);
|
||||
2. adds `run-parity` to a fork's pull request; our own branches run `parity` by themselves. Since #2448 it tests
|
||||
the pull request merged into main;
|
||||
3. merges once `verify` and `parity` pass, nobody's change request is open, and every comment has been read (a
|
||||
"don't merge" from anyone holds it).
|
||||
|
||||
Never merge your own pull request: another Team member reviews and merges it. Never force-push or rebase a
|
||||
contributor's branch; push a fixup commit, or merge main into it.
|
||||
|
||||
**A risky pull request gets the `needs-proof` label** and waits for an agent's local proof (both single-player
|
||||
paths, `lan_e2e`, a screenshot for anything visible) before it merges. Risky means any of:
|
||||
|
||||
- it changes what is saved or loaded (`save.rs`, `entity_nbt.rs`, `block_entity_nbt.rs`, `region.rs`, `datafix/`):
|
||||
a bug there damages players' worlds;
|
||||
- the network or the server thread: `horizon-protocol`, `net.rs`, `remote*.rs`, `lan/`, `lan*.rs`,
|
||||
`integrated.rs`, `levels.rs`, `dedicated*`;
|
||||
- world generation (`worldgen/`): seeds must keep generating the same terrain;
|
||||
- the launcher's installs and updates, or the mod runtime;
|
||||
- the harnesses themselves (`tools/test_*_parity.py`, the Java oracles): a wrong harness hides regressions;
|
||||
- over ~500 lines, or `parity` failed, was skipped or ran no harness for gameplay code.
|
||||
|
||||
Unsure? Add `needs-proof`: it costs a few hours, a broken release costs more.
|
||||
|
||||
`parity` becomes required once it has run green on pull requests for a while (a week without a false failure):
|
||||
|
||||
@@ -131,7 +160,7 @@ gh api -X POST repos/horizon-rust/horizon/branches/main/protection/required_stat
|
||||
```
|
||||
|
||||
A skipped `parity` (a fork's pull request without the label) counts as passed, so requiring it never blocks a
|
||||
fork's pull request; the maintainer decides whether to run it.
|
||||
fork's pull request: the Team member decides whether to run it, and a skipped run makes the pull request risky.
|
||||
|
||||
## Parity on main
|
||||
|
||||
@@ -420,10 +449,16 @@ than main's head and holds something main lacks, otherwise main (`tools/ci/night
|
||||
new publishes nothing. A Nightly can include pull requests not reviewed yet: that is what it is for, playing them
|
||||
before the gate.
|
||||
|
||||
Every Nightly ships its exact source. Its notes name the commit; `horizon-source.tar.gz` on the release is that
|
||||
commit's `git archive`, signed with the builds in `SHA256SUMS`; and `nightly.py keep-source` tags the commit
|
||||
`nightly-source/<date>-<short>`. That tag is never pruned, unlike the `nightly-<date>` release and the
|
||||
`nightly/build-*` branch (7 days each), because native mods pin a Nightly's commit and the launcher downloads that
|
||||
commit's source to build them ([native-mods.md](native-mods.md)).
|
||||
|
||||
### Trusted reviewers
|
||||
|
||||
One list, `.github/trusted-reviewers.txt`, governs two things for the people on it (kacpherek, rdbtCVS and
|
||||
Online5277 since 2026-10-27) and for Ludovic, with no repository role: `/nightly` (below) and triage by comment.
|
||||
Online5277 since 2026-10-27, levzzz5154 since 2026-10-28) and for Ludovic, with no repository role: `/nightly` (below) and triage by comment.
|
||||
|
||||
```text
|
||||
/close closes an issue as completed; a pull request is closed without merging
|
||||
@@ -449,7 +484,7 @@ Online5277 since 2026-10-27) and for Ludovic, with no repository role: `/nightly
|
||||
### Trusted reviewers pick the Nightly's pull requests
|
||||
|
||||
The Nightly is where contributors test pull requests before the gate. The trusted reviewers listed in
|
||||
`.github/trusted-reviewers.txt` (kacpherek, rdbtCVS and Online5277 since 2026-10-27) and Ludovic pick them with a
|
||||
`.github/trusted-reviewers.txt` (kacpherek, rdbtCVS and Online5277 since 2026-10-27, levzzz5154 since 2026-10-28) and Ludovic pick them with a
|
||||
comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `tools/ci/nightly_set.py`):
|
||||
|
||||
```text
|
||||
@@ -478,7 +513,7 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
|
||||
`nightly-<date>` release points to, or any other branch (`nightly.py prune-builds`).
|
||||
- **Refused.** A pull request that changes a path CODEOWNERS gives to Ludovic (`.github/`, `tools/ci/`, the
|
||||
signing and update code, `launcher/src/backend.rs`, `launcher/links.json`…), more than 30 in a set, and more than
|
||||
6 builds a day (the scheduled one always runs).
|
||||
12 builds a day (the scheduled one always runs; 6 until 2026-10-28).
|
||||
- **No repository role.** Reviewers need no permission on the repository: the list file and the workflow are the
|
||||
whole of it.
|
||||
- **Ludovic stays in charge.**
|
||||
|
||||
Generated
+5
-5
@@ -5062,9 +5062,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -6871,14 +6871,14 @@ version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
+4
-3
@@ -136,8 +136,9 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
made up (`horizon-player.txt`). Picking one plays as it from the next Play. Offline names play
|
||||
only while an account that owns the game is signed in ([Microsoft accounts](#microsoft-accounts));
|
||||
signing the last one out keeps them, unplayable until one is back. Settings > Account
|
||||
adds offline names (3 to 16 letters, digits or `_`; the game gets `--name`) and forgets them;
|
||||
the Console's Share log takes them out of the log too. `horizon-launcher account offline
|
||||
adds offline names (3 to 16 letters, digits or `_`; the game gets `--name`) and forgets them,
|
||||
skin and cape files included, and rolls the game's made-up name for a new one; the Console's
|
||||
Share log takes the names out of the log too. `horizon-launcher account offline
|
||||
[NAME]`, `account select <name>` (an account or an offline name), `account forget-offline
|
||||
<name>`.
|
||||
- **Accent and Home background**: on top of Dark, Light or System, Settings > Launcher picks an
|
||||
@@ -197,7 +198,7 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
keeping every field the launcher doesn't show and the servers joined with Direct Connection. The
|
||||
same from the command line: `horizon-launcher multiplayer [list|add|remove] [--instance <name>]`
|
||||
and `horizon-launcher play --server <address or name>`.
|
||||
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors. Quiet "Discord" and "Website" links sit in the footer.
|
||||
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors. Quiet "Sponsor", "Discord" and "Website" links sit in the sidebar footer.
|
||||
|
||||
Data lives in the platform's data directory (`~/.local/share/horizon-launcher` on Linux,
|
||||
`~/Library/Application Support/horizon-launcher` on macOS, `%APPDATA%\horizon-launcher` on
|
||||
|
||||
+11
-1
@@ -120,6 +120,7 @@
|
||||
"INSTANCES": "INSTANCES",
|
||||
"SERVERS": "SERVEURS",
|
||||
"HIDE": "MASQUER",
|
||||
"Sponsor": "Soutenir",
|
||||
"Website": "Site web",
|
||||
"Ready": "Prêt",
|
||||
"Needs preparing": "À préparer",
|
||||
@@ -176,5 +177,14 @@
|
||||
"Download and prepare": "Télécharger et préparer",
|
||||
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Pas préparé. Horizon peut télécharger Minecraft {version} depuis Mojang pour votre compte, ou utiliser votre propre installation.",
|
||||
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Pas préparé. Connectez-vous d'abord avec le compte Microsoft qui possède Minecraft {version}.",
|
||||
"Cancel": "Annuler"
|
||||
"Cancel": "Annuler",
|
||||
"Native mods (experimental)": "Mods natifs (expérimental)",
|
||||
"Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Les mods natifs de la page Mods sont compilés dans le jeu avec le Rust du launcher. Sans bac à sable : ils peuvent faire tout ce que vous pouvez faire sur cet ordinateur.",
|
||||
"{version} plays in this instance.": "Cette instance joue {version}.",
|
||||
"Install Horizon first.": "Installez d'abord Horizon.",
|
||||
"Starting the build": "Début de la compilation",
|
||||
"These mods will run with full access to your computer": "Ces mods auront un accès complet à votre ordinateur",
|
||||
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Ils peuvent lire, modifier ou supprimer vos fichiers, et utiliser vos comptes et votre réseau, comme tout programme que vous lancez. Ne les compilez que si vous faites confiance à leurs auteurs : la page Mods les nomme.",
|
||||
"Mods: {mods}": "Mods : {mods}",
|
||||
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "La compilation construit le jeu : une dizaine de minutes et quelques Go de disque la première fois, quelques minutes ensuite."
|
||||
}
|
||||
+11
-1
@@ -120,6 +120,7 @@
|
||||
"INSTANCES": "СБОРКИ",
|
||||
"SERVERS": "СЕРВЕРЫ",
|
||||
"HIDE": "СКРЫТЬ",
|
||||
"Sponsor": "Поддержать",
|
||||
"Website": "Сайт",
|
||||
"Ready": "Готово",
|
||||
"Needs preparing": "Нужна подготовка",
|
||||
@@ -176,5 +177,14 @@
|
||||
"Download and prepare": "Скачать и подготовить",
|
||||
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Не подготовлено. Horizon может скачать Minecraft {version} у Mojang для вашего аккаунта или взять вашу установку.",
|
||||
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Не подготовлено. Сначала войдите в аккаунт Microsoft, у которого есть Minecraft {version}.",
|
||||
"Cancel": "Отмена"
|
||||
"Cancel": "Отмена",
|
||||
"Native mods (experimental)": "Нативные моды (экспериментально)",
|
||||
"Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Нативные моды со страницы «Моды» компилируются в игру собственным Rust лаунчера. Без песочницы: они могут делать на этом компьютере всё, что можете вы.",
|
||||
"{version} plays in this instance.": "В этой сборке запускается {version}.",
|
||||
"Install Horizon first.": "Сначала установите Horizon.",
|
||||
"Starting the build": "Начало сборки",
|
||||
"These mods will run with full access to your computer": "Эти моды получат полный доступ к вашему компьютеру",
|
||||
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Они могут читать, изменять и удалять ваши файлы, использовать ваши аккаунты и сеть, как любая запущенная вами программа. Собирайте их, только если доверяете авторам: они указаны на странице «Моды».",
|
||||
"Mods: {mods}": "Моды: {mods}",
|
||||
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "Сборка компилирует игру: около десяти минут и несколько ГБ на диске в первый раз, потом несколько минут."
|
||||
}
|
||||
@@ -907,14 +907,16 @@ impl Accounts {
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// Forgets an offline name; if it was playing, the game's own name plays.
|
||||
pub fn remove_offline(&mut self, name: &str) -> Result<(), String> {
|
||||
/// Forgets an offline name: its look and its skin and cape files (`skins`) go with it; if
|
||||
/// it was playing, the game's own name plays.
|
||||
pub fn remove_offline(&mut self, name: &str, skins_dir: &Path) -> Result<(), String> {
|
||||
let before = self.offline.len();
|
||||
self.offline.retain(|n| !n.eq_ignore_ascii_case(name));
|
||||
self.looks.remove(&name.to_ascii_lowercase());
|
||||
if self.offline.len() == before {
|
||||
return Err(format!("No offline name {name}."));
|
||||
}
|
||||
crate::skins::forget(skins_dir, name);
|
||||
if self
|
||||
.offline_selected
|
||||
.as_ref()
|
||||
|
||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user