mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'origin/feat/world-probe-binary' into feat/client-probe-binary
This commit is contained in:
commit
b9501fc826
153 files changed
+2783
-2407
No files matched your search
@@ -64,9 +64,13 @@ jobs:
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: cargo +stable test --workspace
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
cargo +stable test --workspace --doc
|
||||
parity:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: ./.github/workflows/parity.yml
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Horizon: a map for agents
|
||||
|
||||
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
|
||||
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
|
||||
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Rules that bite
|
||||
|
||||
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
|
||||
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
|
||||
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
|
||||
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with
|
||||
`> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.` (issues: "Filed by", reviews:
|
||||
"Review by"); every comment starts with `🤖 Agent:`. Never write as Ludovic.
|
||||
- **Attribution.** Commits end with `Co-Authored-By: Claude <model> <[email protected]>`; PR bodies end
|
||||
with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
they would change the vanilla look.
|
||||
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Every merge to main ships
|
||||
([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
|
||||
## Crates
|
||||
|
||||
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `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` |
|
||||
| `horizon-assets` | Resource decoding: block-state and item models, baking, atlases |
|
||||
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
|
||||
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
|
||||
|
||||
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
|
||||
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
|
||||
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
|
||||
|
||||
## Build and test fast
|
||||
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
|
||||
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
|
||||
harnesses for CI.
|
||||
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
|
||||
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
|
||||
([docs/horizon-ctl-commands.md](docs/horizon-ctl-commands.md)).
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
|
||||
- Bytecode queries (callers through lambdas, every overload):
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
|
||||
([docs/decompilation-status.md](docs/decompilation-status.md)).
|
||||
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
|
||||
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
|
||||
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
|
||||
random draws must happen in vanilla's order.
|
||||
|
||||
## horizon-client: where things live
|
||||
|
||||
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`Game`; bin code reaches the lib as `horizon_client::<module>`.
|
||||
|
||||
| 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` |
|
||||
| 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/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| 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`, `block_entity_nbt.rs`, `options_file.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` |
|
||||
| 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`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_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)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
|
||||
## How `Game` is organised
|
||||
|
||||
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
|
||||
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
|
||||
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
|
||||
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
|
||||
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
|
||||
## The two single-player paths
|
||||
|
||||
1. **In-frame (default since #1428):** the window's `Game` ticks the world itself, one level that follows
|
||||
the player (`fn enter_dimension`). This is what Stable players run and what harnesses test.
|
||||
2. **Server thread (`--server-thread` or `HORIZON_SERVER_THREAD=1`):** `game/integrated.rs` runs a second
|
||||
`Game` (`serving`) on a "Server thread" with one level per dimension; the window joins it as a client
|
||||
through memory (`remote`), exactly as a LAN guest or a dedicated server's player does. Not at parity yet:
|
||||
open `[server-thread]` issues list the gaps.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # in-frame
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --server-thread # server thread
|
||||
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
|
||||
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
## Docs worth knowing
|
||||
|
||||
[docs/parity-workflow.md](docs/parity-workflow.md) (runner, gate, shards, scopes) ·
|
||||
[docs/native-client.md](docs/native-client.md) (options, controls, horizon-ctl) ·
|
||||
[docs/server-levels.md](docs/server-levels.md) (levels per dimension) ·
|
||||
[docs/rust-parity.md](docs/rust-parity.md) (what is ported) · [docs/known-issues.md](docs/known-issues.md) ·
|
||||
[docs/release-flow.md](docs/release-flow.md) · [docs/README.md](docs/README.md) (index).
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `banner_patterns` layers carried by banner items, shields and placed
|
||||
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
|
||||
//! six-layer limit and the result stack).
|
||||
use crate::{block_entity_render::DYES, item_data, items::ItemStack, items::Items};
|
||||
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
@@ -261,12 +261,6 @@ pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Keeps `item_data` linked: the `StackData` fields the banner code reads.
|
||||
pub fn set_layers(stack: &mut ItemStack, layers: Vec<BannerLayer>) {
|
||||
let data: &mut item_data::StackData = stack.data_mut();
|
||||
data.banner_patterns = layers;
|
||||
stack.tidy();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -144,9 +144,6 @@ impl BlockEntities {
|
||||
pub fn set_states(&mut self, states: Vec<Ticking>) {
|
||||
self.states = states;
|
||||
}
|
||||
pub fn take_states(&mut self) -> Vec<Ticking> {
|
||||
std::mem::take(&mut self.states)
|
||||
}
|
||||
pub fn ticking(&self, state: u32) -> Ticking {
|
||||
self.states.get(state as usize).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -382,10 +382,6 @@ impl Shulker {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `getProgress(a)`: `Mth.lerp(a, progressOld, progress)`.
|
||||
pub fn get_progress(&self, a: f32) -> f32 {
|
||||
self.progress_old + a * (self.progress - self.progress_old)
|
||||
}
|
||||
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
|
||||
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
|
||||
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
|
||||
@@ -668,14 +664,6 @@ impl Skull {
|
||||
self.animating = false;
|
||||
}
|
||||
}
|
||||
/// `getAnimation(a)`.
|
||||
pub fn get_animation(&self, a: f32) -> f32 {
|
||||
if self.animating {
|
||||
self.tick_count as f32 + a
|
||||
} else {
|
||||
self.tick_count as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DecoratedPotBlockEntity.WobbleStyle`.
|
||||
|
||||
@@ -875,13 +875,6 @@ impl BlockEntityRenderer {
|
||||
visibility: None,
|
||||
}
|
||||
}
|
||||
/// The standing banner's pole-and-bar and flag models (`BannerRenderer.submitSpecial`).
|
||||
pub fn banner_models(&self) -> Option<(&ModelPart, &ModelPart)> {
|
||||
Some((
|
||||
self.models.get("standing_banner")?,
|
||||
self.models.get("standing_banner_flag")?,
|
||||
))
|
||||
}
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
self.kinds.get(state as usize).unwrap_or(&Kind::None)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ impl BossBar {
|
||||
style: "progress".into(),
|
||||
value: 0,
|
||||
max: 100,
|
||||
visible: false,
|
||||
visible: true,
|
||||
player: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,10 +169,6 @@ impl TickRateManager {
|
||||
self.frozen_ticks_to_run -= 1;
|
||||
}
|
||||
}
|
||||
/// `isEntityFrozen` for an entity that is not (and does not carry) a player.
|
||||
pub fn is_entity_frozen(&self, is_player_or_carries_player: bool) -> bool {
|
||||
!self.runs_normally() && !is_player_or_carries_player
|
||||
}
|
||||
}
|
||||
|
||||
/// What `/tick sprint` reports when it ends.
|
||||
|
||||
@@ -812,14 +812,6 @@ pub fn java_parse_int(s: &str) -> Option<i32> {
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
/// Long.parseLong.
|
||||
pub fn java_parse_long(s: &str) -> Option<i64> {
|
||||
let digits = s.strip_prefix(['-', '+']).unwrap_or(s);
|
||||
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
/// Double.parseDouble over the characters StringReader reads as a number.
|
||||
pub fn java_parse_double(s: &str) -> Option<f64> {
|
||||
let t = s.trim_matches(|c: char| c <= ' ');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! ComponentSerialization.CODEC and Style.Serializer.MAP_CODEC decoding of
|
||||
//! SNBT text components, with the data-fixer-upper error messages
|
||||
//! (ComponentArgument, StyleArgument and component-valued item data).
|
||||
use super::codec::{get, no_key, not_list, not_map, snbt};
|
||||
use super::codec::{get, no_key, not_list, not_map};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
|
||||
/// A DataResult error: its message and whether it carries a partial result.
|
||||
@@ -362,7 +362,3 @@ pub fn decode_style(t: &Tag) -> Result<(), String> {
|
||||
_ => Err(not_map(t)),
|
||||
}
|
||||
}
|
||||
#[allow(unused)]
|
||||
fn _keep(t: &Tag) -> String {
|
||||
snbt(t)
|
||||
}
|
||||
@@ -632,10 +632,6 @@ pub fn parse_predicate(reader: &mut Reader, reg: &Registries) -> CmdResult<ItemI
|
||||
pub fn suggest_predicate(builder: &mut SuggestionsBuilder, reg: &Registries) {
|
||||
PREDICATE.with(|g| g.parse_for_suggestions_with(builder, Some(reg)));
|
||||
}
|
||||
#[allow(unused)]
|
||||
fn _unused(t: &Tag) -> String {
|
||||
snbt(t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -183,9 +183,6 @@ impl<'a> Reader<'a> {
|
||||
pub fn read_int(&mut self) -> CmdResult<i32> {
|
||||
self.number("int")
|
||||
}
|
||||
pub fn read_long(&mut self) -> CmdResult<i64> {
|
||||
self.number("long")
|
||||
}
|
||||
pub fn read_double(&mut self) -> CmdResult<f64> {
|
||||
self.number("double")
|
||||
}
|
||||
@@ -222,21 +219,6 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
Err(CommandError::tr_at("parsing.quote.expected.end", &[], self))
|
||||
}
|
||||
pub fn read_quoted_string(&mut self) -> CmdResult<String> {
|
||||
if !self.can_read() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let next = self.peek();
|
||||
if next != '"' && next != '\'' {
|
||||
return Err(CommandError::tr_at(
|
||||
"parsing.quote.expected.start",
|
||||
&[],
|
||||
self,
|
||||
));
|
||||
}
|
||||
self.skip();
|
||||
self.read_string_until(next)
|
||||
}
|
||||
pub fn read_string(&mut self) -> CmdResult<String> {
|
||||
if !self.can_read() {
|
||||
return Ok(String::new());
|
||||
@@ -539,18 +521,6 @@ impl<'a> SuggestionsBuilder<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The same with a tooltip per candidate.
|
||||
pub fn suggest_matching_tooltip<'b>(
|
||||
&mut self,
|
||||
candidates: impl IntoIterator<Item = (&'b str, String)>,
|
||||
) {
|
||||
let lower = self.remaining_lower();
|
||||
for (c, tip) in candidates {
|
||||
if args::matches_sub_str(&lower, &c.to_lowercase()) {
|
||||
self.suggest_tooltip(c, tip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// String.compareToIgnoreCase.
|
||||
pub fn compare_ignore_case(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! SnbtGrammar and TagParser: vanilla's packrat SNBT grammar, with its
|
||||
//! error messages and completion suggestions, producing NBT tags.
|
||||
use super::packrat::{
|
||||
Grammar, Reason, Rule, State, Sugg, Term, Value, alt, ch, chs, look, opt, seq,
|
||||
};
|
||||
use super::packrat::{Grammar, Reason, State, Sugg, Term, Value, alt, ch, chs, look, opt, seq};
|
||||
use super::{CmdResult, CommandError, Reader, SuggestionsBuilder};
|
||||
use horizon_world::nbt::{Compound, JavaString, Tag};
|
||||
use std::rc::Rc;
|
||||
@@ -981,5 +979,3 @@ pub fn rule_in<V: super::packrat::Value>(
|
||||
}
|
||||
}
|
||||
}
|
||||
#[allow(unused)]
|
||||
fn _rule_kind(_: Rule<SV>) {}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! mode keywords are read back from the parsed literals.
|
||||
use super::{
|
||||
Dispatcher,
|
||||
args::{ArgType, GameMode, Resource, integer},
|
||||
args::{ArgType, Resource, integer},
|
||||
};
|
||||
use crate::gamerules::{RULES, Value};
|
||||
|
||||
@@ -509,10 +509,6 @@ fn clone_mode(d: &mut Dispatcher, node: usize) {
|
||||
d.executes(l, "clone");
|
||||
}
|
||||
}
|
||||
/// Game mode names for selector `gamemode=` and the argument.
|
||||
pub fn game_modes() -> [&'static str; 4] {
|
||||
GameMode::ALL.map(GameMode::name)
|
||||
}
|
||||
/// The shared vanilla tree.
|
||||
/// Reorder node children as vanilla registers them (Brigadier keeps
|
||||
/// children in registration order; smart usage lists them that way).
|
||||
@@ -559,5 +555,3 @@ pub fn release_dispatcher() -> &'static Dispatcher {
|
||||
static TREE: std::sync::OnceLock<Dispatcher> = std::sync::OnceLock::new();
|
||||
TREE.get_or_init(|| build_with(false))
|
||||
}
|
||||
#[allow(unused)]
|
||||
const _: (u8, u8) = (ALL, GAMEMASTERS);
|
||||
@@ -252,24 +252,6 @@ impl BlockEntity {
|
||||
};
|
||||
Some(Self::Chest(vec![None; size]))
|
||||
}
|
||||
/// The vanilla block entity type id of a container block.
|
||||
pub fn id_for_block(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
"minecraft:chest" => "minecraft:chest",
|
||||
n if n.ends_with("copper_chest") => "minecraft:chest",
|
||||
"minecraft:trapped_chest" => "minecraft:trapped_chest",
|
||||
"minecraft:barrel" => "minecraft:barrel",
|
||||
"minecraft:hopper" => "minecraft:hopper",
|
||||
"minecraft:dispenser" => "minecraft:dispenser",
|
||||
"minecraft:dropper" => "minecraft:dropper",
|
||||
"minecraft:furnace" => "minecraft:furnace",
|
||||
"minecraft:blast_furnace" => "minecraft:blast_furnace",
|
||||
"minecraft:smoker" => "minecraft:smoker",
|
||||
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
|
||||
"minecraft:crafter" => "minecraft:crafter",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Chest(items) => match items.len() {
|
||||
|
||||
@@ -17,8 +17,6 @@ use std::{
|
||||
pub const CAPACITY: usize = 240;
|
||||
/// `AbstractDebugChart.CHART_HEIGHT`.
|
||||
pub const CHART_HEIGHT: i32 = 60;
|
||||
/// `TpsDebugDimensions`: a tick sample's values.
|
||||
pub const FULL_TICK: usize = 0;
|
||||
pub const TICK_SERVER_METHOD: usize = 1;
|
||||
pub const SCHEDULED_TASKS: usize = 2;
|
||||
pub const IDLE: usize = 3;
|
||||
@@ -252,10 +250,6 @@ impl Chart {
|
||||
pub fn width(max_width: i32) -> i32 {
|
||||
(CAPACITY as i32 + 2).min(max_width)
|
||||
}
|
||||
/// `getFullHeight`: the box and the min/avg/max line above it.
|
||||
pub fn full_height() -> i32 {
|
||||
CHART_HEIGHT + 9
|
||||
}
|
||||
/// `AbstractDebugChart.extractRenderState`.
|
||||
pub fn draw(
|
||||
self,
|
||||
|
||||
@@ -359,10 +359,6 @@ impl Enchantments {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Whether a stack draws the enchantment glint.
|
||||
pub fn has_glint(&self, stack: &ItemStack) -> bool {
|
||||
stack.has_any_enchantments() || self.props(&stack.item).is_some_and(|p| p.glint)
|
||||
}
|
||||
|
||||
// ---- Enchanting table (EnchantmentHelper) ----
|
||||
|
||||
@@ -514,19 +510,6 @@ impl Enchantments {
|
||||
.is_some_and(|e| e.effects.get(component).is_some())
|
||||
})
|
||||
}
|
||||
/// The highest level among enchantments on `stack` that have `component`.
|
||||
pub fn component_level(&self, stack: &ItemStack, component: &str) -> u32 {
|
||||
stack
|
||||
.enchantments()
|
||||
.iter()
|
||||
.filter(|(id, _)| {
|
||||
self.get(id)
|
||||
.is_some_and(|e| e.effects.get(component).is_some())
|
||||
})
|
||||
.map(|(_, l)| *l)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// `minecraft:attributes` contributions of `stack`: (attribute, id, amount, operation).
|
||||
pub fn attributes(&self, stack: &ItemStack) -> Vec<(String, String, f64, String)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -89,6 +89,47 @@ impl Base<'_> {
|
||||
}
|
||||
|
||||
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
|
||||
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
|
||||
/// not allays, villagers, golems, dolphins or fish.
|
||||
fn is_animal(id: &str) -> bool {
|
||||
matches!(
|
||||
id.strip_prefix("minecraft:").unwrap_or(id),
|
||||
"pig"
|
||||
| "cow"
|
||||
| "mooshroom"
|
||||
| "sheep"
|
||||
| "chicken"
|
||||
| "rabbit"
|
||||
| "horse"
|
||||
| "donkey"
|
||||
| "mule"
|
||||
| "zombie_horse"
|
||||
| "skeleton_horse"
|
||||
| "llama"
|
||||
| "trader_llama"
|
||||
| "goat"
|
||||
| "panda"
|
||||
| "polar_bear"
|
||||
| "fox"
|
||||
| "wolf"
|
||||
| "cat"
|
||||
| "ocelot"
|
||||
| "parrot"
|
||||
| "bee"
|
||||
| "turtle"
|
||||
| "armadillo"
|
||||
| "frog"
|
||||
| "axolotl"
|
||||
| "hoglin"
|
||||
| "strider"
|
||||
| "camel"
|
||||
| "camel_husk"
|
||||
| "sniffer"
|
||||
| "happy_ghast"
|
||||
| "nautilus"
|
||||
| "zombie_nautilus"
|
||||
)
|
||||
}
|
||||
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: &m.kind.id,
|
||||
@@ -175,12 +216,15 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
for (key, value) in [
|
||||
("Age", Tag::Int(0)),
|
||||
("ForcedAge", Tag::Int(0)),
|
||||
("InLove", Tag::Int(0)),
|
||||
("AgeLocked", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
|
||||
if is_animal(id) {
|
||||
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
|
||||
}
|
||||
match id {
|
||||
"minecraft:creeper" => {
|
||||
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
|
||||
@@ -621,6 +665,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
}
|
||||
if is_animal(&m.kind.id) {
|
||||
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
|
||||
}
|
||||
m.name_visible = flag(tag, "CustomNameVisible");
|
||||
m.no_ai = flag(tag, "NoAI");
|
||||
m.no_gravity = flag(tag, "NoGravity");
|
||||
|
||||
@@ -289,15 +289,6 @@ impl Default for HandHeights {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What `FirstPersonHandsAndItems.tick` decides about a hand's visible item.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct HandChange {
|
||||
/// The visible item is not the hand's item after the instant replacement
|
||||
/// (`mainHandItem != nextMainHand`): the hand lowers.
|
||||
pub changed: bool,
|
||||
/// The visible item becomes the hand's item this tick.
|
||||
pub replace: bool,
|
||||
}
|
||||
impl HandHeights {
|
||||
/// `FirstPersonHandsAndItems.tick`'s heights. `changed` says per hand
|
||||
/// whether the visible item differs from the held one once
|
||||
|
||||
@@ -271,7 +271,10 @@ fn load_bitmap(
|
||||
.flatten();
|
||||
// The sheet: its size, and its pixels when they are not in the atlas.
|
||||
let (sw, sh, sheet) = match in_atlas {
|
||||
Some(rect) => (rect.w, rect.h, None),
|
||||
Some(rect) => {
|
||||
let (w, h) = rect.texels(atlas);
|
||||
(w, h, None)
|
||||
}
|
||||
None => match crate::hud::decode(&root.join(resource_path(file, "textures", ""))) {
|
||||
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
|
||||
Err(_) => {
|
||||
|
||||
@@ -1021,9 +1021,11 @@ pub struct UiResources {
|
||||
pub panorama: Arc<horizon_client::gfx::panorama::Faces>,
|
||||
}
|
||||
impl UiResources {
|
||||
pub fn load(root: &std::path::Path) -> Result<Self> {
|
||||
/// From a resource root layering packs over `defaults`, the game's own
|
||||
/// resources (`root` itself when no pack is on).
|
||||
pub fn load(root: &std::path::Path, defaults: &std::path::Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
atlas: HudAtlas::build(root)?,
|
||||
atlas: HudAtlas::build_over(root, defaults)?,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
|
||||
})
|
||||
}
|
||||
@@ -2575,6 +2577,17 @@ impl Game<'_> {
|
||||
LevelEvent::LevelEvent { id: 2004, pos, .. } => {
|
||||
self.spawner_burst([pos.x, pos.y, pos.z])
|
||||
}
|
||||
// LevelEventHandler 1505: the growth particles, then the sound.
|
||||
LevelEvent::LevelEvent {
|
||||
id: 1505,
|
||||
pos,
|
||||
data,
|
||||
} => {
|
||||
self.growth_particles(pos, data);
|
||||
let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5);
|
||||
self.audio
|
||||
.play("minecraft:item.bone_meal.use", Some(at), 1.0, 1.0);
|
||||
}
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2015,
|
||||
pos,
|
||||
@@ -2618,7 +2631,6 @@ impl Game<'_> {
|
||||
1030 => Some(("minecraft:block.anvil.use", 1.0)),
|
||||
1029 => Some(("minecraft:block.anvil.destroy", 1.0)),
|
||||
1031 => Some(("minecraft:block.anvil.land", 0.3)),
|
||||
1505 => Some(("minecraft:item.bone_meal.use", 1.0)),
|
||||
1042 => Some(("minecraft:block.grindstone.use", 1.0)),
|
||||
1044 => Some(("minecraft:block.smithing_table.use", 1.0)),
|
||||
// LevelEvent.SOUND_PAGE_TURN (a lectern's page).
|
||||
@@ -8100,13 +8112,15 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `BlockTransformer.transformBlock` for a held axe, hoe or shovel.
|
||||
fn use_block_transformer(
|
||||
&mut self,
|
||||
/// What `BlockTransformer.transformBlock` would do with a held axe, hoe
|
||||
/// or shovel on the block at `pos`, without doing it: the new state, its
|
||||
/// sound, loot table, the durability it costs and its particle.
|
||||
pub(super) fn block_transform(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
) -> Result<bool> {
|
||||
) -> Option<(BlockStateId, String, Option<String>, u32, String)> {
|
||||
use horizon_client::block_transform::Around;
|
||||
struct Column<'w> {
|
||||
world: &'w World,
|
||||
@@ -8126,14 +8140,11 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
}
|
||||
let Some(id) = self
|
||||
let id = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.block_transformer.clone())
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
.and_then(|i| i.block_transformer.clone())?;
|
||||
// playerHasBlockingItemUseIntent: a shield in the off hand is raised
|
||||
// instead, unless sneaking.
|
||||
let shield = self
|
||||
@@ -8142,7 +8153,7 @@ impl Game<'_> {
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
.is_some_and(|i| i.blocks_attacks.is_some());
|
||||
if shield && !self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
return Ok(false);
|
||||
return None;
|
||||
}
|
||||
let face_name = match face {
|
||||
[0, -1, 0] => "down",
|
||||
@@ -8153,38 +8164,47 @@ impl Game<'_> {
|
||||
_ => "east",
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
let (state, sound, loot, damage, particle) = {
|
||||
let around = Column {
|
||||
world: &self.world,
|
||||
registry,
|
||||
pos,
|
||||
};
|
||||
let Some((_, t, state)) =
|
||||
self.res
|
||||
.transformers()
|
||||
.apply(self.res.items(), registry, &id, face_name, &around)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
(
|
||||
state,
|
||||
t.sound.clone(),
|
||||
t.loot.clone(),
|
||||
t.damage,
|
||||
t.particle.clone(),
|
||||
)
|
||||
let around = Column {
|
||||
world: &self.world,
|
||||
registry,
|
||||
pos,
|
||||
};
|
||||
let Ok(new) = registry.resolve(&state) else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.level_set(pos, new)?;
|
||||
// BlockTransformer.TransformParticle.send.
|
||||
match particle.as_str() {
|
||||
let (_, t, state) =
|
||||
self.res
|
||||
.transformers()
|
||||
.apply(self.res.items(), registry, &id, face_name, &around)?;
|
||||
let new = registry.resolve(&state).ok()?;
|
||||
Some((
|
||||
new,
|
||||
t.sound.clone(),
|
||||
t.loot.clone(),
|
||||
t.damage,
|
||||
t.particle.clone(),
|
||||
))
|
||||
}
|
||||
/// `BlockTransformer.TransformParticle.send`: its level event.
|
||||
pub(super) fn transform_particle(&mut self, particle: &str, pos: [i32; 3]) {
|
||||
match particle {
|
||||
"scrape" => self.level_event_at(3005, pos, 0),
|
||||
"wax_on" => self.level_event_at(3003, pos, 0),
|
||||
"wax_off" => self.level_event_at(3004, pos, 0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `BlockTransformer.transformBlock` for a held axe, hoe or shovel.
|
||||
fn use_block_transformer(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
) -> Result<bool> {
|
||||
let Some((new, sound, loot, damage, particle)) = self.block_transform(stack, pos, face)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.level_set(pos, new)?;
|
||||
// BlockTransformer.TransformParticle.send.
|
||||
self.transform_particle(&particle, pos);
|
||||
if loot.as_deref() == Some("minecraft:till/rooted_dirt") {
|
||||
self.pop_resource(
|
||||
[pos[0] + face[0], pos[1] + face[1], pos[2] + face[2]],
|
||||
@@ -9570,6 +9590,7 @@ impl Game<'_> {
|
||||
);
|
||||
map.insert("Air".into(), horizon_world::nbt::Tag::Short(p.air as i16));
|
||||
}
|
||||
level.fall_distance = p.fall_distance;
|
||||
|
||||
level.food = p.health.food.food;
|
||||
level.saturation = p.health.food.saturation;
|
||||
@@ -12108,7 +12129,20 @@ fn load_resources(
|
||||
// loads; the synchronous launch instead carries it in `Loaded`.
|
||||
let mut loaded_gui = None;
|
||||
let mut ui_ready = false;
|
||||
if let Ok(ui) = UiResources::load(&root) {
|
||||
let mut pack_error = None;
|
||||
let ui = match UiResources::load(&root, vanilla_root) {
|
||||
Ok(ui) => Some(ui),
|
||||
// Minecraft.rollbackResourcePacks: the packs' textures don't fit, start
|
||||
// with the game's own resources.
|
||||
Err(e) if root != vanilla_root => {
|
||||
log::warn!("Horizon: resource packs failed to load ({e}); using the default resources");
|
||||
pack_error = Some(e.to_string());
|
||||
root = vanilla_root.to_path_buf();
|
||||
None
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(ui) = ui {
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_ui_ready();
|
||||
log::info!(
|
||||
@@ -12122,7 +12156,6 @@ fn load_resources(
|
||||
ui_ready = true;
|
||||
}
|
||||
progress.set(0);
|
||||
let mut pack_error = None;
|
||||
let (assets, render) = match Assets::load(&root).and_then(|assets| {
|
||||
let render = assets.reload(&root)?;
|
||||
Ok((assets, render))
|
||||
@@ -12149,7 +12182,7 @@ fn load_resources(
|
||||
let render = Arc::new(render);
|
||||
// The menu bundle must come from the same root as the world bundle.
|
||||
if !ui_ready || root != requested_root {
|
||||
let ui = UiResources::load(&root)?;
|
||||
let ui = UiResources::load(&root, vanilla_root)?;
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_ui_ready();
|
||||
log::info!(
|
||||
@@ -12560,6 +12593,10 @@ fn start_game<'a>(
|
||||
game.rest_loader = rest_loader;
|
||||
game.ui.vanilla_root = vanilla_root;
|
||||
if let Some(error) = pack_error {
|
||||
// Minecraft.clearResourcePacksOnError: the selection is cleared and
|
||||
// saved, so the next launch starts without the packs.
|
||||
game.settings.resource_packs.clear();
|
||||
game.settings.save(&Game::options_path());
|
||||
game.open_options_at(true, horizon_client::options_ui::Page::Packs);
|
||||
game.ui.options.resource_error = Some(error);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use horizon_client::{
|
||||
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
|
||||
command::Registries,
|
||||
gamerules::GameRules,
|
||||
hud::{HudBatch, rgba},
|
||||
hud::HudBatch,
|
||||
screen_draw::Measure,
|
||||
text::{ClickEvent, Style, Text},
|
||||
};
|
||||
@@ -883,10 +883,6 @@ impl Game<'_> {
|
||||
.chat
|
||||
.add(&self.gui.atlas, message, None, self.ticks, focused);
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
fn chat_debug_fill(&self, batch: &mut HudBatch) {
|
||||
batch.fill(&self.gui.atlas, 0.0, 0.0, 1.0, 1.0, rgba(0, 0, 0, 0));
|
||||
}
|
||||
}
|
||||
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
|
||||
/// approximated as UTC).
|
||||
|
||||
@@ -834,6 +834,12 @@ impl Game<'_> {
|
||||
"velocity": [p.velocity.x, p.velocity.y, p.velocity.z],
|
||||
"on_ground": p.ground.on_ground,
|
||||
"health": p.health.health(),
|
||||
// The maximum the HUD's hearts last read (its cached value).
|
||||
"max_health": p
|
||||
.attributes
|
||||
.lookup(p.attributes.key("minecraft:max_health").as_ref())
|
||||
.and_then(|h| p.attributes.instance(&h).ok())
|
||||
.map(|i| i.cached_value()),
|
||||
"food": p.health.food.food,
|
||||
})
|
||||
});
|
||||
@@ -971,6 +977,10 @@ impl Game<'_> {
|
||||
"flashes": self.lightning_seen[1], "flash": self.flash});
|
||||
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
|
||||
status["projectiles"] = json!(self.projectiles_status());
|
||||
// The particles made so far, by type.
|
||||
status["particles"] = json!(self.particles.made());
|
||||
// The scoreboard sidebar the HUD shows.
|
||||
status["sidebar"] = self.sidebar_status();
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,8 +97,58 @@ impl EndState {
|
||||
}
|
||||
}
|
||||
|
||||
impl EndState {
|
||||
/// The eyes' entity ids, for a server to number them.
|
||||
pub(super) fn eye_ids(&mut self) -> impl Iterator<Item = &mut u32> {
|
||||
self.eyes.iter_mut().map(|e| &mut e.id)
|
||||
}
|
||||
/// The numbered eyes as a server's clients track them: (id, position, motion).
|
||||
pub(super) fn eyes_tracked(&self) -> impl Iterator<Item = (u32, [f64; 3], [f64; 3])> {
|
||||
self.eyes
|
||||
.iter()
|
||||
.filter(|e| e.id != 0)
|
||||
.map(|e| (e.id, e.position.to_array(), e.velocity.to_array()))
|
||||
}
|
||||
/// `handleAddEntity` for an eye of ender on a server's client: it flies
|
||||
/// where the server says.
|
||||
pub(super) fn add_remote_eye(&mut self, id: u32, at: [f64; 3], velocity: [f64; 3]) {
|
||||
self.eyes.retain(|e| e.id != id);
|
||||
let at = glam::DVec3::from_array(at);
|
||||
self.eyes.push(EyeOfEnder {
|
||||
id,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: glam::DVec3::from_array(velocity),
|
||||
target: at,
|
||||
life: 0,
|
||||
survive: false,
|
||||
});
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) {
|
||||
for e in &mut self.eyes {
|
||||
e.previous = e.position;
|
||||
e.life += 1;
|
||||
e.position = glam::DVec3::from_array(step(e.id, e.position.to_array()));
|
||||
}
|
||||
}
|
||||
pub(super) fn remove_remote_eye(&mut self, id: u32) {
|
||||
self.eyes.retain(|e| e.id != id);
|
||||
}
|
||||
pub(super) fn clear_remote_eyes(&mut self) {
|
||||
self.eyes.clear();
|
||||
}
|
||||
/// Where each eye is, for `status`.
|
||||
pub(super) fn eye_positions(&self) -> impl Iterator<Item = (u32, [f64; 3])> {
|
||||
self.eyes.iter().map(|e| (e.id, e.position.to_array()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The EyeOfEnder entity.
|
||||
struct EyeOfEnder {
|
||||
/// Its entity id once a server's clients track it (0 until then).
|
||||
id: u32,
|
||||
position: glam::DVec3,
|
||||
previous: glam::DVec3,
|
||||
velocity: glam::DVec3,
|
||||
@@ -1023,6 +1073,7 @@ impl Game<'_> {
|
||||
};
|
||||
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
|
||||
self.end.eyes.push(EyeOfEnder {
|
||||
id: 0,
|
||||
position: start,
|
||||
previous: start,
|
||||
velocity: glam::DVec3::ZERO,
|
||||
|
||||
@@ -997,6 +997,7 @@ impl Game<'_> {
|
||||
self.lan_update_streaming(&host);
|
||||
self.lan_update_models(&mut host);
|
||||
self.lan_update_waypoints(&mut host, state.as_ref());
|
||||
self.lan_sync_scoreboard(&mut host);
|
||||
drop(holding);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
@@ -1087,8 +1088,14 @@ impl Game<'_> {
|
||||
}
|
||||
for e in events {
|
||||
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
|
||||
// The acting guest does not predict the egg's teleport particles.
|
||||
let except = if *id == 2015 { None } else { self.lan_acting };
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, nor bone meal's growth (BoneMealItem.useOn's
|
||||
// levelEvent names no player).
|
||||
let except = if matches!(*id, 2015 | 1505) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
};
|
||||
self.lan_events
|
||||
.push((*id, [pos.x, pos.y, pos.z], *data, except));
|
||||
}
|
||||
|
||||
@@ -220,6 +220,12 @@ impl Game<'_> {
|
||||
host.hub.guests[i].position[2],
|
||||
);
|
||||
host.hub.guests[i].motion = PlayerMotion::new(self.motion, p).ok().map(Box::new);
|
||||
// The first body after joining burns, breathes and falls on as saved.
|
||||
if let Some(saved) = host.hub.guests[i].loaded_body.take()
|
||||
&& let Some(m) = host.hub.guests[i].motion.as_mut()
|
||||
{
|
||||
saved.apply(m);
|
||||
}
|
||||
// The saved golden hearts go on the first body of a player who
|
||||
// has been here before.
|
||||
let saved = host.hub.guests[i].saved_absorption.take();
|
||||
|
||||
@@ -277,6 +277,8 @@ impl Game<'_> {
|
||||
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
|
||||
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
|
||||
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
|
||||
self.potions.thrown_ids().for_each(&mut give);
|
||||
self.end.eye_ids().for_each(&mut give);
|
||||
}
|
||||
|
||||
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
|
||||
@@ -359,6 +361,26 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// Thrown potions and bottles: ThrowableItemProjectile.DATA_ITEM_STACK
|
||||
// (the potion's colour), which a plain bottle's type already has.
|
||||
for (id, kind, stack, position, velocity) in self.potions.thrown_tracked() {
|
||||
let mut e = state(id, kind, at(position), 0.0, false);
|
||||
e.velocity = at(velocity);
|
||||
if stack.extra.is_some() || stack.count != 1 {
|
||||
e.meta.push((
|
||||
8,
|
||||
serializer::ITEM_STACK,
|
||||
Value::Item(hub.stack(&Some(stack.clone()))),
|
||||
));
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// EyeOfEnder: its item is the plain eye (DATA_ITEM_STACK's default).
|
||||
for (id, position, velocity) in self.end.eyes_tracked() {
|
||||
let mut e = state(id, "minecraft:eye_of_ender", position, 0.0, false);
|
||||
e.velocity = velocity;
|
||||
out.push(e);
|
||||
}
|
||||
for t in self.tridents.iter().filter(|t| t.id != 0) {
|
||||
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
|
||||
e.pitch = t.pitch;
|
||||
|
||||
@@ -129,23 +129,6 @@ impl Game<'_> {
|
||||
}
|
||||
/// The screen title: the custom name, else the profession (the client
|
||||
/// adds the level itself).
|
||||
#[allow(dead_code)]
|
||||
fn merchant_title(&self, state: &MerchantState) -> String {
|
||||
if let Some(name) = &state.name {
|
||||
return name.clone();
|
||||
}
|
||||
let e = &self.res.stations().enchantments;
|
||||
let profession = state.session.profession.trim_start_matches("minecraft:");
|
||||
if state.session.villager {
|
||||
e.translate(&format!("entity.minecraft.villager.{profession}"))
|
||||
.unwrap_or(profession)
|
||||
.to_owned()
|
||||
} else {
|
||||
e.translate("entity.minecraft.wandering_trader")
|
||||
.unwrap_or("Wandering Trader")
|
||||
.to_owned()
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.sendMerchantOffers`, when the offers changed.
|
||||
fn lan_send_offers(
|
||||
&mut self,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Level events whose client handler (`LevelEventHandler`) spawns particles
|
||||
//! on a block: dragon egg teleportation (2015), a consumable's teleport
|
||||
//! (2017), electric sparks (3002), waxing (3003), wax off (3004) and
|
||||
//! scraping (3005).
|
||||
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
|
||||
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
|
||||
//! off (3004) and scraping (3005).
|
||||
use super::Game;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::LevelEvent;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -42,6 +42,65 @@ impl Game<'_> {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
/// `BoneMealItem.addGrowthParticles` (event 1505, `count` the data):
|
||||
/// happy villager sparks over a spreading block's neighbourhood (grass,
|
||||
/// nylium, netherrack, moss) or water, or inside a growing block.
|
||||
pub(super) fn growth_particles(&mut self, pos: BlockPos, count: i32) {
|
||||
let p = [pos.x, pos.y, pos.z];
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let facts = self.res.facts();
|
||||
// ParticleUtils.spawnParticles(level, pos, count, xzSpread, ySpread,
|
||||
// allowFloatingParticles, particle).
|
||||
let (at, count, xz, height, floating) = match self.res.behaviours().bone_meal_spreads(state)
|
||||
{
|
||||
Some(spreads) => {
|
||||
// BonemealableBlock.getParticlePos: below for rooted dirt
|
||||
// (its hanging roots) and mangrove leaves (their propagule).
|
||||
let at = match &*facts.get(state).name {
|
||||
"minecraft:rooted_dirt" | "minecraft:mangrove_leaves" => [p[0], p[1] - 1, p[2]],
|
||||
_ => p,
|
||||
};
|
||||
if spreads {
|
||||
(at, count * 3, 3.0, 1.0, false)
|
||||
} else {
|
||||
// ParticleUtils.spawnParticleInBlock: up to the
|
||||
// block's shape (a full block when air).
|
||||
let inside = self.world.block(at[0], at[1], at[2]);
|
||||
let height = if facts.is_air(inside) {
|
||||
1.0
|
||||
} else {
|
||||
facts
|
||||
.get(inside)
|
||||
.outline
|
||||
.iter()
|
||||
.map(|b| b[4])
|
||||
.fold(0.0, f64::max)
|
||||
};
|
||||
(at, count, 0.5, height, true)
|
||||
}
|
||||
}
|
||||
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
|
||||
None => return,
|
||||
};
|
||||
let options = ParticleOptions::simple(ParticleType::HappyVillager);
|
||||
for _ in 0..count.max(0) {
|
||||
let r = self.particles.level_random();
|
||||
let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02);
|
||||
let start = 0.5 - xz;
|
||||
let x = f64::from(at[0]) + start + r.next_double() * xz * 2.0;
|
||||
let y = f64::from(at[1]) + r.next_double() * height;
|
||||
let z = f64::from(at[2]) + start + r.next_double() * xz * 2.0;
|
||||
let below = [x.floor() as i32, y.floor() as i32 - 1, z.floor() as i32];
|
||||
if floating
|
||||
|| !self
|
||||
.res
|
||||
.facts()
|
||||
.is_air(self.world.block(below[0], below[1], below[2]))
|
||||
{
|
||||
self.particles.add(&options, [x, y, z], v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
|
||||
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
|
||||
@@ -133,7 +133,7 @@ impl Game<'_> {
|
||||
}
|
||||
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
|
||||
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
|
||||
let mut atlas = HudAtlas::build(root)?;
|
||||
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
|
||||
atlas.font.uniform = self.settings.force_unicode;
|
||||
atlas.font.japanese = self.settings.japanese_glyphs;
|
||||
let gui = super::UiResources {
|
||||
|
||||
@@ -18,6 +18,8 @@ use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
/// A thrown splash or lingering potion in flight.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct ThrownPotion {
|
||||
/// Its entity id once a server's clients track it (0 until then).
|
||||
id: u32,
|
||||
stack: ItemStack,
|
||||
/// The position before the last tick, for drawing between ticks.
|
||||
previous: Vec3,
|
||||
@@ -47,6 +49,7 @@ impl Game<'_> {
|
||||
/// player's throws and dispensers both end up here).
|
||||
pub(super) fn launch_thrown(&mut self, stack: ItemStack, position: Vec3, velocity: Vec3) {
|
||||
self.potions.thrown.push(ThrownPotion {
|
||||
id: 0,
|
||||
stack,
|
||||
previous: position,
|
||||
position,
|
||||
@@ -90,19 +93,78 @@ impl PotionState {
|
||||
pub(super) fn thrown_entities(&self) -> Vec<(&'static str, Vec3, Vec3)> {
|
||||
self.thrown
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let kind = match &*t.stack.item {
|
||||
"minecraft:lingering_potion" => "minecraft:lingering_potion",
|
||||
"minecraft:experience_bottle" => "minecraft:experience_bottle",
|
||||
_ => "minecraft:splash_potion",
|
||||
};
|
||||
(kind, t.position, t.velocity)
|
||||
})
|
||||
.map(|t| (thrown_kind(&t.stack), t.position, t.velocity))
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn clear_thrown(&mut self) {
|
||||
self.thrown.clear();
|
||||
}
|
||||
/// Their entity ids, for a server to number them.
|
||||
pub(super) fn thrown_ids(&mut self) -> impl Iterator<Item = &mut u32> {
|
||||
self.thrown.iter_mut().map(|t| &mut t.id)
|
||||
}
|
||||
/// The numbered ones as a server's clients track them: (id, entity
|
||||
/// type, item, position, motion).
|
||||
pub(super) fn thrown_tracked(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (u32, &'static str, &ItemStack, Vec3, Vec3)> {
|
||||
self.thrown.iter().filter(|t| t.id != 0).map(|t| {
|
||||
(
|
||||
t.id,
|
||||
thrown_kind(&t.stack),
|
||||
&t.stack,
|
||||
t.position,
|
||||
t.velocity,
|
||||
)
|
||||
})
|
||||
}
|
||||
/// `handleAddEntity` for a thrown potion or bottle on a server's client:
|
||||
/// drawn where the server says (its item comes in its entity data).
|
||||
pub(super) fn add_remote_thrown(&mut self, id: u32, kind: &str, at: Vec3, velocity: Vec3) {
|
||||
self.thrown.retain(|t| t.id != id);
|
||||
let item = match kind {
|
||||
"minecraft:lingering_potion" | "minecraft:experience_bottle" => kind,
|
||||
_ => "minecraft:splash_potion",
|
||||
};
|
||||
self.thrown.push(ThrownPotion {
|
||||
id,
|
||||
stack: ItemStack::new(item, 1),
|
||||
previous: at,
|
||||
position: at,
|
||||
velocity,
|
||||
age: 0,
|
||||
});
|
||||
}
|
||||
/// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it shows.
|
||||
pub(super) fn remote_thrown_item(&mut self, id: u32, stack: ItemStack) {
|
||||
if let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) {
|
||||
t.stack = stack;
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_thrown(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
for t in &mut self.thrown {
|
||||
t.previous = t.position;
|
||||
t.age += 1;
|
||||
t.position = step(t.id, t.position);
|
||||
}
|
||||
}
|
||||
pub(super) fn remove_remote_thrown(&mut self, id: u32) {
|
||||
self.thrown.retain(|t| t.id != id);
|
||||
}
|
||||
pub(super) fn has_thrown(&self, id: u32) -> bool {
|
||||
self.thrown.iter().any(|t| t.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The entity type of a thrown potion or bottle.
|
||||
fn thrown_kind(stack: &ItemStack) -> &'static str {
|
||||
match &*stack.item {
|
||||
"minecraft:lingering_potion" => "minecraft:lingering_potion",
|
||||
"minecraft:experience_bottle" => "minecraft:experience_bottle",
|
||||
_ => "minecraft:splash_potion",
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -164,6 +164,8 @@ pub(super) struct Remote {
|
||||
level_players: HashMap<i32, u128>,
|
||||
/// The local player's profile UUID (always in the level).
|
||||
profile: u128,
|
||||
/// The local player's profile name: its score holder and team member.
|
||||
pub(super) profile_name: String,
|
||||
destroying: Option<([i32; 3], u8, f32)>,
|
||||
destroy_delay: u32,
|
||||
input: horizon_client::interaction_input::InteractionInput,
|
||||
@@ -411,6 +413,7 @@ impl Game<'_> {
|
||||
online_mode: false,
|
||||
level_players: HashMap::new(),
|
||||
profile: 0,
|
||||
profile_name: String::new(),
|
||||
destroying: None,
|
||||
destroy_delay: 0,
|
||||
input: Default::default(),
|
||||
@@ -795,6 +798,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.joined = true;
|
||||
r.profile = uuid;
|
||||
r.profile_name = name.clone();
|
||||
r.status = format!("Joined as {name}; loading terrain...");
|
||||
}
|
||||
}
|
||||
@@ -1725,29 +1729,10 @@ impl Game<'_> {
|
||||
self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1));
|
||||
}
|
||||
}
|
||||
C::LevelEvent(p) if p.kind == 2015 => {
|
||||
self.dragon_egg_particles(p.pos, p.data);
|
||||
}
|
||||
C::LevelEvent(p) if p.kind == 2017 => {
|
||||
self.consume_teleport_particles(p.pos, p.data);
|
||||
}
|
||||
C::LevelEvent(p) if p.kind == 2001 => {
|
||||
// LevelEvent.PARTICLES_DESTROY_BLOCK: someone else broke a block.
|
||||
let state = BlockStateId(p.data.max(0) as u32);
|
||||
let s = self.res.facts().get(state).sounds.clone();
|
||||
let at = [
|
||||
f64::from(p.pos[0]) + 0.5,
|
||||
f64::from(p.pos[1]) + 0.5,
|
||||
f64::from(p.pos[2]) + 0.5,
|
||||
];
|
||||
self.audio.play(
|
||||
&s.break_sound,
|
||||
Some(at),
|
||||
(s.volume + 1.0) / 2.0,
|
||||
s.pitch * 0.8,
|
||||
);
|
||||
self.destroy_particles(p.pos, state);
|
||||
}
|
||||
// ClientLevel.levelEvent: LevelEventHandler, as this client's own
|
||||
// level plays them (particles and sounds: a spawner's burst, bone
|
||||
// meal, waxing, a block broken by someone else).
|
||||
C::LevelEvent(p) => self.level_event_at(p.kind, p.pos, p.data),
|
||||
C::Sound(p) => {
|
||||
let name = match &p.sound.1 {
|
||||
Some((location, _)) => Some(location.clone()),
|
||||
@@ -2147,6 +2132,15 @@ impl Game<'_> {
|
||||
m.swing();
|
||||
}
|
||||
}
|
||||
// The player's own instances: its maximum health (the hearts the
|
||||
// HUD draws and the health it may hold), reach, speed and so on.
|
||||
// Then the received health again, which the old maximum clamped.
|
||||
C::UpdateAttributes(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
horizon_client::lan::attributes::apply(&mut pl.attributes, &p.values);
|
||||
}
|
||||
self.apply_remote_health();
|
||||
}
|
||||
C::UpdateMobEffect(p) => self.remote_effect(&p),
|
||||
C::RemoveMobEffect(p) => {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
@@ -3821,6 +3815,8 @@ impl Game<'_> {
|
||||
}
|
||||
if reacts {
|
||||
self.predict_use(&changes, seq);
|
||||
} else if let Some(stack) = held.as_ref() {
|
||||
self.predict_item_on_block(stack, &hit);
|
||||
}
|
||||
if held_block {
|
||||
if reacts
|
||||
@@ -3839,6 +3835,38 @@ impl Game<'_> {
|
||||
false
|
||||
}
|
||||
|
||||
/// `Item.useOn` on this client's level for the items whose effects are
|
||||
/// the clicker's own (`Level.levelEvent(player, ...)` and
|
||||
/// `playSound(player, ...)`: the server sends them to the others only):
|
||||
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
|
||||
/// block (stripping, scraping, removing wax, tilling, making a path).
|
||||
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) {
|
||||
let at = [
|
||||
f64::from(hit.pos[0]) + 0.5,
|
||||
f64::from(hit.pos[1]) + 0.5,
|
||||
f64::from(hit.pos[2]) + 0.5,
|
||||
];
|
||||
if &*stack.item == "minecraft:honeycomb" {
|
||||
// HoneycombItem.useOn.
|
||||
let registry = self.pack.assets.registry;
|
||||
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let waxes = registry
|
||||
.state(clicked)
|
||||
.and_then(|old| self.res.transformers().waxed(registry, old))
|
||||
.is_some();
|
||||
if waxes {
|
||||
self.level_event_at(3003, hit.pos, 0);
|
||||
self.audio
|
||||
.play("minecraft:item.honeycomb.wax_on", Some(at), 1.0, 1.0);
|
||||
}
|
||||
} else if let Some((_, sound, _, _, particle)) =
|
||||
self.block_transform(stack, hit.pos, hit.face)
|
||||
{
|
||||
self.transform_particle(&particle, hit.pos);
|
||||
self.audio.play(&sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// MultiPlayerGameMode.useItemOn inside startPrediction: the blocks the
|
||||
/// use changed on this client's level at once, each kept as predicted
|
||||
/// under the use's sequence until the server acknowledges it (a use the
|
||||
|
||||
@@ -79,6 +79,15 @@ impl Game<'_> {
|
||||
self.rockets
|
||||
.push(super::fireworks::Rocket::remote(id, at, velocity));
|
||||
}
|
||||
"minecraft:splash_potion"
|
||||
| "minecraft:lingering_potion"
|
||||
| "minecraft:experience_bottle" => {
|
||||
self.potions.add_remote_thrown(id, kind, at, velocity);
|
||||
}
|
||||
"minecraft:eye_of_ender" => {
|
||||
self.end
|
||||
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
|
||||
}
|
||||
"minecraft:end_crystal" => self.crystals.push(super::dragon::Crystal {
|
||||
id,
|
||||
position: at,
|
||||
@@ -105,6 +114,17 @@ impl Game<'_> {
|
||||
/// `handleSetEntityData` for these types: what changes how they look.
|
||||
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let id = id as u32;
|
||||
if self.potions.has_thrown(id) {
|
||||
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
|
||||
for (index, _, value) in values {
|
||||
if let (8, Value::Item(stack)) = (index, value)
|
||||
&& let Some(stack) = self.to_stack(stack)
|
||||
{
|
||||
self.potions.remote_thrown_item(id, stack);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (index, _, value) in values {
|
||||
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
|
||||
// PrimedTnt.DATA_FUSE_ID.
|
||||
@@ -184,6 +204,11 @@ impl Game<'_> {
|
||||
c.time += 1;
|
||||
c.position = step(c.id, c.position);
|
||||
}
|
||||
self.potions.step_remote_thrown(&mut step);
|
||||
self.end.step_remote_eyes(|id, at| {
|
||||
let at = step(id, Vec3::new(at[0], at[1], at[2]));
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
|
||||
@@ -193,6 +218,8 @@ impl Game<'_> {
|
||||
self.tridents.retain(|t| t.id != id);
|
||||
self.rockets.retain(|r| r.id != id);
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.potions.remove_remote_thrown(id);
|
||||
self.end.remove_remote_eye(id);
|
||||
}
|
||||
|
||||
/// Every one a server sent (a new connection, level or dimension).
|
||||
@@ -203,6 +230,8 @@ impl Game<'_> {
|
||||
self.tridents.clear();
|
||||
self.rockets.clear();
|
||||
self.crystals.clear();
|
||||
self.potions.clear_thrown();
|
||||
self.end.clear_remote_eyes();
|
||||
}
|
||||
|
||||
/// Their kind and place, for `status`.
|
||||
@@ -225,6 +254,12 @@ impl Game<'_> {
|
||||
for p in &self.creatures.projectiles {
|
||||
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position)}));
|
||||
}
|
||||
for (kind, p, _) in self.potions.thrown_entities() {
|
||||
out.push(json!({"kind": kind, "position": at(p)}));
|
||||
}
|
||||
for (id, p) in self.end.eye_positions() {
|
||||
out.push(json!({"id": id, "kind": "minecraft:eye_of_ender", "position": p}));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,22 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerScoreboard`'s packets: each placed player's client is sent
|
||||
/// what changed on the board since it was last sent it (all of it on
|
||||
/// joining), lan/scoreboard.rs.
|
||||
pub(super) fn lan_sync_scoreboard(&mut self, host: &mut super::lan::LanHost) {
|
||||
if host.hub.guests.iter().all(|g| !g.placed) {
|
||||
return;
|
||||
}
|
||||
self.load_scoreboard();
|
||||
let view = horizon_client::lan::scoreboard::View::of(&self.score.board);
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
if g.scoreboard != view {
|
||||
g.send_all(&g.scoreboard.changes(&view));
|
||||
g.scoreboard = view.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.doTick` score updates and the statistic criteria.
|
||||
pub(super) fn tick_scoreboard(&mut self) {
|
||||
self.load_scoreboard();
|
||||
@@ -563,19 +579,42 @@ impl Game<'_> {
|
||||
|
||||
// ---- the sidebar -----------------------------------------------------------
|
||||
|
||||
/// `Hud.extractScoreboardSidebar` / `displayScoreboardSidebar`.
|
||||
pub(super) fn draw_scoreboard(&self, batch: &mut HudBatch) {
|
||||
/// The objective in the player's team's sidebar slot, else the sidebar's.
|
||||
fn sidebar_objective(&self) -> Option<&sb::Objective> {
|
||||
let board = &self.score.board;
|
||||
if board.display.is_empty() {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
// A server's client is in its team by its profile name.
|
||||
let name = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map_or(PLAYER_NAME, |r| r.profile_name.as_str());
|
||||
let team_slot = board
|
||||
.players_team(PLAYER_NAME)
|
||||
.players_team(name)
|
||||
.and_then(|t| t.color.as_ref())
|
||||
.map(|c| format!("sidebar.team.{c}"))
|
||||
.and_then(|slot| board.display_objective(&slot));
|
||||
let Some(objective) = team_slot.or_else(|| board.display_objective(sb::SLOT_SIDEBAR))
|
||||
else {
|
||||
team_slot.or_else(|| board.display_objective(sb::SLOT_SIDEBAR))
|
||||
}
|
||||
/// The sidebar the HUD shows: its objective and its lines, for `status`.
|
||||
pub(super) fn sidebar_status(&self) -> serde_json::Value {
|
||||
let board = &self.score.board;
|
||||
let Some(objective) = self.sidebar_objective() else {
|
||||
return serde_json::Value::Null;
|
||||
};
|
||||
let scores: serde_json::Map<String, serde_json::Value> = board
|
||||
.scores
|
||||
.iter()
|
||||
.filter_map(|(holder, s)| Some((holder.clone(), s.get(&objective.name)?.value.into())))
|
||||
.collect();
|
||||
serde_json::json!({ "objective": objective.name, "title": objective.display_text().plain(), "scores": scores })
|
||||
}
|
||||
|
||||
/// `Hud.extractScoreboardSidebar` / `displayScoreboardSidebar`.
|
||||
pub(super) fn draw_scoreboard(&self, batch: &mut HudBatch) {
|
||||
let board = &self.score.board;
|
||||
let Some(objective) = self.sidebar_objective() else {
|
||||
return;
|
||||
};
|
||||
let draws = hud_overlays::sidebar(
|
||||
|
||||
@@ -1491,6 +1491,13 @@ impl Renderer {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let (atlas_pixels, atlas_width, atlas_height) = hud_atlas;
|
||||
let limit = device.limits().max_texture_dimension_2d;
|
||||
if atlas_width.max(atlas_height) > limit {
|
||||
return Err(format!(
|
||||
"the shared atlas is {atlas_width}x{atlas_height}; this GPU allows {limit}x{limit}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let atlas = device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
@@ -2456,10 +2463,6 @@ impl Renderer {
|
||||
view_formats: &config.view_formats,
|
||||
})
|
||||
}
|
||||
/// Rendering to a texture instead of the window (`--offscreen`).
|
||||
pub fn is_offscreen(&self) -> bool {
|
||||
self.surface.is_none()
|
||||
}
|
||||
/// Release the window surface before another renderer uses the same window.
|
||||
pub fn release_surface(&mut self) {
|
||||
self.surface = None;
|
||||
|
||||
@@ -119,10 +119,6 @@ impl Tile {
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
pub fn resident_lods(&self) -> usize {
|
||||
self.lods.len()
|
||||
}
|
||||
|
||||
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh) {
|
||||
self.remove_lod(key);
|
||||
if let Some(bounds) = Bounds::mesh(mesh)
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::collections::BTreeMap;
|
||||
|
||||
/// `ReputationEventType` amounts.
|
||||
pub const REPUTATION_CHANGE_PER_EVENT: i32 = 25;
|
||||
pub const REPUTATION_CHANGE_PER_EVERLASTING_MEMORY: i32 = 20;
|
||||
pub const REPUTATION_CHANGE_PER_TRADE: i32 = 2;
|
||||
/// `GossipContainer.DISCARD_THRESHOLD`: values below this are forgotten.
|
||||
const DISCARD_THRESHOLD: i32 = 2;
|
||||
|
||||
@@ -486,14 +486,6 @@ impl Item {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Caption and value text, split from the label ("Caption: Value").
|
||||
pub fn caption_value(self, s: &Settings) -> (String, String) {
|
||||
let label = self.label(s).plain();
|
||||
match label.split_once(": ") {
|
||||
Some((c, v)) => (c.to_owned(), v.to_owned()),
|
||||
None => (label, String::new()),
|
||||
}
|
||||
}
|
||||
pub fn slider(self, s: &Settings) -> Option<f32> {
|
||||
match self {
|
||||
Item::Vanilla(o) => o.slider_position(s),
|
||||
|
||||
@@ -9,6 +9,11 @@ use std::{
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// A sprite's atlas region, and its size in GUI pixels: the size of the
|
||||
/// default resources' texture of that name, whatever the resolution of the
|
||||
/// pack that replaced it (vanilla's GUI code draws in those units: `blit`
|
||||
/// divides by the texture size it is given, sprites by their `gui.scaling`
|
||||
/// size). Textures the default resources lack keep their own size.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Rect {
|
||||
pub u0: f32,
|
||||
@@ -18,6 +23,15 @@ pub struct Rect {
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
impl Rect {
|
||||
/// The region's size in texels of an atlas this large.
|
||||
pub fn texels(&self, (width, height): (u32, u32)) -> (u32, u32) {
|
||||
(
|
||||
((self.u1 - self.u0) * width as f32).round() as u32,
|
||||
((self.v1 - self.v0) * height as f32).round() as u32,
|
||||
)
|
||||
}
|
||||
}
|
||||
/// `TextureMetadataSection`'s `blur` and `clamp`: how vanilla samples a
|
||||
/// standalone texture (`ReloadableTexture.setSampler`: linear rather than
|
||||
/// nearest filtering, clamp-to-edge rather than repeat addressing).
|
||||
@@ -54,6 +68,9 @@ pub struct HudAtlas {
|
||||
}
|
||||
/// Atlas rows reserved for glyphs rasterized at run time.
|
||||
pub const GLYPH_ROWS: u32 = 512;
|
||||
/// The largest atlas side: the 2D texture limit of the GPUs Horizon runs on
|
||||
/// (Apple silicon, RADV and Intel allow 16384; NVIDIA 32768).
|
||||
pub const MAX_ATLAS_SIZE: u32 = 16384;
|
||||
/// 128x128 atlas slots for map textures. A lobby paints its signs as walls of
|
||||
/// framed maps, so the pool holds a wall; too few slots re-upload every map
|
||||
/// every frame. 26.3's `MapTextureManager` keeps a texture per map instead.
|
||||
@@ -89,6 +106,15 @@ pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
}
|
||||
Ok((info.width, info.height, rgba))
|
||||
}
|
||||
/// A PNG's width and height, from its header alone.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
let mut header = [0u8; 24];
|
||||
fs::File::open(path).ok()?.read_exact(&mut header).ok()?;
|
||||
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
|
||||
.then(|| (field(16), field(20)))
|
||||
}
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
@@ -201,8 +227,14 @@ impl HudAtlas {
|
||||
(g.rect, g.advance)
|
||||
}
|
||||
pub fn build(root: &Path) -> Result<Self> {
|
||||
Self::build_over(root, root)
|
||||
}
|
||||
/// The atlas of a resource root that layers packs over `defaults` (the
|
||||
/// game's own resources), whose textures give the sprites' GUI sizes.
|
||||
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
|
||||
Self::pack(
|
||||
root,
|
||||
defaults,
|
||||
&[
|
||||
"gui",
|
||||
"item",
|
||||
@@ -265,14 +297,53 @@ impl HudAtlas {
|
||||
/// holding its textures. The pixels are dropped once the glyph widths are
|
||||
/// read, since nothing uploads them.
|
||||
pub fn fonts(root: &Path) -> Result<Self> {
|
||||
let mut atlas = Self::pack(root, &["font"], false)?;
|
||||
let mut atlas = Self::pack(root, root, &["font"], false)?;
|
||||
atlas.pixels = Vec::new();
|
||||
Ok(atlas)
|
||||
}
|
||||
/// Shelf placements of `images` (tallest first) in the narrowest atlas
|
||||
/// width from 4096 up that keeps the atlas, glyph rows included, within
|
||||
/// `max` texels a side; and the first row free for glyphs. High-resolution
|
||||
/// packs fill a 4096-wide atlas past what GPUs allow (Faithful 64x needs
|
||||
/// 32768 rows); vanilla's stitcher fails the reload when its atlases don't
|
||||
/// fit, and the packs are then turned off (`Minecraft.clearResourcePacksOnError`).
|
||||
fn layout(images: &[PackedImage], max: u32) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
let mut width = 4096.min(max);
|
||||
loop {
|
||||
let mut placements = Vec::with_capacity(images.len());
|
||||
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
|
||||
for (_, w, h, _) in images {
|
||||
if x + w + 1 > width {
|
||||
x = 0;
|
||||
y += row + 1;
|
||||
row = 0;
|
||||
}
|
||||
placements.push((x, y));
|
||||
x += w + 1;
|
||||
row = row.max(*h);
|
||||
}
|
||||
let glyph_region = y + row + 1;
|
||||
let wide_enough = images.iter().all(|i| i.1 < width);
|
||||
if wide_enough && glyph_region + GLYPH_ROWS <= max {
|
||||
return Ok((width, placements, glyph_region));
|
||||
}
|
||||
if width >= max {
|
||||
let texels: u64 = images.iter().map(|i| u64::from(i.1) * u64::from(i.2)).sum();
|
||||
return Err(format!(
|
||||
"the textures ({} images, {texels} texels) do not fit a {max}x{max} atlas",
|
||||
images.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
width = (width * 2).min(max);
|
||||
}
|
||||
}
|
||||
/// Pack the textures under these folders of `textures/`, and for the
|
||||
/// `full` atlas the generated sprites and the run-time slots too.
|
||||
fn pack(root: &Path, folders: &[&str], full: bool) -> Result<Self> {
|
||||
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
|
||||
let textures = root.join("assets/minecraft/textures");
|
||||
let default_textures =
|
||||
(defaults != root).then(|| defaults.join("assets/minecraft/textures"));
|
||||
let mut files = Vec::new();
|
||||
for folder in folders {
|
||||
walk(&textures.join(folder), &mut files);
|
||||
@@ -281,7 +352,8 @@ impl HudAtlas {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
});
|
||||
let decode_one = |path: &PathBuf| -> Option<(PackedImage, Option<TextureFlags>)> {
|
||||
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>);
|
||||
let decode_one = |path: &PathBuf| -> Option<Decoded> {
|
||||
let name = path
|
||||
.strip_prefix(&textures)
|
||||
.ok()?
|
||||
@@ -289,18 +361,29 @@ impl HudAtlas {
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let (w, mut h, pixels) = decode(path).ok()?;
|
||||
let is_frame_strip = (name.starts_with("block/") || name.starts_with("item/")) && h > w;
|
||||
let pixels = if is_frame_strip {
|
||||
// Animated strips keep their first frame.
|
||||
let frames = name.starts_with("block/") || name.starts_with("item/");
|
||||
let pixels = if frames && h > w {
|
||||
h = w;
|
||||
pixels[..(w * w * 4) as usize].to_vec()
|
||||
} else {
|
||||
pixels
|
||||
};
|
||||
// The default texture's size, when a pack's differs from it.
|
||||
let gui_size = default_textures
|
||||
.as_ref()
|
||||
.and_then(|d| png_size(&d.join(format!("{name}.png"))))
|
||||
.map(|(dw, dh)| (dw, if frames { dh.min(dw) } else { dh }))
|
||||
.filter(|&size| size != (w, h));
|
||||
let flags = fs::read_to_string(path.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|json| TextureFlags::from_mcmeta(&json));
|
||||
(w <= 1024 && (h <= 512 || name.starts_with("font/")))
|
||||
.then_some(((name, w, h, pixels), flags))
|
||||
let (gw, gh) = gui_size.unwrap_or((w, h));
|
||||
(gw <= 1024 && (gh <= 512 || name.starts_with("font/"))).then_some((
|
||||
(name, w, h, pixels),
|
||||
flags,
|
||||
gui_size,
|
||||
))
|
||||
};
|
||||
let threads = std::thread::available_parallelism().map_or(2, |n| n.get());
|
||||
let chunk = files.len().div_ceil(threads).max(1);
|
||||
@@ -317,11 +400,15 @@ impl HudAtlas {
|
||||
.collect()
|
||||
});
|
||||
let mut flags = HashMap::new();
|
||||
let mut gui_sizes = HashMap::new();
|
||||
let mut images: Vec<PackedImage> = Vec::with_capacity(decoded.len());
|
||||
for (image, image_flags) in decoded {
|
||||
for (image, image_flags, gui_size) in decoded {
|
||||
if let Some(f) = image_flags {
|
||||
flags.insert(image.0.clone(), f);
|
||||
}
|
||||
if let Some(size) = gui_size {
|
||||
gui_sizes.insert(image.0.clone(), size);
|
||||
}
|
||||
images.push(image);
|
||||
}
|
||||
if full {
|
||||
@@ -351,21 +438,10 @@ impl HudAtlas {
|
||||
}
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let width = 4096u32;
|
||||
let mut placements = Vec::with_capacity(images.len());
|
||||
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
|
||||
for (_, w, h, _) in &images {
|
||||
if x + w + 1 > width {
|
||||
x = 0;
|
||||
y += row + 1;
|
||||
row = 0;
|
||||
}
|
||||
placements.push((x, y));
|
||||
x += w + 1;
|
||||
row = row.max(*h);
|
||||
}
|
||||
let glyph_region = y + row + 1;
|
||||
let height = (glyph_region + GLYPH_ROWS).next_power_of_two();
|
||||
let (width, placements, glyph_region) = Self::layout(&images, MAX_ATLAS_SIZE)?;
|
||||
// A multiple of 1024 rather than a power of two: the default resources
|
||||
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
|
||||
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
|
||||
let mut pixels = vec![0u8; (width * height * 4) as usize];
|
||||
let mut sprites = HashMap::new();
|
||||
for ((name, w, h, data), (px, py)) in images.iter().zip(placements) {
|
||||
@@ -376,6 +452,7 @@ impl HudAtlas {
|
||||
}
|
||||
let b = u32::from(bordered.contains(name));
|
||||
let (px, py, w, h) = (px + b, py + b, w - 2 * b, h - 2 * b);
|
||||
let (gw, gh) = gui_sizes.get(name).copied().unwrap_or((w, h));
|
||||
sprites.insert(
|
||||
name.clone(),
|
||||
Rect {
|
||||
@@ -383,8 +460,8 @@ impl HudAtlas {
|
||||
v0: py as f32 / height as f32,
|
||||
u1: (px + w) as f32 / width as f32,
|
||||
v1: (py + h) as f32 / height as f32,
|
||||
w,
|
||||
h,
|
||||
w: gw,
|
||||
h: gh,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -404,14 +481,15 @@ impl HudAtlas {
|
||||
.find(|a| a.layer == layer)
|
||||
.cloned()
|
||||
{
|
||||
let image = animation.resized_pixels(animation.frame(0), rect.w, rect.h);
|
||||
let (tw, th) = rect.texels((width, height));
|
||||
let image = animation.resized_pixels(animation.frame(0), tw, th);
|
||||
let x = (rect.u0 * width as f32).round() as usize;
|
||||
let y = (rect.v0 * height as f32).round() as usize;
|
||||
for row in 0..rect.h as usize {
|
||||
let row_bytes = tw as usize * 4;
|
||||
for row in 0..th as usize {
|
||||
let dst = ((y + row) * width as usize + x) * 4;
|
||||
let src = row * rect.w as usize * 4;
|
||||
pixels[dst..dst + rect.w as usize * 4]
|
||||
.copy_from_slice(&image[src..src + rect.w as usize * 4]);
|
||||
let src = row * row_bytes;
|
||||
pixels[dst..dst + row_bytes].copy_from_slice(&image[src..src + row_bytes]);
|
||||
}
|
||||
fire_animations.push((rect, animation));
|
||||
}
|
||||
@@ -783,24 +861,6 @@ impl HudBatch {
|
||||
};
|
||||
self.text_styled(atlas, &t, x, y, color, shadow)
|
||||
}
|
||||
/// `text_styled` under a pose: GUI positions scaled by `scale`, then
|
||||
/// moved to `origin` (Matrix3x2fStack translate + scale).
|
||||
pub fn text_posed(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
at: [f32; 2],
|
||||
color: u32,
|
||||
scale: f32,
|
||||
origin: [f32; 2],
|
||||
) -> f32 {
|
||||
let start = self.vertices.len();
|
||||
let advance = self.text_styled(atlas, text, at[0], at[1], color, true);
|
||||
for v in &mut self.vertices[start..] {
|
||||
v.pos = [origin[0] + v.pos[0] * scale, origin[1] + v.pos[1] * scale];
|
||||
}
|
||||
advance
|
||||
}
|
||||
/// Font.drawInternal: styled glyphs, shadows first, with bold, italic,
|
||||
/// underline and strikethrough. `color` is the default colour and alpha.
|
||||
pub fn text_styled(
|
||||
@@ -1162,7 +1222,8 @@ impl HudRenderer {
|
||||
if frame == *previous {
|
||||
continue;
|
||||
}
|
||||
let pixels = animation.resized_pixels(frame, rect.w, rect.h);
|
||||
let (w, h) = rect.texels((texture.width(), texture.height()));
|
||||
let pixels = animation.resized_pixels(frame, w, h);
|
||||
uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
@@ -1175,8 +1236,8 @@ impl HudRenderer {
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
rect.w,
|
||||
rect.h,
|
||||
w,
|
||||
h,
|
||||
&pixels,
|
||||
);
|
||||
*previous = frame;
|
||||
@@ -1340,6 +1401,87 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `count` images of `w x h`; layout reads only their sizes.
|
||||
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
|
||||
(0..count)
|
||||
.map(|i| (format!("i{i}"), w, h, Vec::new()))
|
||||
.collect()
|
||||
}
|
||||
fn assert_inside(images: &[PackedImage], width: u32, placements: &[(u32, u32)], free: u32) {
|
||||
for ((_, w, h, _), (x, y)) in images.iter().zip(placements) {
|
||||
assert!(
|
||||
x + w <= width && y + h < free,
|
||||
"{x},{y} {w}x{h} outside {width}x{free}"
|
||||
);
|
||||
}
|
||||
}
|
||||
fn write_png(root: &Path, name: &str, w: u32, h: u32) {
|
||||
let path = root.join(format!("assets/minecraft/textures/{name}.png"));
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels = vec![255; (w * h * 4) as usize];
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn a_pack_texture_keeps_the_default_textures_gui_size() {
|
||||
let temp = std::env::temp_dir().join(format!("horizon-gui-size-{}", std::process::id()));
|
||||
let (defaults, pack) = (temp.join("defaults"), temp.join("pack"));
|
||||
write_png(&defaults, "gui/container/inventory", 256, 256);
|
||||
write_png(&defaults, "gui/sprites/widget/button", 200, 20);
|
||||
write_png(&defaults, "block/stone", 16, 16);
|
||||
write_png(&defaults, "item/compass", 16, 512);
|
||||
// Faithful 64x's inventory (past the 512 rows a texture may have),
|
||||
// 32x's button and stone, an animated strip, and a texture of its own.
|
||||
write_png(&pack, "gui/container/inventory", 1024, 1024);
|
||||
write_png(&pack, "gui/sprites/widget/button", 400, 40);
|
||||
write_png(&pack, "block/stone", 32, 32);
|
||||
write_png(&pack, "item/compass", 32, 1024);
|
||||
write_png(&pack, "gui/sprites/pack_only", 30, 10);
|
||||
let atlas = HudAtlas::build_over(&pack, &defaults).unwrap();
|
||||
let _ = fs::remove_dir_all(&temp);
|
||||
let size = (atlas.width, atlas.height);
|
||||
for (name, gui, texels) in [
|
||||
("gui/container/inventory", (256, 256), (1024, 1024)),
|
||||
("gui/sprites/widget/button", (200, 20), (400, 40)),
|
||||
("block/stone", (16, 16), (32, 32)),
|
||||
("item/compass", (16, 16), (32, 32)),
|
||||
("gui/sprites/pack_only", (30, 10), (30, 10)),
|
||||
] {
|
||||
let r = atlas.sprites[name];
|
||||
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn the_default_textures_keep_a_4096_wide_atlas() {
|
||||
let images = sized(400, 64, 64);
|
||||
let (width, placements, free) = HudAtlas::layout(&images, MAX_ATLAS_SIZE).unwrap();
|
||||
assert_eq!(width, 4096);
|
||||
assert_inside(&images, width, &placements, free);
|
||||
}
|
||||
#[test]
|
||||
fn a_high_resolution_pack_widens_the_atlas_to_fit_the_gpu() {
|
||||
// 34 rows of three 1024x512 GUI textures: 17442 rows at 4096 wide,
|
||||
// past the 16384 GPUs allow (Faithful 64x needed 32768).
|
||||
let images = sized(100, 1024, 512);
|
||||
let (width, placements, free) = HudAtlas::layout(&images, MAX_ATLAS_SIZE).unwrap();
|
||||
assert_eq!(width, 8192);
|
||||
assert!(free + GLYPH_ROWS <= MAX_ATLAS_SIZE);
|
||||
assert_inside(&images, width, &placements, free);
|
||||
}
|
||||
#[test]
|
||||
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
|
||||
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
|
||||
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
|
||||
// One image wider than the largest atlas.
|
||||
assert!(HudAtlas::layout(&sized(1, 2048, 16), 2048).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrapped_border_repeats_the_opposite_edges() {
|
||||
// 2x2 texels a b / c d, one byte each in red.
|
||||
|
||||
@@ -124,17 +124,6 @@ impl ItemRenderState {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.layers.is_empty()
|
||||
}
|
||||
/// `pickParticleMaterial`: a random layer's particle sprite.
|
||||
pub fn pick_particle(
|
||||
&self,
|
||||
random: &mut impl horizon_core::random::RandomSource,
|
||||
) -> Option<String> {
|
||||
if self.layers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let i = random.next_int_bound(self.layers.len() as i32).unwrap_or(0) as usize;
|
||||
self.layers.get(i).map(|l| l.particle.clone())
|
||||
}
|
||||
/// `usesBlockLight` of the first layer (GUI lighting choice).
|
||||
pub fn uses_block_light(&self) -> bool {
|
||||
self.layers.first().is_some_and(|l| l.uses_block_light)
|
||||
|
||||
@@ -236,6 +236,24 @@ mod tests {
|
||||
assert_eq!(vendor_name(0, "Apple M4 Pro"), "Apple");
|
||||
}
|
||||
|
||||
/// wgpu is given the WGSL text: it must parse again.
|
||||
fn reparse_error(pack: &JavaPasses) -> Option<String> {
|
||||
let passes = pack
|
||||
.folders
|
||||
.iter()
|
||||
.flat_map(|f| f.passes.iter().chain(&f.final_pass));
|
||||
passes
|
||||
.flat_map(|p| [(p, &p.vertex, "vsh"), (p, &p.fragment, "fsh")])
|
||||
.find_map(|(p, t, ext)| {
|
||||
let e = naga::front::wgsl::parse_str(&t.wgsl).err()?;
|
||||
Some(format!(
|
||||
"{}.{ext}: {}",
|
||||
p.pass.program,
|
||||
e.emit_to_string(&t.wgsl)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The packs in `HORIZON_TEST_SHADERPACKS` (folders or zips) load as the
|
||||
/// game loads them, with their default options: the compatibility table
|
||||
/// of docs/shader-packs.md. Skipped when the variable isn't set.
|
||||
@@ -255,6 +273,10 @@ mod tests {
|
||||
for path in entries {
|
||||
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
||||
match load(&path, &BTreeMap::new(), "Apple", "Apple M4 Pro") {
|
||||
Ok(pack) if let Some(e) = reparse_error(&pack) => {
|
||||
println!("{name}: {e}");
|
||||
failures.push(name);
|
||||
}
|
||||
Ok(pack) => {
|
||||
let folders: Vec<String> = pack
|
||||
.folders
|
||||
|
||||
@@ -295,15 +295,6 @@ impl KeyBinds {
|
||||
self.keys.insert(n, key.to_owned());
|
||||
}
|
||||
}
|
||||
/// Load `key_<name>` from options.txt; false for unknown mappings.
|
||||
pub fn set_saved(&mut self, name: &str, key: &str) -> bool {
|
||||
if MAPPINGS.iter().any(|m| m.0 == name) {
|
||||
self.set(name, key);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
pub fn saved(&self) -> Vec<(&'static str, String)> {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
|
||||
@@ -104,8 +104,6 @@ pub const SNEAK: usize = 0;
|
||||
pub const SPRINT: usize = 1;
|
||||
/// Attack (mouse).
|
||||
pub const ATTACK: usize = 2;
|
||||
/// Use (mouse).
|
||||
pub const USE: usize = 3;
|
||||
|
||||
/// The `ToggleKeyMapping`s `Options` builds: sneak and sprint (keyboard,
|
||||
/// restored after a screen), attack (mouse, restored) and use (mouse, not
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! changed since (`getAttributesToSync`), to the trackers and the player itself.
|
||||
use horizon_protocol::packets::play::{AttributeModifier, AttributeSnapshot};
|
||||
use horizon_world::attributes::{
|
||||
Instance, Operation,
|
||||
Instance, Modifier, Operation,
|
||||
entity::{AttributeId, EntityAttributes},
|
||||
live::LiveAttributes,
|
||||
};
|
||||
@@ -70,6 +70,48 @@ pub fn take_dirty<G: FnMut() -> i32>(map: &mut LiveAttributes<G>) -> Vec<Attribu
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleUpdateAttributes`: each instance the server
|
||||
/// synced takes its base and exactly its modifiers (`removeModifiers`, then
|
||||
/// `addTransientModifier` for each). An attribute the map doesn't know is
|
||||
/// skipped, as vanilla warns and skips it.
|
||||
pub fn apply<G: FnMut() -> i32>(map: &mut LiveAttributes<G>, values: &[AttributeSnapshot]) {
|
||||
for s in values {
|
||||
let Some(key) = map
|
||||
.keys()
|
||||
.iter()
|
||||
.find(|k| k.attribute().registry_id() == s.attribute)
|
||||
.cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(Some(handle)) = map.get_instance(Some(key)) else {
|
||||
continue;
|
||||
};
|
||||
let modifiers: Vec<_> = s
|
||||
.modifiers
|
||||
.iter()
|
||||
.filter_map(|m| Modifier::new(&m.id, m.amount, operation(m.operation)).ok())
|
||||
.collect();
|
||||
let _ = map.edit(&handle, |instance, observer| {
|
||||
instance.set_base_value(s.base, observer)?;
|
||||
instance.remove_all(observer)?;
|
||||
for m in modifiers {
|
||||
instance.add_transient(m, observer)?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `AttributeModifier.Operation.BY_ID`.
|
||||
fn operation(id: i32) -> Operation {
|
||||
match id {
|
||||
1 => Operation::AddMultipliedBase,
|
||||
2 => Operation::AddMultipliedTotal,
|
||||
_ => Operation::AddValue,
|
||||
}
|
||||
}
|
||||
|
||||
/// The client-syncable instances of a generic attribute map.
|
||||
pub fn generic_syncable(map: &EntityAttributes) -> impl Iterator<Item = AttributeSnapshot> + '_ {
|
||||
let catalog = map.catalog();
|
||||
|
||||
@@ -15,9 +15,33 @@ use std::{
|
||||
/// GameType ids.
|
||||
pub const SURVIVAL: i32 = 0;
|
||||
pub const CREATIVE: i32 = 1;
|
||||
pub const ADVENTURE: i32 = 2;
|
||||
pub const SPECTATOR: i32 = 3;
|
||||
|
||||
/// The body state a player's file keeps that lives on `PlayerMotion`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SavedBody {
|
||||
pub fire_ticks: i32,
|
||||
pub air: i32,
|
||||
pub fall_distance: f64,
|
||||
}
|
||||
|
||||
impl SavedBody {
|
||||
/// The body state of `motion`, to save.
|
||||
pub fn of(motion: &PlayerMotion) -> Self {
|
||||
Self {
|
||||
fire_ticks: motion.fire_ticks,
|
||||
air: motion.air,
|
||||
fall_distance: motion.fall_distance,
|
||||
}
|
||||
}
|
||||
/// Put it on a new body.
|
||||
pub fn apply(self, motion: &mut PlayerMotion) {
|
||||
motion.fire_ticks = self.fire_ticks;
|
||||
motion.air = self.air;
|
||||
motion.fall_distance = self.fall_distance;
|
||||
}
|
||||
}
|
||||
|
||||
/// An effect as last sent in ClientboundUpdateMobEffectPacket.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SentEffect {
|
||||
@@ -133,6 +157,9 @@ pub struct Guest {
|
||||
pub tracker: Tracker,
|
||||
// Player state the gameplay code reads and writes.
|
||||
pub motion: Option<Box<PlayerMotion>>,
|
||||
/// What the player's file says of the body (`Entity.load`: the fire
|
||||
/// and air ticks and the fall distance), for the first body it gets.
|
||||
pub loaded_body: Option<SavedBody>,
|
||||
/// The hurt and attack logs of the body it had before it respawned,
|
||||
/// until the new body takes them over (the control socket's `hurtlog`
|
||||
/// reads across a death).
|
||||
@@ -215,6 +242,8 @@ pub struct Guest {
|
||||
pub advancements_loaded: bool,
|
||||
pub advancements_visible: Vec<bool>,
|
||||
pub advancements_changed: std::collections::BTreeSet<usize>,
|
||||
/// The server's scoreboard as this player's client has it (lan/scoreboard.rs).
|
||||
pub scoreboard: super::scoreboard::View,
|
||||
/// Recipes unlocked since the client was last told
|
||||
/// (`ServerRecipeBook.addRecipes` sends them).
|
||||
pub recipes_unlocked: Vec<String>,
|
||||
@@ -282,6 +311,7 @@ impl Guest {
|
||||
batch_quota: 0.0,
|
||||
tracker: Tracker::default(),
|
||||
motion: None,
|
||||
loaded_body: None,
|
||||
logs_kept: None,
|
||||
inventory: vec![None; 36],
|
||||
armor: [None, None, None, None],
|
||||
@@ -326,6 +356,7 @@ impl Guest {
|
||||
advancements_loaded: false,
|
||||
advancements_visible: Vec::new(),
|
||||
advancements_changed: Default::default(),
|
||||
scoreboard: Default::default(),
|
||||
recipes_unlocked: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,13 +172,6 @@ pub struct Hub {
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(JavaString::from(s))
|
||||
}
|
||||
pub fn yellow_translation(key: &str, args: &[&str]) -> Tag {
|
||||
let mut t = translatable(key, args);
|
||||
if let Tag::Compound(map) = &mut t {
|
||||
map.insert(JavaString::from("color"), string("yellow"));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Player.getDisplayName for a player in no team: the name, suggesting
|
||||
/// `/tell NAME `, showing the player on hover and inserting the name.
|
||||
@@ -1360,21 +1353,6 @@ impl Hub {
|
||||
(!batch.is_empty()).then_some(batch)
|
||||
}
|
||||
|
||||
/// Resend a chunk to everyone who has it (a block entity changed, relight).
|
||||
pub fn resend_chunk(&mut self, world: &World, x: i32, z: i32) {
|
||||
let Some(chunk) = world.chunks.get(&(x, z)) else {
|
||||
return;
|
||||
};
|
||||
let body = self.encoder.body_in(chunk, world);
|
||||
for g in self
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.sent_chunks.contains(&(x, z)))
|
||||
{
|
||||
g.sender.send(body.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChunkHolder.broadcastChanges`' light part: the light sections that
|
||||
/// changed in a column go to the players for whom it is on the border of
|
||||
/// what they track (`ChunkMap.isChunkOnTrackedBorder`); the others light
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod hub;
|
||||
pub mod menu;
|
||||
pub mod playerdata;
|
||||
mod recipes;
|
||||
pub mod scoreboard;
|
||||
pub mod tracking;
|
||||
pub mod waypoints;
|
||||
|
||||
@@ -272,9 +273,6 @@ impl Shared {
|
||||
pub fn max_players(&self) -> usize {
|
||||
self.max_players.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn set_max_players(&self, n: usize) {
|
||||
self.max_players.store(n, Ordering::Relaxed);
|
||||
}
|
||||
pub fn release(&self, uuid: u128) {
|
||||
self.online.lock().unwrap().retain(|(u, _)| *u != uuid);
|
||||
}
|
||||
@@ -513,9 +511,6 @@ impl Lan {
|
||||
pub fn reserve(&self, uuid: u128) {
|
||||
self.shared.reserved.lock().unwrap().insert(uuid);
|
||||
}
|
||||
pub fn set_motd(&self, motd: &str) {
|
||||
*self.shared.motd.lock().unwrap() = motd.to_owned();
|
||||
}
|
||||
pub fn stop(&mut self) {
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
self.stop_listening();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Guests' saved state: `players/data/<uuid>.dat` in the vanilla player
|
||||
//! compound, through the same code that saves the host.
|
||||
use super::guest::Guest;
|
||||
use super::guest::{Guest, SavedBody};
|
||||
use crate::{
|
||||
items::ItemStack,
|
||||
save::{Level, read_dat, write_dat},
|
||||
@@ -80,6 +80,17 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
|
||||
guest.respawn_elsewhere = level.respawn_elsewhere.clone();
|
||||
guest.saved = level.player_extra.clone();
|
||||
let extra = level.player_extra.as_ref();
|
||||
let short = |key: &str, default: i64| {
|
||||
extra
|
||||
.and_then(|t| crate::save::number(t.get(key)))
|
||||
.unwrap_or(default) as i32
|
||||
};
|
||||
guest.loaded_body = Some(SavedBody {
|
||||
fire_ticks: short("Fire", -20),
|
||||
air: short("Air", 300),
|
||||
fall_distance: level.fall_distance,
|
||||
});
|
||||
guest.recipe_book = level.recipe_book.clone();
|
||||
guest.dimension = match level.dimension.as_str() {
|
||||
d @ ("minecraft:the_nether" | "minecraft:the_end") => d.to_owned(),
|
||||
@@ -151,6 +162,24 @@ pub fn store(guest: &Guest, level: &mut Level) {
|
||||
level.respawn_elsewhere = guest.respawn_elsewhere.clone();
|
||||
level.on_ground = guest.on_ground;
|
||||
level.player_extra = guest.saved.clone();
|
||||
// The live body's fire, air and fall distance (Entity.saveWithoutId),
|
||||
// or what the file had while the player has no body yet.
|
||||
let body = guest
|
||||
.motion
|
||||
.as_deref()
|
||||
.map(SavedBody::of)
|
||||
.or(guest.loaded_body);
|
||||
if let Some(body) = body {
|
||||
level.fall_distance = body.fall_distance;
|
||||
let extra = level
|
||||
.player_extra
|
||||
.get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default()));
|
||||
if let horizon_world::nbt::Tag::Compound(map) = extra {
|
||||
let short = |v: i32| horizon_world::nbt::Tag::Short(v.clamp(-32768, 32767) as i16);
|
||||
map.insert("Fire".into(), short(body.fire_ticks));
|
||||
map.insert("Air".into(), short(body.air));
|
||||
}
|
||||
}
|
||||
level.recipe_book = guest.recipe_book.clone();
|
||||
level.ender_items = guest.ender_items.clone();
|
||||
level.inventory.clear();
|
||||
@@ -174,3 +203,52 @@ pub fn store(guest: &Guest, level: &mut Level) {
|
||||
put(100, &guest.armor[3]);
|
||||
put(150, &guest.offhand);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lan::{Joined, PlayerSender};
|
||||
use horizon_protocol::packets::configuration::ClientInformation;
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
fn guest() -> Guest {
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 2,
|
||||
name: "Alex".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(8),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
},
|
||||
2,
|
||||
[0.0, 64.0, 0.0],
|
||||
8,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_burn_and_fall_come_back() {
|
||||
// A player saved burning in mid-fall (#1323, #1327): Entity.saveWithoutId's
|
||||
// Fire, Air and fall_distance, read back for the next body.
|
||||
let mut g = guest();
|
||||
g.loaded_body = Some(SavedBody {
|
||||
fire_ticks: 96,
|
||||
air: 250,
|
||||
fall_distance: 9.5,
|
||||
});
|
||||
let mut level = Level::default();
|
||||
store(&g, &mut level);
|
||||
let tag = level.player_tag();
|
||||
let mut read = Level::default();
|
||||
read.read_player(&tag);
|
||||
read.player_extra = Some(tag);
|
||||
let mut back = guest();
|
||||
apply(&mut back, &read);
|
||||
assert_eq!(back.loaded_body, g.loaded_body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! `ServerScoreboard`: what a player's client knows of the server's
|
||||
//! scoreboard. Every team goes to every player; an objective goes once it
|
||||
//! sits in a display slot (`startTrackingObjective`: its objective, its
|
||||
//! slots, then its scores) and leaves when it sits in none
|
||||
//! (`stopTrackingObjective`). A joining player gets it all
|
||||
//! (`PlayerList.updateEntireScoreboard`).
|
||||
//!
|
||||
//! A server's commands change the board in place; each tick a player is
|
||||
//! sent the packets that turn what it was last sent into what it should
|
||||
//! have now, which are the ones `ServerScoreboard`'s callbacks send.
|
||||
use crate::scoreboard::{NumberFormat, RenderType, Scoreboard, component_tag, display_slots};
|
||||
use crate::text::COLORS;
|
||||
use horizon_protocol::packets::play::{
|
||||
self, Clientbound as C, ResetScore, SetDisplayObjective, SetObjective, SetPlayerTeam, SetScore,
|
||||
TeamParameters,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
type Format = Option<play::NumberFormat>;
|
||||
|
||||
/// The scoreboard as one player's client has it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct View {
|
||||
/// The tracked objectives: display name, render type (1 hearts), format.
|
||||
objectives: BTreeMap<String, (Tag, i32, Format)>,
|
||||
/// `DisplaySlot` id to objective.
|
||||
display: BTreeMap<i32, String>,
|
||||
/// The tracked objectives' scores, by holder and objective.
|
||||
scores: BTreeMap<(String, String), (i32, Option<Tag>, Format)>,
|
||||
/// Every team: its parameters and players, in the team's order.
|
||||
teams: BTreeMap<String, (TeamParameters, Vec<String>)>,
|
||||
}
|
||||
|
||||
fn format(f: Option<&NumberFormat>) -> Format {
|
||||
f.map(|f| match f {
|
||||
NumberFormat::Blank => play::NumberFormat::Blank,
|
||||
NumberFormat::Styled(s) => play::NumberFormat::Styled(crate::scoreboard::json_to_tag(s)),
|
||||
NumberFormat::Fixed(c) => play::NumberFormat::Fixed(component_tag(c)),
|
||||
})
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// What the board's state gives a client.
|
||||
pub fn of(board: &Scoreboard) -> Self {
|
||||
let slots = display_slots();
|
||||
let display: BTreeMap<i32, String> = board
|
||||
.display
|
||||
.iter()
|
||||
.filter_map(|(slot, o)| Some((slots.iter().position(|s| s == slot)? as i32, o.clone())))
|
||||
.collect();
|
||||
let tracked: BTreeSet<&String> = display.values().collect();
|
||||
let objectives = board
|
||||
.objectives
|
||||
.iter()
|
||||
.filter(|o| tracked.contains(&o.name))
|
||||
.map(|o| {
|
||||
let render = i32::from(o.render_type == RenderType::Hearts);
|
||||
(
|
||||
o.name.clone(),
|
||||
(
|
||||
component_tag(&o.display_name),
|
||||
render,
|
||||
format(o.number_format.as_ref()),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let scores = board
|
||||
.scores
|
||||
.iter()
|
||||
.flat_map(|(holder, scores)| {
|
||||
scores
|
||||
.iter()
|
||||
.filter(|(o, _)| tracked.contains(o))
|
||||
.map(move |(o, s)| {
|
||||
(
|
||||
(holder.clone(), o.clone()),
|
||||
(
|
||||
s.value,
|
||||
s.display.as_ref().map(component_tag),
|
||||
format(s.number_format.as_ref()),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let teams = board
|
||||
.teams
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let params = TeamParameters {
|
||||
display_name: component_tag(&t.display_name),
|
||||
prefix: component_tag(&t.prefix),
|
||||
suffix: component_tag(&t.suffix),
|
||||
name_tag_visibility: t.nametag_visibility as i32,
|
||||
collision_rule: t.collision_rule as i32,
|
||||
color: t
|
||||
.color
|
||||
.as_deref()
|
||||
.and_then(|c| COLORS.iter().position(|(_, n, _)| *n == c))
|
||||
.map(|i| i as i32),
|
||||
options: i8::from(t.allow_friendly_fire)
|
||||
| (i8::from(t.see_friendly_invisibles) << 1),
|
||||
};
|
||||
(
|
||||
t.name.clone(),
|
||||
(params, t.players.iter().cloned().collect()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
objectives,
|
||||
display,
|
||||
scores,
|
||||
teams,
|
||||
}
|
||||
}
|
||||
|
||||
/// The packets that take a client from `self` to `next`.
|
||||
pub fn changes(&self, next: &View) -> Vec<C> {
|
||||
let mut out = Vec::new();
|
||||
let team = |name: &str, method, parameters, players| {
|
||||
C::SetPlayerTeam(SetPlayerTeam {
|
||||
name: name.to_owned(),
|
||||
method,
|
||||
parameters,
|
||||
players,
|
||||
})
|
||||
};
|
||||
for name in self.teams.keys().filter(|n| !next.teams.contains_key(*n)) {
|
||||
out.push(team(name, 1, None, Vec::new()));
|
||||
}
|
||||
for (name, (params, players)) in &next.teams {
|
||||
let Some((was, had)) = self.teams.get(name) else {
|
||||
out.push(team(name, 0, Some(params.clone()), players.clone()));
|
||||
continue;
|
||||
};
|
||||
if was != params {
|
||||
out.push(team(name, 2, Some(params.clone()), Vec::new()));
|
||||
}
|
||||
let joined: Vec<_> = players
|
||||
.iter()
|
||||
.filter(|p| !had.contains(p))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !joined.is_empty() {
|
||||
out.push(team(name, 3, None, joined));
|
||||
}
|
||||
let left: Vec<_> = had
|
||||
.iter()
|
||||
.filter(|p| !players.contains(p))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !left.is_empty() {
|
||||
out.push(team(name, 4, None, left));
|
||||
}
|
||||
}
|
||||
for (name, display) in &next.objectives {
|
||||
let method = match self.objectives.get(name) {
|
||||
None => 0,
|
||||
Some(was) if was != display => 2,
|
||||
Some(_) => continue,
|
||||
};
|
||||
out.push(C::SetObjective(SetObjective {
|
||||
name: name.clone(),
|
||||
method,
|
||||
display: Some(display.clone()),
|
||||
}));
|
||||
}
|
||||
let slots: BTreeSet<i32> = self
|
||||
.display
|
||||
.keys()
|
||||
.chain(next.display.keys())
|
||||
.copied()
|
||||
.collect();
|
||||
for slot in slots {
|
||||
let objective = next.display.get(&slot);
|
||||
if self.display.get(&slot) != objective {
|
||||
out.push(C::SetDisplayObjective(SetDisplayObjective {
|
||||
slot,
|
||||
objective: objective.cloned().unwrap_or_default(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
for ((holder, objective), (value, display, number_format)) in &next.scores {
|
||||
let key = (holder.clone(), objective.clone());
|
||||
if self.scores.get(&key) != Some(&(*value, display.clone(), number_format.clone())) {
|
||||
out.push(C::SetScore(SetScore {
|
||||
owner: holder.clone(),
|
||||
objective: objective.clone(),
|
||||
score: *value,
|
||||
display: display.clone(),
|
||||
number_format: number_format.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
for (holder, objective) in self.scores.keys() {
|
||||
let key = (holder.clone(), objective.clone());
|
||||
if !next.scores.contains_key(&key) && next.objectives.contains_key(objective) {
|
||||
out.push(C::ResetScore(ResetScore {
|
||||
owner: holder.clone(),
|
||||
objective: Some(objective.clone()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
for name in self
|
||||
.objectives
|
||||
.keys()
|
||||
.filter(|n| !next.objectives.contains_key(*n))
|
||||
{
|
||||
out.push(C::SetObjective(SetObjective {
|
||||
name: name.clone(),
|
||||
method: 1,
|
||||
display: None,
|
||||
}));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn methods(packets: &[C]) -> Vec<String> {
|
||||
packets
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
C::SetObjective(o) => format!("objective {} {}", o.name, o.method),
|
||||
C::SetDisplayObjective(d) => format!("display {} {}", d.slot, d.objective),
|
||||
C::SetScore(s) => format!("score {} {} {}", s.owner, s.objective, s.score),
|
||||
C::ResetScore(r) => format!("reset {} {:?}", r.owner, r.objective),
|
||||
C::SetPlayerTeam(t) => format!("team {} {} {:?}", t.name, t.method, t.players),
|
||||
_ => "other".into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shown_objective_goes_with_its_scores_and_leaves_when_hidden() {
|
||||
let mut board = Scoreboard::default();
|
||||
board.add_objective(
|
||||
"audit",
|
||||
"dummy",
|
||||
json!("Audit"),
|
||||
RenderType::Integer,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
board.score_mut("Alpha", "audit").value = 7;
|
||||
let empty = View::default();
|
||||
// Not displayed: not tracked, nothing sent.
|
||||
let hidden = View::of(&board);
|
||||
assert!(empty.changes(&hidden).is_empty());
|
||||
board.set_display_objective("sidebar", Some("audit"));
|
||||
let shown = View::of(&board);
|
||||
assert_eq!(
|
||||
methods(&hidden.changes(&shown)),
|
||||
[
|
||||
"objective audit 0",
|
||||
"display 1 audit",
|
||||
"score Alpha audit 7"
|
||||
]
|
||||
);
|
||||
board.score_mut("Alpha", "audit").value = 12;
|
||||
board.score_mut("Beta", "audit").value = 3;
|
||||
board.reset_one("Beta", "audit");
|
||||
let changed = View::of(&board);
|
||||
assert_eq!(methods(&shown.changes(&changed)), ["score Alpha audit 12"]);
|
||||
board.reset_one("Alpha", "audit");
|
||||
let reset = View::of(&board);
|
||||
assert_eq!(
|
||||
methods(&changed.changes(&reset)),
|
||||
["reset Alpha Some(\"audit\")"]
|
||||
);
|
||||
board.set_display_objective("sidebar", None);
|
||||
assert_eq!(
|
||||
methods(&reset.changes(&View::of(&board))),
|
||||
["display 1 ", "objective audit 1"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teams_go_to_everyone_with_their_players() {
|
||||
let mut board = Scoreboard::default();
|
||||
board.add_team("red");
|
||||
board.add_to_team("Alpha", "red");
|
||||
let first = View::of(&board);
|
||||
assert_eq!(
|
||||
methods(&View::default().changes(&first)),
|
||||
["team red 0 [\"Alpha\"]"]
|
||||
);
|
||||
board.add_to_team("Beta", "red");
|
||||
board.remove_from_team("Alpha");
|
||||
let second = View::of(&board);
|
||||
assert_eq!(
|
||||
methods(&first.changes(&second)),
|
||||
["team red 3 [\"Beta\"]", "team red 4 [\"Alpha\"]"]
|
||||
);
|
||||
board.remove_team("red");
|
||||
assert_eq!(
|
||||
methods(&second.changes(&View::of(&board))),
|
||||
["team red 1 []"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,12 +77,6 @@ pub fn apply_deprecated(map: &mut HashMap<String, String>, json: &[u8]) {
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub fn from_map(code: &str, map: HashMap<String, String>) -> Self {
|
||||
Self {
|
||||
code: code.to_owned(),
|
||||
map,
|
||||
}
|
||||
}
|
||||
/// ClientLanguage.loadFrom: en_us first, then the selected language, each
|
||||
/// from every namespace and every source in priority order (later sources
|
||||
/// override).
|
||||
|
||||
@@ -203,7 +203,7 @@ impl LightTables {
|
||||
Ok(tables)
|
||||
}
|
||||
/// Assembled tables (tests and benchmarks): derives the packed light cache.
|
||||
#[allow(dead_code)]
|
||||
#[cfg(test)]
|
||||
fn from_parts(cells: Vec<Cell>, occludes: Vec<bool>, faces: usize) -> Self {
|
||||
let mut tables = Self {
|
||||
cells,
|
||||
|
||||
@@ -239,10 +239,6 @@ pub enum ArmPose {
|
||||
/// Raising a trident to throw it (or to launch with Riptide).
|
||||
ThrowTrident,
|
||||
}
|
||||
/// HumanoidModel.setupAnim for piglins: `PiglinModel` poses the arms itself.
|
||||
pub fn humanoid_item(p: &mut Poses, s: &State, _holding: bool) {
|
||||
humanoid(p, s, &Hum::new(ArmPose::Empty, ArmPose::Empty));
|
||||
}
|
||||
/// HumanoidModel.setupAnim with empty hands (endermen).
|
||||
pub fn humanoid_pub(p: &mut Poses, s: &State, _m: &Mob) {
|
||||
humanoid(p, s, &Hum::new(ArmPose::Empty, ArmPose::Empty));
|
||||
|
||||
@@ -152,10 +152,6 @@ pub fn protector(heart: &mut CreakingHeart, host: &impl HeartHost) -> Option<u32
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `isProtector`.
|
||||
pub fn is_protector(heart: &mut CreakingHeart, host: &impl HeartHost, id: u32) -> bool {
|
||||
protector(heart, host) == Some(id)
|
||||
}
|
||||
fn distance_to_creaking(heart: &mut CreakingHeart, pos: [i32; 3], host: &impl HeartHost) -> f64 {
|
||||
protector(heart, host)
|
||||
.and_then(|id| host.mob(id))
|
||||
|
||||
@@ -76,9 +76,6 @@ pub struct GoalSelector {
|
||||
locked: [Option<usize>; 4],
|
||||
pub disabled: u8,
|
||||
}
|
||||
fn flag_index(f: u8) -> usize {
|
||||
f.trailing_zeros() as usize
|
||||
}
|
||||
impl GoalSelector {
|
||||
pub fn add(&mut self, priority: i32, goal: impl Goal + 'static) {
|
||||
self.goals.push(Wrapped {
|
||||
@@ -174,10 +171,6 @@ impl GoalSelector {
|
||||
}
|
||||
self.locked = [None; 4];
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
fn locked_by(&self, flag: u8) -> Option<usize> {
|
||||
self.locked[flag_index(flag)]
|
||||
}
|
||||
}
|
||||
|
||||
// --- random positions (DefaultRandomPos / LandRandomPos / RandomPos) -------
|
||||
@@ -565,36 +558,6 @@ impl Mob {
|
||||
m.land_up_out_of_solid(w, p)
|
||||
})
|
||||
}
|
||||
/// LandRandomPos.getPosTowards
|
||||
pub fn land_random_pos_towards(
|
||||
&mut self,
|
||||
w: &MobWorld,
|
||||
h: i32,
|
||||
v: i32,
|
||||
to: [f64; 3],
|
||||
) -> Option<[f64; 3]> {
|
||||
let (xd, zd) = (to[0] - self.position.x, to[2] - self.position.z);
|
||||
self.pick_best(w, |m| {
|
||||
let d = m.random_direction_within(
|
||||
0.0,
|
||||
f64::from(h),
|
||||
v,
|
||||
0,
|
||||
xd,
|
||||
zd,
|
||||
std::f64::consts::FRAC_PI_2,
|
||||
)?;
|
||||
let p = m.toward(f64::from(h), d);
|
||||
if p[1] < crate::chunk::MIN_Y
|
||||
|| p[1] >= 320
|
||||
|| m.restricted(f64::from(h), p)
|
||||
|| !m.stable_destination(w, p)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
m.land_up_out_of_solid(w, p)
|
||||
})
|
||||
}
|
||||
pub fn target_info(&self, w: &MobWorld) -> Option<TargetInfo> {
|
||||
self.ai.target.and_then(|t| w.target_info(t))
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ use horizon_core::{
|
||||
// Happy ghast state: `head` the harness, `scratch[0]` 1 once set up as a
|
||||
// baby and 2 as an adult, `ai.home` the restriction, `counter`
|
||||
// the end tick of the follow-an-adult range pick.
|
||||
pub const BABY_SCALE: f64 = 0.2375;
|
||||
/// `Attributes.FLYING_SPEED` and the speed `travel` uses (`* 5 / 3`).
|
||||
pub const FLYING_SPEED: f64 = 0.05;
|
||||
pub const AIR_SPEED: f32 = 0.05 * 5.0 / 3.0;
|
||||
|
||||
@@ -250,10 +250,6 @@ pub fn nearest_players(m: &Mob) -> Vec<Who> {
|
||||
.and_then(|b| b.mem.nearest_players.value())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// The damage the brain remembers (`HURT_BY`).
|
||||
pub fn hurt_by_present(m: &Mob) -> bool {
|
||||
brain(m).is_some_and(|b| b.mem.hurt_by.present())
|
||||
}
|
||||
/// `HURT_BY`'s damage source entity, if a living one.
|
||||
fn hurt_by_entity(m: &Mob, w: &MobWorld) -> Option<Who> {
|
||||
let t = brain(m)?.mem.hurt_by.value()?;
|
||||
|
||||
@@ -76,14 +76,6 @@ pub fn finalize(m: &mut Mob, warm_ocean: bool) {
|
||||
m.extra.variant = u8::from(warm_ocean);
|
||||
}
|
||||
}
|
||||
/// `checkNautilusSpawnRules`: 5 to 25 blocks below sea level, water below
|
||||
/// and above.
|
||||
pub fn spawn_rules_ok(w: &MobWorld, x: i32, y: i32, z: i32) -> bool {
|
||||
let sea = 63;
|
||||
(sea - 25..=sea - 5).contains(&y)
|
||||
&& w.fluid(x, y - 1, z) == Fluid::Water
|
||||
&& w.block_name(x, y + 1, z) == "minecraft:water"
|
||||
}
|
||||
/// `getControllingPassenger`: a player on a saddled nautilus.
|
||||
pub fn controlled(m: &Mob) -> bool {
|
||||
m.rider.is_some() && m.extra.saddled
|
||||
|
||||
@@ -582,26 +582,4 @@ impl Mob {
|
||||
NavKind::Amphibious => !w.facts.is_air(w.world.block(p[0], p[1] - 1, p[2])),
|
||||
}
|
||||
}
|
||||
pub fn should_recompute_path(&self, p: [i32; 3]) -> bool {
|
||||
if self.ai.nav.delayed_recompute {
|
||||
return false;
|
||||
}
|
||||
let Some(path) = &self.ai.nav.path else {
|
||||
return false;
|
||||
};
|
||||
if path.is_done() || path.nodes.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(end) = path.end() else { return false };
|
||||
let mid = [
|
||||
(f64::from(end.x) + self.position.x) / 2.0,
|
||||
(f64::from(end.y) + self.position.y) / 2.0,
|
||||
(f64::from(end.z) + self.position.z) / 2.0,
|
||||
];
|
||||
let d = (f64::from(p[0]) + 0.5 - mid[0]).powi(2)
|
||||
+ (f64::from(p[1]) + 0.5 - mid[1]).powi(2)
|
||||
+ (f64::from(p[2]) + 0.5 - mid[2]).powi(2);
|
||||
let r = (path.nodes.len() - path.next) as f64;
|
||||
d < r * r
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn love_ticks_survive_a_save() {
|
||||
// Animal.addAdditionalSaveData / readAdditionalSaveData: "InLove" (#1396).
|
||||
let mut m = rabbit();
|
||||
m.extra.in_love = 570;
|
||||
let tag = crate::entity_nbt::mob_to_nbt(&m, [0, 0, 0, 1], None);
|
||||
assert_eq!(tag.get("InLove"), Some(&horizon_world::nbt::Tag::Int(570)));
|
||||
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
|
||||
let back = crate::entity_nbt::mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!(back.extra.in_love, 570);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landing_waits_then_real_hop_clock_moves_the_model() {
|
||||
let mut m = rabbit();
|
||||
|
||||
@@ -769,19 +769,6 @@ pub fn weighted_pick(list: &[SpawnEntry], random: &mut impl RandomSource) -> Opt
|
||||
None
|
||||
}
|
||||
|
||||
/// `NaturalSpawner.getRandomSpawnMobAt`.
|
||||
pub fn random_spawn_mob_at(
|
||||
list: &[SpawnEntry],
|
||||
category: &str,
|
||||
reduced_water_ambient: bool,
|
||||
random: &mut impl RandomSource,
|
||||
) -> Option<SpawnEntry> {
|
||||
if category == "water_ambient" && reduced_water_ambient && random.next_float() < 0.98 {
|
||||
return None;
|
||||
}
|
||||
weighted_pick(list, random)
|
||||
}
|
||||
|
||||
/// `SpawnerData.count().sample(random)`: a constant draws nothing.
|
||||
pub fn sample_count(entry: &SpawnEntry, random: &mut impl RandomSource) -> u32 {
|
||||
if entry.constant {
|
||||
|
||||
@@ -30,25 +30,6 @@ pub struct BiomeFacts {
|
||||
pub fn placement_ok(species: Species, w: &MobWorld, x: i32, y: i32, z: i32) -> bool {
|
||||
super::spawn_rules::is_spawn_position_ok(species, w, x, y, z)
|
||||
}
|
||||
/// `SpawnPlacements.checkSpawnRules` for a natural spawn.
|
||||
pub fn spawn_rules_ok(
|
||||
species: Species,
|
||||
w: &MobWorld,
|
||||
random: &mut impl RandomSource,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
) -> bool {
|
||||
super::spawn_rules::check_spawn_rules(
|
||||
species,
|
||||
w,
|
||||
super::spawn_rules::Reason::Natural,
|
||||
random,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
)
|
||||
}
|
||||
/// Mob.finalizeSpawn for natural, chunk-generation and summoned mobs.
|
||||
/// `DifficultyInstance`: the local difficulty at a position.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! other `GateBehavior`s shuffle with a random of their own, seeded with
|
||||
//! `Brain::shuffle_seed` when the brain is built.
|
||||
use super::{
|
||||
Mob, MobEvent, MobWorld, Seen, Species, Who,
|
||||
Mob, MobEvent, MobWorld, Species, Who,
|
||||
path::{Path, PathNode},
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -4156,10 +4156,6 @@ pub fn woken(m: &mut Mob, now: i64) {
|
||||
pub fn potential_job_site(m: &Mob) -> Option<[i32; 3]> {
|
||||
mem_ref(m).and_then(|mm| mm.potential_job_site.value())
|
||||
}
|
||||
/// Whether a seen mob is a villager wanting to breed (`AgeableMob::canBreed`).
|
||||
pub fn seen_can_breed(s: &Seen) -> bool {
|
||||
s.species == Species::Villager && s.in_love
|
||||
}
|
||||
/// The memories other villagers read: the interaction target, the path's
|
||||
/// nodes (while it is not done), whether a golem was seen lately and the
|
||||
/// last sleep.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! What mobs read from the level: block solidity, ray clipping for line of
|
||||
//! sight (`ClipContext.Block.COLLIDER`), sky visibility, light-dependent
|
||||
//! values and the positions of other entities.
|
||||
use super::{MobWorld, Seen, Species, Who, data};
|
||||
use super::{MobWorld, Seen, Who, data};
|
||||
use crate::{
|
||||
blocks::{BlockFacts, Fluid},
|
||||
collisions::SceneCollisions,
|
||||
@@ -269,12 +269,6 @@ impl MobWorld<'_> {
|
||||
});
|
||||
v.into_iter()
|
||||
}
|
||||
pub fn count_species(&self, species: Species) -> usize {
|
||||
self.others
|
||||
.iter()
|
||||
.filter(|s| s.species == species && s.alive)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Level.clip with VISUAL shapes and no fluids: the block the segment first
|
||||
|
||||
@@ -337,6 +337,7 @@ impl PlayerMotion {
|
||||
self.air = extra
|
||||
.and_then(|t| crate::save::number(t.get("Air")))
|
||||
.unwrap_or(300) as i32;
|
||||
self.fall_distance = level.fall_distance;
|
||||
self.set_health(level.health, false)?;
|
||||
// LivingEntity.readAdditionalSaveData: internalSetAbsorptionAmount.
|
||||
self.internal_set_absorption(level.absorption)?;
|
||||
|
||||
@@ -804,11 +804,6 @@ impl SelectionModel {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Whether activating the entry asks first (`handlePackSelection`'s
|
||||
/// ConfirmScreen for an incompatible pack).
|
||||
pub fn needs_confirmation(&self, selected: bool, i: usize) -> bool {
|
||||
self.can_select(selected, i) && !self.unselected[i].compatible
|
||||
}
|
||||
/// `moveUp` / `moveDown` when allowed.
|
||||
pub fn move_by(&mut self, selected: bool, i: usize, up: bool) -> bool {
|
||||
let ok = if up {
|
||||
|
||||
@@ -1158,6 +1158,8 @@ pub struct ParticleEngine {
|
||||
starters: Vec<Starter>,
|
||||
/// Local sounds requested by particles (dripstone drips): (event, position, volume, pitch).
|
||||
pub sounds: Vec<(&'static str, [f64; 3], f32, f32)>,
|
||||
/// How many of each type were made, for `horizon-ctl status`.
|
||||
made: std::collections::BTreeMap<&'static str, u64>,
|
||||
/// The item particles' sprites (`BreakingItemParticle`'s slime ball, snowball
|
||||
/// and cobweb models, resolved for the ground), one per model layer.
|
||||
pub item_sprites: HashMap<ParticleType, Vec<String>>,
|
||||
@@ -1183,6 +1185,7 @@ impl ParticleEngine {
|
||||
starters: Vec::new(),
|
||||
camera: [0.0; 3],
|
||||
sounds: Vec::new(),
|
||||
made: Default::default(),
|
||||
item_sprites: HashMap::new(),
|
||||
};
|
||||
let dir = root.join("assets/minecraft/particles");
|
||||
@@ -1449,9 +1452,14 @@ impl ParticleEngine {
|
||||
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
|
||||
let seed = self.seeds.next_long();
|
||||
if let Some(particle) = self.make(options, p, v, seed) {
|
||||
*self.made.entry(options.ty.name()).or_default() += 1;
|
||||
self.pending.push(particle);
|
||||
}
|
||||
}
|
||||
/// How many particles of each type were made so far.
|
||||
pub fn made(&self) -> &std::collections::BTreeMap<&'static str, u64> {
|
||||
&self.made
|
||||
}
|
||||
/// Parity probes: seed the provider (`ParticleEngine.random`), level and
|
||||
/// `SpellParticle.RANDOM` generators.
|
||||
pub fn seed_randoms(&mut self, provider: i64, level: i64, spell: i64) {
|
||||
|
||||
@@ -384,21 +384,6 @@ impl Book {
|
||||
order,
|
||||
}
|
||||
}
|
||||
/// Which book a recipe id belongs to.
|
||||
pub fn book_of(&self, id: &str) -> Option<BookType> {
|
||||
let e = self.entries.iter().find(|e| e.id == id)?;
|
||||
Some(match e.category {
|
||||
Category::CraftingBuilding
|
||||
| Category::CraftingRedstone
|
||||
| Category::CraftingEquipment
|
||||
| Category::CraftingMisc => BookType::Crafting,
|
||||
Category::FurnaceFood | Category::FurnaceBlocks | Category::FurnaceMisc => {
|
||||
BookType::Furnace
|
||||
}
|
||||
Category::BlastBlocks | Category::BlastMisc => BookType::BlastFurnace,
|
||||
Category::SmokerFood => BookType::Smoker,
|
||||
})
|
||||
}
|
||||
/// `RecipeDisplayEntry.canCraft`: the recipe's placement ingredients
|
||||
/// (`craftingRequirements`) can all be found in `stock`.
|
||||
pub fn can_craft(&self, entry: &Entry, stock: &crate::menu::place::Stacked) -> bool {
|
||||
|
||||
@@ -750,12 +750,6 @@ fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag {
|
||||
)
|
||||
}))
|
||||
}
|
||||
/// The heightmaps a ClientboundLevelChunkPacketData carries (the types with
|
||||
/// sendToClient: WORLD_SURFACE, MOTION_BLOCKING, MOTION_BLOCKING_NO_LEAVES),
|
||||
/// as (network id, packed longs) in Heightmap.Types order.
|
||||
pub fn client_heightmaps(chunk: &Chunk, tables: &SaveTables) -> Vec<(i32, Vec<i64>)> {
|
||||
client_heightmaps_in(&chunk.sections, tables, MIN_Y, SECTIONS as i32 * 16)
|
||||
}
|
||||
/// The client heightmaps of a column's sections, in a level from `min_y`,
|
||||
/// `height` blocks high.
|
||||
pub fn client_heightmaps_in(
|
||||
|
||||
@@ -78,7 +78,6 @@ pub const RESONANCE: [&str; 15] = [
|
||||
];
|
||||
pub const SHRIEK: &str = "minecraft:shriek";
|
||||
pub const TENDRILS_CLICKING: &str = "minecraft:sculk_sensor_tendrils_clicking";
|
||||
pub const NO_VIBRATION_FREQUENCY: i32 = 0;
|
||||
|
||||
/// `VibrationSystem.getGameEventFrequency`.
|
||||
pub fn frequency(event: &str) -> i32 {
|
||||
@@ -567,10 +566,6 @@ pub struct WardenSpawnTracker {
|
||||
}
|
||||
impl WardenSpawnTracker {
|
||||
pub const MAX_WARNING_LEVEL: i32 = 4;
|
||||
/// Blocks around a shrieker within which players are warned.
|
||||
pub const PLAYER_SEARCH_RADIUS: f64 = 16.0;
|
||||
/// `AABB.ofSize(centre, 48, 48, 48)` for an existing warden.
|
||||
pub const WARNING_CHECK_DIAMETER: f64 = 48.0;
|
||||
const DECREASE_EVERY: i32 = 12000;
|
||||
const INCREASE_COOLDOWN: i32 = 200;
|
||||
pub fn tick(&mut self) {
|
||||
|
||||
@@ -81,10 +81,6 @@ pub fn sound_source(event: &str) -> usize {
|
||||
index("master")
|
||||
}
|
||||
}
|
||||
/// Volume multiplier of an event's source (master applied separately).
|
||||
pub fn event_volume(event: &str) -> f32 {
|
||||
source_volume(sound_source(event))
|
||||
}
|
||||
/// Volume multiplier of a sound source by index.
|
||||
pub fn source_volume(i: usize) -> f32 {
|
||||
if i == 0 {
|
||||
|
||||
@@ -317,9 +317,6 @@ impl TabList {
|
||||
self.visible = visible;
|
||||
}
|
||||
}
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.visible
|
||||
}
|
||||
/// `reset`.
|
||||
pub fn reset(&mut self) {
|
||||
self.header = None;
|
||||
|
||||
@@ -455,9 +455,6 @@ impl Text {
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn char_count(&self) -> usize {
|
||||
self.spans.iter().map(|s| s.text.chars().count()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// The style fields of a component's JSON.
|
||||
|
||||
@@ -1630,26 +1630,6 @@ fn caret_field<'a>(
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `yyyy/MM/dd HH:mm` (UTC) for a modification time in Unix seconds.
|
||||
pub fn date_label(secs: u64) -> String {
|
||||
date_string(secs)
|
||||
}
|
||||
fn date_string(secs: u64) -> String {
|
||||
// Civil date from Unix time (days-from-civil inverse), UTC.
|
||||
let days = (secs / 86400) as i64;
|
||||
let (h, m) = ((secs % 86400) / 3600, (secs % 3600) / 60);
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if mo <= 2 { y + 1 } else { y };
|
||||
format!("{y}-{mo:02}-{d:02} {h:02}:{m:02}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod classic_tests {
|
||||
|
||||
+257
-76
@@ -7,9 +7,40 @@ use crate::{
|
||||
};
|
||||
use std::{collections::HashMap, path::Path, sync::RwLock, time::Instant};
|
||||
|
||||
/// Nine-slice borders (left, top, right, bottom) from `.png.mcmeta` files.
|
||||
static NINE_SLICE: RwLock<Option<HashMap<String, [u32; 4]>>> = RwLock::new(None);
|
||||
/// `GuiSpriteScaling.NineSlice`: the sprite's size in GUI pixels, whatever
|
||||
/// its texture's resolution, its borders (left, top, right, bottom) in the
|
||||
/// same units, and whether the edges and centre stretch rather than tile.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct NineSlice {
|
||||
width: u32,
|
||||
height: u32,
|
||||
border: [u32; 4],
|
||||
stretch_inner: bool,
|
||||
}
|
||||
/// Nine-slice scaling from `.png.mcmeta` files.
|
||||
static NINE_SLICE: RwLock<Option<HashMap<String, NineSlice>>> = RwLock::new(None);
|
||||
|
||||
/// A `gui.scaling` section of type `nine_slice`.
|
||||
fn parse_nine_slice(v: &serde_json::Value) -> Option<NineSlice> {
|
||||
let s = &v["gui"]["scaling"];
|
||||
if s["type"] != "nine_slice" {
|
||||
return None;
|
||||
}
|
||||
let size = |k: &str| s[k].as_u64().map(|n| n as u32);
|
||||
let border = &s["border"];
|
||||
let border = if let Some(n) = border.as_u64() {
|
||||
[n as u32; 4]
|
||||
} else {
|
||||
let g = |k: &str| border[k].as_u64().unwrap_or(0) as u32;
|
||||
[g("left"), g("top"), g("right"), g("bottom")]
|
||||
};
|
||||
Some(NineSlice {
|
||||
width: size("width")?,
|
||||
height: size("height")?,
|
||||
border,
|
||||
stretch_inner: s["stretch_inner"].as_bool().unwrap_or(false),
|
||||
})
|
||||
}
|
||||
/// Read `gui.scaling` metadata for every GUI sprite under `root`.
|
||||
pub fn load_metadata(root: &Path) {
|
||||
let mut map = HashMap::new();
|
||||
@@ -32,47 +63,48 @@ pub fn load_metadata(root: &Path) {
|
||||
let Ok(text) = std::fs::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let s = &v["gui"]["scaling"];
|
||||
if s["type"] != "nine_slice" {
|
||||
continue;
|
||||
}
|
||||
let border = &s["border"];
|
||||
let b = if let Some(n) = border.as_u64() {
|
||||
[n as u32; 4]
|
||||
} else {
|
||||
let g = |k: &str| border[k].as_u64().unwrap_or(0) as u32;
|
||||
[g("left"), g("top"), g("right"), g("bottom")]
|
||||
};
|
||||
let key = Path::new(stripped)
|
||||
.strip_prefix(&base)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
map.insert(key, b);
|
||||
map.insert(key, nine_slice);
|
||||
}
|
||||
}
|
||||
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map);
|
||||
}
|
||||
fn border(sprite: &str) -> [u32; 4] {
|
||||
/// The sprite's nine-slice metadata; without any (no resources loaded), its
|
||||
/// GUI size and the widgets' usual borders.
|
||||
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
|
||||
NINE_SLICE
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(sprite).copied())
|
||||
.unwrap_or(match sprite {
|
||||
"gui/sprites/widget/button"
|
||||
| "gui/sprites/widget/button_highlighted"
|
||||
| "gui/sprites/widget/button_disabled" => [3; 4],
|
||||
"gui/sprites/widget/slider_handle" | "gui/sprites/widget/slider_handle_highlighted" => {
|
||||
[2, 2, 2, 3]
|
||||
}
|
||||
_ => [1; 4],
|
||||
.unwrap_or(NineSlice {
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
border: match sprite {
|
||||
"gui/sprites/widget/button"
|
||||
| "gui/sprites/widget/button_highlighted"
|
||||
| "gui/sprites/widget/button_disabled" => [3; 4],
|
||||
"gui/sprites/widget/slider_handle"
|
||||
| "gui/sprites/widget/slider_handle_highlighted" => [2, 2, 2, 3],
|
||||
_ => [1; 4],
|
||||
},
|
||||
stretch_inner: false,
|
||||
})
|
||||
}
|
||||
fn sub(r: Rect, x: f32, y: f32, w: f32, h: f32) -> Rect {
|
||||
let (du, dv) = ((r.u1 - r.u0) / r.w as f32, (r.v1 - r.v0) / r.h as f32);
|
||||
/// The region `[x, y, w, h]` of a sprite `size` GUI pixels large, in its
|
||||
/// own units (`TextureAtlasSprite.getU(x / width)`).
|
||||
fn sub(r: Rect, size: [f32; 2], [x, y, w, h]: [f32; 4]) -> Rect {
|
||||
let (du, dv) = ((r.u1 - r.u0) / size[0], (r.v1 - r.v0) / size[1]);
|
||||
Rect {
|
||||
u0: r.u0 + x * du,
|
||||
v0: r.v0 + y * dv,
|
||||
@@ -82,8 +114,9 @@ fn sub(r: Rect, x: f32, y: f32, w: f32, h: f32) -> Rect {
|
||||
h: h as u32,
|
||||
}
|
||||
}
|
||||
/// Tile `src` (a sub-rectangle of a sprite, in its pixels) over a target area.
|
||||
fn tile(batch: &mut HudBatch, r: Rect, src: [f32; 4], dst: [f32; 4], color: u32) {
|
||||
/// `blitTiledSprite`: `src` (a region of a sprite `size` large, in its
|
||||
/// units) repeated over `dst` from its top left, the last row and column cut.
|
||||
fn tile(batch: &mut HudBatch, r: Rect, size: [f32; 2], src: [f32; 4], dst: [f32; 4], color: u32) {
|
||||
let [sx, sy, sw, sh] = src;
|
||||
let [dx, dy, dw, dh] = dst;
|
||||
if sw <= 0.0 || sh <= 0.0 || dw <= 0.0 || dh <= 0.0 {
|
||||
@@ -95,13 +128,16 @@ fn tile(batch: &mut HudBatch, r: Rect, src: [f32; 4], dst: [f32; 4], color: u32)
|
||||
let mut x = 0.0;
|
||||
while x < dw {
|
||||
let w = sw.min(dw - x);
|
||||
batch.rect_uv(dx + x, dy + y, w, h, sub(r, sx, sy, w, h), color);
|
||||
batch.rect_uv(dx + x, dy + y, w, h, sub(r, size, [sx, sy, w, h]), color);
|
||||
x += sw;
|
||||
}
|
||||
y += sh;
|
||||
}
|
||||
}
|
||||
/// GuiGraphics.blitSprite with nine-slice scaling (inner parts tiled).
|
||||
/// `GuiGraphicsExtractor.blitNineSlicedSprite`: the corners at their size,
|
||||
/// the edges and centre tiled (or stretched with `stretch_inner`), all in
|
||||
/// the sprite's `gui.scaling` units, so a high-resolution pack's sprite draws
|
||||
/// as the default one does.
|
||||
pub fn nine_slice(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
@@ -112,47 +148,73 @@ pub fn nine_slice(
|
||||
h: f32,
|
||||
color: u32,
|
||||
) {
|
||||
let Some(r) = atlas.sprites.get(sprite).copied() else {
|
||||
return;
|
||||
if let Some(r) = atlas.sprites.get(sprite) {
|
||||
draw_nine_slice(batch, *r, scaling(sprite, r), [x, y, w, h], color);
|
||||
}
|
||||
}
|
||||
fn draw_nine_slice(
|
||||
batch: &mut HudBatch,
|
||||
r: Rect,
|
||||
n: NineSlice,
|
||||
[x, y, w, h]: [f32; 4],
|
||||
color: u32,
|
||||
) {
|
||||
let size = [n.width as f32, n.height as f32];
|
||||
let [sw, sh] = size;
|
||||
let [l, t, rr, b] = n.border.map(|v| v as f32);
|
||||
let (half_w, half_h) = ((w / 2.0).floor(), (h / 2.0).floor());
|
||||
let (l, rr, t, b) = (l.min(half_w), rr.min(half_w), t.min(half_h), b.min(half_h));
|
||||
// blitSprite(sprite, width, height, textureX, textureY, x, y, w, h).
|
||||
let part = |batch: &mut HudBatch, tx: f32, ty: f32, dst: [f32; 4]| {
|
||||
if dst[2] != 0.0 && dst[3] != 0.0 {
|
||||
batch.rect_uv(
|
||||
dst[0],
|
||||
dst[1],
|
||||
dst[2],
|
||||
dst[3],
|
||||
sub(r, size, [tx, ty, dst[2], dst[3]]),
|
||||
color,
|
||||
);
|
||||
}
|
||||
};
|
||||
let [l, t, rr, b] = border(sprite).map(|v| v as f32);
|
||||
let (sw, sh) = (r.w as f32, r.h as f32);
|
||||
let l = l.min(w / 2.0);
|
||||
let rr = rr.min(w / 2.0);
|
||||
let t = t.min(h / 2.0);
|
||||
let b = b.min(h / 2.0);
|
||||
let (cw, ch) = (w - l - rr, h - t - b);
|
||||
let (scw, sch) = (sw - l - rr, sh - t - b);
|
||||
// Corners.
|
||||
batch.rect_uv(x, y, l, t, sub(r, 0.0, 0.0, l, t), color);
|
||||
batch.rect_uv(x + w - rr, y, rr, t, sub(r, sw - rr, 0.0, rr, t), color);
|
||||
batch.rect_uv(x, y + h - b, l, b, sub(r, 0.0, sh - b, l, b), color);
|
||||
batch.rect_uv(
|
||||
x + w - rr,
|
||||
y + h - b,
|
||||
rr,
|
||||
b,
|
||||
sub(r, sw - rr, sh - b, rr, b),
|
||||
color,
|
||||
);
|
||||
// Edges and centre.
|
||||
tile(batch, r, [l, 0.0, scw, t], [x + l, y, cw, t], color);
|
||||
tile(
|
||||
batch,
|
||||
r,
|
||||
[l, sh - b, scw, b],
|
||||
[x + l, y + h - b, cw, b],
|
||||
color,
|
||||
);
|
||||
tile(batch, r, [0.0, t, l, sch], [x, y + t, l, ch], color);
|
||||
tile(
|
||||
batch,
|
||||
r,
|
||||
[sw - rr, t, rr, sch],
|
||||
[x + w - rr, y + t, rr, ch],
|
||||
color,
|
||||
);
|
||||
tile(batch, r, [l, t, scw, sch], [x + l, y + t, cw, ch], color);
|
||||
// blitNineSliceInnerSegment.
|
||||
let inner = |batch: &mut HudBatch, dst: [f32; 4], src: [f32; 4]| {
|
||||
if dst[2] <= 0.0 || dst[3] <= 0.0 {
|
||||
return;
|
||||
}
|
||||
if n.stretch_inner {
|
||||
batch.rect_uv(dst[0], dst[1], dst[2], dst[3], sub(r, size, src), color);
|
||||
} else {
|
||||
tile(batch, r, size, src, dst, color);
|
||||
}
|
||||
};
|
||||
if w == sw && h == sh {
|
||||
part(batch, 0.0, 0.0, [x, y, w, h]);
|
||||
return;
|
||||
}
|
||||
if h == sh {
|
||||
part(batch, 0.0, 0.0, [x, y, l, h]);
|
||||
inner(batch, [x + l, y, w - rr - l, h], [l, 0.0, sw - rr - l, sh]);
|
||||
part(batch, sw - rr, 0.0, [x + w - rr, y, rr, h]);
|
||||
return;
|
||||
}
|
||||
if w == sw {
|
||||
part(batch, 0.0, 0.0, [x, y, w, t]);
|
||||
inner(batch, [x, y + t, w, h - b - t], [0.0, t, sw, sh - b - t]);
|
||||
part(batch, 0.0, sh - b, [x, y + h - b, w, b]);
|
||||
return;
|
||||
}
|
||||
let (cw, ch) = (w - rr - l, h - b - t);
|
||||
let (scw, sch) = (sw - rr - l, sh - b - t);
|
||||
part(batch, 0.0, 0.0, [x, y, l, t]);
|
||||
inner(batch, [x + l, y, cw, t], [l, 0.0, scw, t]);
|
||||
part(batch, sw - rr, 0.0, [x + w - rr, y, rr, t]);
|
||||
part(batch, 0.0, sh - b, [x, y + h - b, l, b]);
|
||||
inner(batch, [x + l, y + h - b, cw, b], [l, sh - b, scw, b]);
|
||||
part(batch, sw - rr, sh - b, [x + w - rr, y + h - b, rr, b]);
|
||||
inner(batch, [x, y + t, l, ch], [0.0, t, l, sch]);
|
||||
inner(batch, [x + l, y + t, cw, ch], [l, t, scw, sch]);
|
||||
inner(batch, [x + w - rr, y + t, rr, ch], [sw - rr, t, rr, sch]);
|
||||
}
|
||||
/// Repeat a whole texture with each copy drawn `size` GUI pixels square.
|
||||
pub fn tiled(
|
||||
@@ -169,7 +231,6 @@ pub fn tiled(
|
||||
let Some(r) = atlas.sprites.get(sprite).copied() else {
|
||||
return;
|
||||
};
|
||||
let scale = r.w as f32 / size;
|
||||
let mut yy = 0.0;
|
||||
while yy < h {
|
||||
let th = size.min(h - yy);
|
||||
@@ -181,7 +242,7 @@ pub fn tiled(
|
||||
y + yy,
|
||||
tw,
|
||||
th,
|
||||
sub(r, 0.0, 0.0, tw * scale, th * scale),
|
||||
sub(r, [size, size], [0.0, 0.0, tw, th]),
|
||||
color,
|
||||
);
|
||||
xx += size;
|
||||
@@ -793,10 +854,6 @@ impl EditBox {
|
||||
text_color: rgba(224, 224, 224, 255),
|
||||
}
|
||||
}
|
||||
/// When the field gained focus, for the cursor's blink.
|
||||
pub fn focused_since(&mut self, at: Instant) {
|
||||
self.focused_at = at;
|
||||
}
|
||||
pub fn inner_width(&self) -> f32 {
|
||||
if self.bordered {
|
||||
self.width - 8.0
|
||||
@@ -1128,6 +1185,130 @@ impl EditBox {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The quads' destination rectangles and their UVs.
|
||||
fn quads(batch: &HudBatch) -> Vec<([f32; 4], [f32; 4])> {
|
||||
batch
|
||||
.vertices
|
||||
.chunks(6)
|
||||
.map(|q| {
|
||||
let (a, c) = (q[0], q[2]);
|
||||
(
|
||||
[a.pos[0], a.pos[1], c.pos[0], c.pos[1]],
|
||||
[a.uv[0], a.uv[1], c.uv[0], c.uv[1]],
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Destination and UV rectangles equal to within f32 rounding.
|
||||
fn assert_quad(actual: ([f32; 4], [f32; 4]), expected: ([f32; 4], [f32; 4])) {
|
||||
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-5);
|
||||
assert!(
|
||||
close(actual.0, expected.0) && close(actual.1, expected.1),
|
||||
"{actual:?} != {expected:?}"
|
||||
);
|
||||
}
|
||||
/// A sprite filling the atlas, `w x h` texels as a 32x pack draws it.
|
||||
fn sprite(w: u32, h: u32) -> Rect {
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w,
|
||||
h,
|
||||
}
|
||||
}
|
||||
fn faithful(json: &str) -> NineSlice {
|
||||
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn a_high_resolution_button_slices_in_its_gui_units() {
|
||||
// Faithful 32x's widget/button: 400x40 texels, gui.scaling 200x20.
|
||||
let n = faithful(
|
||||
r#"{"gui":{"scaling":{"type":"nine_slice","width":200,"height":20,
|
||||
"border":{"left":20,"top":4,"right":20,"bottom":4}}}}"#,
|
||||
);
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_nine_slice(
|
||||
&mut batch,
|
||||
sprite(400, 40),
|
||||
n,
|
||||
[10.0, 5.0, 150.0, 20.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
// Full height: left border, the centre tiled, right border
|
||||
// (blitNineSlicedSprite's height == nineSlice.height case).
|
||||
assert_quad(q[0], ([10.0, 5.0, 30.0, 25.0], [0.0, 0.0, 0.1, 1.0]));
|
||||
assert_quad(q[1], ([30.0, 5.0, 140.0, 25.0], [0.1, 0.0, 0.65, 1.0]));
|
||||
assert_quad(q[2], ([140.0, 5.0, 160.0, 25.0], [0.9, 0.0, 1.0, 1.0]));
|
||||
assert_eq!(q.len(), 3);
|
||||
}
|
||||
#[test]
|
||||
fn nine_slice_corners_and_a_stretched_centre() {
|
||||
// Faithful 32x's tooltip/frame: 200x200 texels, 100x100, border 12, stretched.
|
||||
let n = faithful(
|
||||
r#"{"gui":{"scaling":{"type":"nine_slice","width":100,"height":100,"border":12,"stretch_inner":true}}}"#,
|
||||
);
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_nine_slice(
|
||||
&mut batch,
|
||||
sprite(200, 200),
|
||||
n,
|
||||
[0.0, 0.0, 300.0, 40.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 9);
|
||||
// Top left corner: 12 GUI pixels, 12/100 of the sprite.
|
||||
assert_quad(q[0], ([0.0, 0.0, 12.0, 12.0], [0.0, 0.0, 0.12, 0.12]));
|
||||
// The centre is one stretched quad (blitNineSliceInnerSegment, stretch_inner).
|
||||
assert_quad(q[7], ([12.0, 12.0, 288.0, 28.0], [0.12, 0.12, 0.88, 0.88]));
|
||||
// Bottom right corner.
|
||||
assert_quad(q[5], ([288.0, 28.0, 300.0, 40.0], [0.88, 0.88, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn a_sprite_drawn_at_its_own_size_is_one_quad() {
|
||||
let n = NineSlice {
|
||||
width: 50,
|
||||
height: 50,
|
||||
border: [10; 4],
|
||||
stretch_inner: false,
|
||||
};
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_nine_slice(
|
||||
&mut batch,
|
||||
sprite(100, 100),
|
||||
n,
|
||||
[4.0, 4.0, 50.0, 50.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 1);
|
||||
assert_quad(q[0], ([4.0, 4.0, 54.0, 54.0], [0.0, 0.0, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn borders_shrink_to_half_the_drawn_size() {
|
||||
let n = NineSlice {
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: [8; 4],
|
||||
stretch_inner: true,
|
||||
};
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
// 9 wide: borders of floor(9 / 2) = 4, as vanilla's integer division.
|
||||
draw_nine_slice(
|
||||
&mut batch,
|
||||
sprite(20, 20),
|
||||
n,
|
||||
[0.0, 0.0, 9.0, 30.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_quad(q[0], ([0.0, 0.0, 4.0, 8.0], [0.0, 0.0, 0.2, 0.4]));
|
||||
assert_quad(q[2], ([5.0, 0.0, 9.0, 8.0], [0.8, 0.0, 1.0, 0.4]));
|
||||
}
|
||||
#[test]
|
||||
fn append_fields_highlight_the_whole_value_and_blink_their_cursor() {
|
||||
use crate::hud::Blend;
|
||||
|
||||
@@ -7,7 +7,6 @@ pub use horizon_core::geometry::border::BorderStatus;
|
||||
|
||||
/// `WorldBorder.MAX_SIZE` (the float 5.999997E7 as a double).
|
||||
pub const MAX_SIZE: f64 = 59_999_968.0;
|
||||
pub const MAX_CENTER: f64 = 2.999_998_4e7;
|
||||
|
||||
/// `BorderStatus.getColor`: the wall colour.
|
||||
pub fn status_color(status: BorderStatus) -> u32 {
|
||||
|
||||
@@ -1,75 +1,35 @@
|
||||
//! Minimal read-only ZIP archive support (stored and deflated entries), used
|
||||
//! for resource packs and the unifont glyph archive.
|
||||
use std::{collections::BTreeMap, fs, io, path::Path};
|
||||
//! Read-only ZIP archives held in memory (resource packs, data packs and the
|
||||
//! unifont glyph archive), on the `zip` crate. Like Java's `ZipFile` (what
|
||||
//! vanilla's `FilePackResources` reads with), entries aren't CRC-checked.
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::{self, Cursor, Read},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
use zip::{CompressionMethod, ZipArchive};
|
||||
|
||||
/// One archive held in memory with its central directory indexed by name.
|
||||
/// One archive with its entries indexed by name (`\` read as `/`, folders
|
||||
/// left out).
|
||||
pub struct Zip {
|
||||
data: Vec<u8>,
|
||||
entries: BTreeMap<String, Entry>,
|
||||
archive: ZipArchive<Cursor<Arc<[u8]>>>,
|
||||
entries: BTreeMap<String, usize>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Entry {
|
||||
method: u16,
|
||||
compressed: usize,
|
||||
size: usize,
|
||||
header: usize,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
fn u16_at(d: &[u8], at: usize) -> io::Result<u16> {
|
||||
d.get(at..at + 2)
|
||||
.map(|b| u16::from_le_bytes([b[0], b[1]]))
|
||||
.ok_or_else(|| invalid("truncated zip"))
|
||||
}
|
||||
fn u32_at(d: &[u8], at: usize) -> io::Result<u32> {
|
||||
d.get(at..at + 4)
|
||||
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
.ok_or_else(|| invalid("truncated zip"))
|
||||
fn invalid(e: impl std::fmt::Display) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, e.to_string())
|
||||
}
|
||||
impl Zip {
|
||||
pub fn open(path: &Path) -> io::Result<Self> {
|
||||
Self::from_bytes(fs::read(path)?)
|
||||
}
|
||||
pub fn from_bytes(data: Vec<u8>) -> io::Result<Self> {
|
||||
// End of central directory: the last "PK\x05\x06" within 64 KiB of the end.
|
||||
let min = data.len().saturating_sub(22 + 65_535);
|
||||
let eocd = (min..=data.len().saturating_sub(22))
|
||||
.rev()
|
||||
.find(|&i| data[i..i + 4] == [0x50, 0x4b, 0x05, 0x06])
|
||||
.ok_or_else(|| invalid("not a zip archive"))?;
|
||||
let count = usize::from(u16_at(&data, eocd + 10)?);
|
||||
let mut at = u32_at(&data, eocd + 16)? as usize;
|
||||
let mut entries = BTreeMap::new();
|
||||
for _ in 0..count {
|
||||
if u32_at(&data, at)? != 0x0201_4b50 {
|
||||
return Err(invalid("bad central directory entry"));
|
||||
}
|
||||
let method = u16_at(&data, at + 10)?;
|
||||
let compressed = u32_at(&data, at + 20)? as usize;
|
||||
let size = u32_at(&data, at + 24)? as usize;
|
||||
let name_len = usize::from(u16_at(&data, at + 28)?);
|
||||
let extra_len = usize::from(u16_at(&data, at + 30)?);
|
||||
let comment_len = usize::from(u16_at(&data, at + 32)?);
|
||||
let header = u32_at(&data, at + 42)? as usize;
|
||||
let name = data
|
||||
.get(at + 46..at + 46 + name_len)
|
||||
.ok_or_else(|| invalid("truncated zip name"))?;
|
||||
let name = String::from_utf8_lossy(name).replace('\\', "/");
|
||||
if !name.ends_with('/') {
|
||||
entries.insert(
|
||||
name,
|
||||
Entry {
|
||||
method,
|
||||
compressed,
|
||||
size,
|
||||
header,
|
||||
},
|
||||
);
|
||||
}
|
||||
at += 46 + name_len + extra_len + comment_len;
|
||||
}
|
||||
Ok(Self { data, entries })
|
||||
let archive = ZipArchive::new(Cursor::new(Arc::from(data))).map_err(invalid)?;
|
||||
let entries = (0..archive.len())
|
||||
.filter_map(|i| Some((archive.name_for_index(i)?.replace('\\', "/"), i)))
|
||||
.filter(|(name, _)| !name.ends_with('/'))
|
||||
.collect();
|
||||
Ok(Self { archive, entries })
|
||||
}
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||
self.entries.keys().map(String::as_str)
|
||||
@@ -78,30 +38,21 @@ impl Zip {
|
||||
self.entries.contains_key(name)
|
||||
}
|
||||
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
|
||||
let e = *self
|
||||
let &index = self
|
||||
.entries
|
||||
.get(name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
|
||||
if u32_at(&self.data, e.header)? != 0x0403_4b50 {
|
||||
return Err(invalid("bad local file header"));
|
||||
}
|
||||
let start = e.header
|
||||
+ 30
|
||||
+ usize::from(u16_at(&self.data, e.header + 26)?)
|
||||
+ usize::from(u16_at(&self.data, e.header + 28)?);
|
||||
let raw = self
|
||||
.data
|
||||
.get(start..start + e.compressed)
|
||||
.ok_or_else(|| invalid("truncated zip entry"))?;
|
||||
match e.method {
|
||||
0 => Ok(raw.to_vec()),
|
||||
8 => {
|
||||
let out = miniz_oxide::inflate::decompress_to_vec_with_limit(
|
||||
raw,
|
||||
e.size.max(1) * 2 + 1024,
|
||||
)
|
||||
.map_err(|_| invalid("corrupt deflate stream"))?;
|
||||
Ok(out)
|
||||
// A clone shares the bytes and the central directory: reading needs `&mut`.
|
||||
let mut archive = self.archive.clone();
|
||||
let mut entry = archive.by_index_raw(index).map_err(invalid)?;
|
||||
let (method, size) = (entry.compression(), entry.size() as usize);
|
||||
let mut raw = Vec::with_capacity(entry.compressed_size() as usize);
|
||||
entry.read_to_end(&mut raw)?;
|
||||
match method {
|
||||
CompressionMethod::Stored => Ok(raw),
|
||||
CompressionMethod::Deflated => {
|
||||
miniz_oxide::inflate::decompress_to_vec_with_limit(&raw, size.max(1) * 2 + 1024)
|
||||
.map_err(|_| invalid("corrupt deflate stream"))
|
||||
}
|
||||
_ => Err(invalid("unsupported zip compression")),
|
||||
}
|
||||
@@ -163,10 +114,97 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn reads_stored_and_deflated_entries() {
|
||||
// Its CRCs are zero: Java's ZipFile doesn't check them either.
|
||||
let zip = Zip::from_bytes(archive()).unwrap();
|
||||
assert_eq!(zip.names().collect::<Vec<_>>(), ["a/b.txt", "pack.mcmeta"]);
|
||||
assert_eq!(zip.read("pack.mcmeta").unwrap(), b"{\"pack\":{}}");
|
||||
assert_eq!(zip.read("a/b.txt").unwrap(), b"hello hello hello hello");
|
||||
assert!(zip.read("missing").is_err());
|
||||
}
|
||||
|
||||
/// A pack as zip tools write it: folder entries, a nested pack root,
|
||||
/// stored and deflated files, and a Windows-style name.
|
||||
fn written() -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
use zip::write::SimpleFileOptions;
|
||||
let mut w = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
w.add_directory("Pack-main/", stored).unwrap();
|
||||
w.add_directory("Pack-main/assets/minecraft/", stored)
|
||||
.unwrap();
|
||||
w.start_file("Pack-main/pack.mcmeta", stored).unwrap();
|
||||
w.write_all(b"{\"pack\":{\"pack_format\":64}}").unwrap();
|
||||
w.start_file("Pack-main/assets/minecraft/lang/en_us.json", deflated)
|
||||
.unwrap();
|
||||
w.write_all("{\"a\":\"b\"}".repeat(100).as_bytes()).unwrap();
|
||||
w.start_file(
|
||||
"Pack-main\\assets\\minecraft\\texts\\splashes.txt",
|
||||
deflated,
|
||||
)
|
||||
.unwrap();
|
||||
w.write_all(b"Also try Horizon!").unwrap();
|
||||
w.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_nested_folders_and_skips_folder_entries() {
|
||||
let zip = Zip::from_bytes(written()).unwrap();
|
||||
assert_eq!(
|
||||
zip.names().collect::<Vec<_>>(),
|
||||
[
|
||||
"Pack-main/assets/minecraft/lang/en_us.json",
|
||||
"Pack-main/assets/minecraft/texts/splashes.txt",
|
||||
"Pack-main/pack.mcmeta",
|
||||
]
|
||||
);
|
||||
assert!(zip.contains("Pack-main/pack.mcmeta"));
|
||||
assert!(!zip.contains("Pack-main/"));
|
||||
assert_eq!(
|
||||
zip.read("Pack-main/assets/minecraft/lang/en_us.json")
|
||||
.unwrap(),
|
||||
"{\"a\":\"b\"}".repeat(100).as_bytes()
|
||||
);
|
||||
assert_eq!(
|
||||
zip.read("Pack-main/assets/minecraft/texts/splashes.txt")
|
||||
.unwrap(),
|
||||
b"Also try Horizon!"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_entry_fails_alone() {
|
||||
let mut bytes = written();
|
||||
// The deflated lang file's stream becomes a reserved block type.
|
||||
let (start, len) = {
|
||||
let mut archive = ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
let index = archive
|
||||
.index_for_name("Pack-main/assets/minecraft/lang/en_us.json")
|
||||
.unwrap();
|
||||
let entry = archive.by_index_raw(index).unwrap();
|
||||
(
|
||||
entry.data_start() as usize,
|
||||
entry.compressed_size() as usize,
|
||||
)
|
||||
};
|
||||
bytes[start..start + len].fill(0xff);
|
||||
let zip = Zip::from_bytes(bytes).unwrap();
|
||||
assert!(
|
||||
zip.read("Pack-main/assets/minecraft/lang/en_us.json")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
zip.read("Pack-main/pack.mcmeta").unwrap(),
|
||||
b"{\"pack\":{\"pack_format\":64}}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_is_not_a_zip() {
|
||||
assert!(Zip::from_bytes(b"PK not really".to_vec()).is_err());
|
||||
assert!(Zip::from_bytes(Vec::new()).is_err());
|
||||
let mut cut = written();
|
||||
cut.truncate(cut.len() / 2);
|
||||
assert!(Zip::from_bytes(cut).is_err());
|
||||
}
|
||||
}
|
||||
@@ -168,9 +168,6 @@ impl Beardifier {
|
||||
self.kernel_junctions = junctions;
|
||||
self
|
||||
}
|
||||
pub fn affected_bounds(&self) -> Option<StructureBounds> {
|
||||
self.affected
|
||||
}
|
||||
fn sample_unchecked(&self, [x, y, z]: [i32; 3]) -> f32 {
|
||||
let mut value = 0.;
|
||||
// The kernel is a process-wide constant; resolving it once keeps the
|
||||
|
||||
@@ -13,8 +13,6 @@ impl ShapeOp {
|
||||
pub const XOR: Self = Self(6);
|
||||
pub const ONLY_FIRST: Self = Self(4);
|
||||
pub const ONLY_SECOND: Self = Self(2);
|
||||
pub const FIRST: Self = Self(12);
|
||||
pub const SECOND: Self = Self(10);
|
||||
pub const fn from_truth_table(bits: u8) -> Self {
|
||||
Self(bits & 15)
|
||||
}
|
||||
|
||||
@@ -466,34 +466,6 @@ impl PalettedContainer {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Block ids as 16-bit values (callers whose registry fits in 16 bits).
|
||||
pub fn unpack_u16(&self, out: &mut [u16; 4096]) {
|
||||
debug_assert_eq!(self.strategy.entries(), 4096);
|
||||
match &self.palette {
|
||||
Palette::Single(value) => out.fill(*value as u16),
|
||||
Palette::List(list) => {
|
||||
let bits = usize::from(self.bits);
|
||||
let per = 64 / bits;
|
||||
let m = mask(self.bits);
|
||||
for (chunk, &word) in out.chunks_mut(per).zip(&self.words) {
|
||||
for (slot, cell) in chunk.iter_mut().enumerate() {
|
||||
let id = ((word >> (slot * bits)) & m) as usize;
|
||||
*cell = list[id] as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
Palette::Global => {
|
||||
let bits = usize::from(self.bits);
|
||||
let per = 64 / bits;
|
||||
let m = mask(self.bits);
|
||||
for (chunk, &word) in out.chunks_mut(per).zip(&self.words) {
|
||||
for (slot, cell) in chunk.iter_mut().enumerate() {
|
||||
*cell = ((word >> (slot * bits)) & m) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A container holding `values` in the smallest configuration for their
|
||||
/// distinct count, with palette ids in first-occurrence order, like a
|
||||
/// container unpacked from disk.
|
||||
|
||||
@@ -91,9 +91,6 @@ impl<C: WorldGenTickContainers> WorldGenTickAccess<C> {
|
||||
pub fn containers_mut(&mut self) -> &mut C {
|
||||
&mut self.containers
|
||||
}
|
||||
pub fn into_containers(self) -> C {
|
||||
self.containers
|
||||
}
|
||||
pub fn schedule(&mut self, tick: ScheduledTick) -> Result<bool, C::Error> {
|
||||
Ok(self.containers.container_at(tick.pos)?.schedule(tick))
|
||||
}
|
||||
|
||||
@@ -636,7 +636,4 @@ impl LevelTicks {
|
||||
pub fn copy_area(&mut self, area: TickArea, offset: BlockPos) {
|
||||
self.schedule_copy(self.area_ticks(area), offset);
|
||||
}
|
||||
pub fn copy_area_from(&mut self, source: &Self, area: TickArea, offset: BlockPos) {
|
||||
self.schedule_copy(source.area_ticks(area), offset);
|
||||
}
|
||||
}
|
||||
@@ -296,14 +296,4 @@ impl Tunnel {
|
||||
})?;
|
||||
Ok((port, thread))
|
||||
}
|
||||
/// The protected stream itself (tests, or a client that reads it
|
||||
/// directly).
|
||||
pub fn into_stream(
|
||||
self,
|
||||
) -> (
|
||||
crypto::SecureReader<TcpStream>,
|
||||
crypto::SecureWriter<TcpStream>,
|
||||
) {
|
||||
self.secure
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,13 @@ macro_rules! export_mod {
|
||||
with(|m| $crate::Mod::chat(m, &text))
|
||||
}
|
||||
}
|
||||
// Only WebAssembly has the component's exports: a host build (cargo test or
|
||||
// clippy on the workspace) checks the mod without them, which macOS
|
||||
// couldn't link (its exported-symbols list cuts names at the `#`).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
$crate::__export_horizon_mod!(Export);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = <Export as $crate::__Guest>::init;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -190,14 +190,6 @@ impl UserCache {
|
||||
result
|
||||
}
|
||||
|
||||
/// `get(UUID)`: a cached player by id (expired or not).
|
||||
pub fn get_by_id(&mut self, id: u128) -> Option<NameAndId> {
|
||||
let index = *self.by_uuid.get(&id)?;
|
||||
self.operations += 1;
|
||||
self.infos[index].last_access = self.operations;
|
||||
Some(self.infos[index].user.clone())
|
||||
}
|
||||
|
||||
/// The file's text: the 1000 most recently used entries, most recent
|
||||
/// first, as Gson writes them (compact, HTML-safe).
|
||||
pub fn to_json(&self) -> String {
|
||||
|
||||
@@ -416,11 +416,6 @@ pub type WhiteList = UserList<WhiteEntry>;
|
||||
pub type BanList = UserList<BanEntry<NameAndId>>;
|
||||
pub type IpBanList = UserList<BanEntry<String>>;
|
||||
|
||||
/// IpBanList.getIpFromAddress.
|
||||
pub fn ip_of(address: &std::net::SocketAddr) -> String {
|
||||
address.ip().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -199,8 +199,11 @@ pub fn preprocess(
|
||||
let (start, end) = (token.location.start, token.location.end);
|
||||
let at = line_of(start);
|
||||
if at > line {
|
||||
// Lines the preprocessor left empty stay empty lines, so a value
|
||||
// continued with `\` before an `#else` (Photon's
|
||||
// `moon_phase_brightness`) ends there, as in Iris.
|
||||
if line > 0 {
|
||||
out.push('\n');
|
||||
out.extend(std::iter::repeat_n('\n', at - line));
|
||||
}
|
||||
line = at;
|
||||
} else if last_end != Some(start) {
|
||||
@@ -799,7 +802,26 @@ b = 1
|
||||
};
|
||||
let values = OptionValues::default();
|
||||
let out = preprocess(text, &env, &set, &values).unwrap();
|
||||
assert_eq!(out, "a = 0.75\nb = 1\n");
|
||||
let lines: Vec<&str> = out.lines().filter(|l| !l.is_empty()).collect();
|
||||
assert_eq!(lines, ["a = 0.75", "b = 1"]);
|
||||
// Photon ends an `#if`'d value with `) \` before `#else`: the value
|
||||
// stops at the empty line the directive leaves.
|
||||
let text = "\
|
||||
#if FOG == 0
|
||||
a = if( \\
|
||||
moonPhase == 0, 1.0, \\
|
||||
2.0 \\
|
||||
) \\
|
||||
#else
|
||||
a = 1.0
|
||||
#endif
|
||||
|
||||
b = 2
|
||||
";
|
||||
let out = preprocess(text, &env, &set, &values).unwrap();
|
||||
let parsed = Properties::parse(&out);
|
||||
assert_eq!(parsed.get("a"), Some("if( moonPhase == 0, 1.0, 2.0 ) "));
|
||||
assert_eq!(parsed.get("b"), Some("2"));
|
||||
assert_eq!(
|
||||
jcpp_condition("#elif FOG == 0.75 && X > 2.5", &set, &values),
|
||||
"#elif 0 == 0 && X > 2"
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
//! (`vaPosition`, `modelViewMatrix`, ...) are declared;
|
||||
//! - legacy texture calls are renamed, `shadow2D` returns a `vec4` again and a
|
||||
//! swizzled shadow lookup is widened first (naga has no scalar swizzle);
|
||||
//! - vertex and compute `texture(s, uv)` calls without an explicit LOD become
|
||||
//! `textureLod(s, uv, 0.0)` so naga doesn't reject implicit derivatives;
|
||||
//! - outside fragment programs, `texture`, `textureProj` and their `Offset`
|
||||
//! forms become their `Lod` forms at level 0 (no derivatives there; naga
|
||||
//! refuses the implicit ones), a bias dropped;
|
||||
//! - global initialisers run at the start of `main` (drivers accept
|
||||
//! non-constant ones), unsized global arrays are sized from them;
|
||||
//! - `isnan`, `isinf` and `modf` are helper functions (WGSL has none);
|
||||
@@ -380,9 +381,10 @@ pub fn translate(
|
||||
stage: naga_stage,
|
||||
defines: Default::default(),
|
||||
};
|
||||
let module = naga::front::glsl::Frontend::default()
|
||||
let mut module = naga::front::glsl::Frontend::default()
|
||||
.parse(&options, &glsl)
|
||||
.map_err(|e| first_lines(&e.emit_to_string(&glsl)))?;
|
||||
unpad_private_arrays(&mut module);
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
@@ -508,6 +510,79 @@ fn negative_constant(e: &ast::Expr) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// naga's GLSL front end lays every struct out as std140, so an array field
|
||||
/// of a struct that never reaches a buffer (Photon's ACES spline parameters,
|
||||
/// `float[6]`) gets a stride of 16, and constructing it from a `float[6](..)`
|
||||
/// (stride 4) fails validation. Give such fields their natural stride.
|
||||
fn unpad_private_arrays(module: &mut naga::Module) {
|
||||
use naga::TypeInner as T;
|
||||
let mut shared = HashSet::new();
|
||||
let mut stack: Vec<_> = module
|
||||
.global_variables
|
||||
.iter()
|
||||
.filter(|(_, v)| {
|
||||
matches!(
|
||||
v.space,
|
||||
naga::AddressSpace::Uniform
|
||||
| naga::AddressSpace::Storage { .. }
|
||||
| naga::AddressSpace::Immediate
|
||||
)
|
||||
})
|
||||
.map(|(_, v)| v.ty)
|
||||
.collect();
|
||||
while let Some(ty) = stack.pop() {
|
||||
if !shared.insert(ty) {
|
||||
continue;
|
||||
}
|
||||
match &module.types[ty].inner {
|
||||
T::Struct { members, .. } => stack.extend(members.iter().map(|m| m.ty)),
|
||||
T::Array { base, .. } | T::BindingArray { base, .. } => stack.push(*base),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut layouter = naga::proc::Layouter::default();
|
||||
if layouter.update(module.to_ctx()).is_err() {
|
||||
return;
|
||||
}
|
||||
let structs: Vec<_> = module
|
||||
.types
|
||||
.iter()
|
||||
.filter(|(h, t)| !shared.contains(h) && matches!(t.inner, T::Struct { .. }))
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
for ty in structs {
|
||||
let mut new = module.types[ty].clone();
|
||||
let T::Struct { members, .. } = &mut new.inner else {
|
||||
continue;
|
||||
};
|
||||
let mut changed = false;
|
||||
for m in members.iter_mut() {
|
||||
let T::Array { base, size, stride } = module.types[m.ty].inner else {
|
||||
continue;
|
||||
};
|
||||
let natural = layouter[base].to_stride();
|
||||
if stride != natural {
|
||||
let span = module.types.get_span(m.ty);
|
||||
m.ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: None,
|
||||
inner: T::Array {
|
||||
base,
|
||||
size,
|
||||
stride: natural,
|
||||
},
|
||||
},
|
||||
span,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
module.types.replace(ty, new);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn first_lines(s: &str) -> String {
|
||||
s.lines().take(8).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
@@ -554,6 +629,8 @@ struct Legaliser<'a> {
|
||||
/// their helpers go in.
|
||||
modulo: bool,
|
||||
nan: bool,
|
||||
/// `textureGather` was used: [`GATHER`] is added.
|
||||
gather: bool,
|
||||
mat2_ctor: bool,
|
||||
/// Constants turned into globals ([`needs_runtime`]).
|
||||
runtime_constants: HashSet<String>,
|
||||
@@ -571,6 +648,94 @@ fn call(name: &str, args: Vec<ast::Expr>) -> ast::Expr {
|
||||
.into_node()
|
||||
}
|
||||
|
||||
/// `textureGather(sampler2D, uv, comp)`: component `comp` of the four texels
|
||||
/// bilinear filtering at `uv` would blend, in GL's order, read with
|
||||
/// `texelFetch` (edges clamped).
|
||||
const GATHER: &str = "\
|
||||
vec4 iris_gather(texture2D t, sampler s, vec2 uv, int c) {
|
||||
ivec2 hi = textureSize(sampler2D(t, s), 0) - 1;
|
||||
ivec2 i = ivec2(floor(uv * vec2(hi + 1) - 0.5));
|
||||
vec4 a = texelFetch(sampler2D(t, s), clamp(i + ivec2(0, 1), ivec2(0), hi), 0);
|
||||
vec4 b = texelFetch(sampler2D(t, s), clamp(i + ivec2(1, 1), ivec2(0), hi), 0);
|
||||
vec4 d = texelFetch(sampler2D(t, s), clamp(i + ivec2(1, 0), ivec2(0), hi), 0);
|
||||
vec4 e = texelFetch(sampler2D(t, s), clamp(i, ivec2(0), hi), 0);
|
||||
return vec4(a[c], b[c], d[c], e[c]);
|
||||
}
|
||||
";
|
||||
|
||||
/// GL converts a `bool` given to a numeric constructor (`vec3(r < radius)`,
|
||||
/// Photon's sun disc); naga refuses. A comparison or logical expression
|
||||
/// passed to one becomes `(cond ? 1.0 : 0.0)` (or `1`/`1u`).
|
||||
fn bool_to_number(e: &mut ast::Expr) {
|
||||
use ast::BinaryOpData as B;
|
||||
let ast::ExprData::FunCall(f, args) = &mut e.content else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = callee(f) else { return };
|
||||
let base = name.trim_start_matches('d');
|
||||
let (one, zero) = if base.starts_with("ivec") || name == "int" {
|
||||
(ast::ExprData::IntConst(1), ast::ExprData::IntConst(0))
|
||||
} else if base.starts_with("uvec") || name == "uint" {
|
||||
(ast::ExprData::UIntConst(1), ast::ExprData::UIntConst(0))
|
||||
} else if base.starts_with("vec") || base.starts_with("mat") || name == "float" {
|
||||
(
|
||||
ast::ExprData::FloatConst(1.0),
|
||||
ast::ExprData::FloatConst(0.0),
|
||||
)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
for a in args.iter_mut() {
|
||||
let boolean = match &a.content {
|
||||
ast::ExprData::Binary(op, ..) => matches!(
|
||||
op.content,
|
||||
B::Or | B::Xor | B::And | B::Equal | B::NonEqual | B::Lt | B::Gt | B::Lte | B::Gte
|
||||
),
|
||||
ast::ExprData::Unary(op, _) => op.content == ast::UnaryOpData::Not,
|
||||
_ => false,
|
||||
};
|
||||
if boolean {
|
||||
let cond = std::mem::replace(a, var("_"));
|
||||
*a = ast::ExprData::Ternary(
|
||||
Box::new(cond),
|
||||
Box::new(one.clone().into_node()),
|
||||
Box::new(zero.clone().into_node()),
|
||||
)
|
||||
.into_node();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outside fragment shaders there are no derivatives, so GL samples level 0
|
||||
/// where `texture` and its kin would pick one; naga refuses them there (the
|
||||
/// vertex programs of Chocapic13', MakeUp and Bliss). Turn them into their
|
||||
/// `Lod` forms at level 0 (a bias is dropped), also inside `vec4(..)` as
|
||||
/// legacy shadow lookups are wrapped.
|
||||
fn explicit_lod(e: &mut ast::Expr) {
|
||||
let ast::ExprData::FunCall(f, args) = &mut e.content else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = callee(f) else { return };
|
||||
if name == "vec4" && args.len() == 1 {
|
||||
return explicit_lod(&mut args[0]);
|
||||
}
|
||||
// (implicit, explicit, where the level goes, how many arguments without a bias)
|
||||
let (lod, at, plain) = match name.as_str() {
|
||||
"texture" => ("textureLod", 2, 2),
|
||||
"textureProj" => ("textureProjLod", 2, 2),
|
||||
"textureOffset" => ("textureLodOffset", 2, 3),
|
||||
"textureProjOffset" => ("textureProjLodOffset", 2, 3),
|
||||
_ => return,
|
||||
};
|
||||
if args.len() < plain {
|
||||
return;
|
||||
}
|
||||
args.truncate(plain);
|
||||
let zero = ast::ExprData::FloatConst(0.0).into_node();
|
||||
args.insert(at, zero);
|
||||
*f = ast::FunIdentifierData::Expr(Box::new(var(lod))).into_node();
|
||||
}
|
||||
|
||||
/// The name a call is made by: a function, or a constructor's type.
|
||||
fn callee(f: &ast::FunIdentifier) -> Option<String> {
|
||||
match &f.content {
|
||||
@@ -784,6 +949,7 @@ impl<'a> Legaliser<'a> {
|
||||
constant: false,
|
||||
modulo: false,
|
||||
nan: false,
|
||||
gather: false,
|
||||
mat2_ctor: false,
|
||||
runtime_constants: HashSet::new(),
|
||||
}
|
||||
@@ -1253,7 +1419,23 @@ impl<'a> Legaliser<'a> {
|
||||
}
|
||||
let mut locals = Vec::new();
|
||||
let mut params = Vec::with_capacity(p.parameters.len());
|
||||
for param in std::mem::take(&mut p.parameters) {
|
||||
for mut param in std::mem::take(&mut p.parameters) {
|
||||
// `const in float x` (Bliss): naga's front end takes no `const`
|
||||
// on parameters, and it changes nothing for the caller.
|
||||
match &mut param.content {
|
||||
ast::FunctionParameterDeclarationData::Named(q, _)
|
||||
| ast::FunctionParameterDeclarationData::Unnamed(q, _) => {
|
||||
if let Some(list) = q {
|
||||
list.qualifiers.retain(|q| {
|
||||
!matches!(&q.content, ast::TypeQualifierSpecData::Storage(s)
|
||||
if s.content == ast::StorageQualifierData::Const)
|
||||
});
|
||||
if list.qualifiers.is_empty() {
|
||||
*q = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (ty, name) = match ¶m.content {
|
||||
ast::FunctionParameterDeclarationData::Named(_, d) => {
|
||||
(type_text(&d.ty), Some(d.ident.ident.as_str().to_owned()))
|
||||
@@ -1713,32 +1895,60 @@ impl<'a> Legaliser<'a> {
|
||||
self.nan = true;
|
||||
return;
|
||||
}
|
||||
let mut target_name = name.as_str();
|
||||
if name == "textureGather"
|
||||
&& (2..=3).contains(&args.len())
|
||||
&& let ast::ExprData::FunCall(g, inner) = &args[0].content
|
||||
&& inner.len() == 2
|
||||
&& callee(g).as_deref() == Some("sampler2D")
|
||||
{
|
||||
// naga's GLSL front end has no `textureGather`.
|
||||
let mut call_args = inner.clone();
|
||||
call_args.push(args[1].clone());
|
||||
call_args.push(
|
||||
args.get(2)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ast::ExprData::IntConst(0).into_node()),
|
||||
);
|
||||
*e = call("iris_gather", call_args);
|
||||
self.gather = true;
|
||||
return;
|
||||
}
|
||||
if name == "modf"
|
||||
&& args.len() == 2
|
||||
&& !matches!(args[1].content, ast::ExprData::Variable(_))
|
||||
{
|
||||
// `modf(x, v.y)` (Photon): WGSL can't point at a vector
|
||||
// component, so no `out` parameter: `+(v.y = trunc(x), x - trunc(x))`
|
||||
// (the unary `+` has the comma printed in brackets, also in an
|
||||
// initialiser).
|
||||
let (x, whole) = (args[0].clone(), args[1].clone());
|
||||
let assign = ast::ExprData::Assignment(
|
||||
Box::new(whole),
|
||||
ast::AssignmentOpData::Equal.into_node(),
|
||||
Box::new(call("trunc", vec![x.clone()])),
|
||||
)
|
||||
.into_node();
|
||||
let fraction = ast::ExprData::Binary(
|
||||
ast::BinaryOpData::Sub.into_node(),
|
||||
Box::new(x.clone()),
|
||||
Box::new(call("trunc", vec![x])),
|
||||
)
|
||||
.into_node();
|
||||
let comma = ast::ExprData::Comma(Box::new(assign), Box::new(fraction)).into_node();
|
||||
*e = ast::ExprData::Unary(ast::UnaryOpData::Add.into_node(), Box::new(comma))
|
||||
.into_node();
|
||||
return;
|
||||
}
|
||||
if let Some((_, new)) = LEGACY_CALLS.iter().find(|(old, _)| *old == name) {
|
||||
*f = ast::FunIdentifierData::Expr(Box::new(var(new))).into_node();
|
||||
target_name = new;
|
||||
} else if let Some((_, new)) = SHADOW_CALLS.iter().find(|(old, _)| *old == name) {
|
||||
let args = std::mem::take(args);
|
||||
*e = call("vec4", vec![call(new, args)]);
|
||||
return;
|
||||
}
|
||||
// In vertex and compute shaders, `texture(sampler, coord)` uses implicit
|
||||
// derivatives that WGSL/naga forbid outside fragment shaders (Bliss):
|
||||
// rewrite 2-argument non-shadow `texture(s, uv)` to `textureLod(s, uv, 0.0)`.
|
||||
if self.stage != Stage::Fragment && target_name == "texture" && args.len() == 2 {
|
||||
let is_shadow = match &args[0].content {
|
||||
ast::ExprData::FunCall(g, _) => {
|
||||
callee(g).and_then(|c| opaque(&c)).is_some_and(|o| o.3)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !is_shadow {
|
||||
if let ast::ExprData::FunCall(f, args) = &mut e.content {
|
||||
*f = ast::FunIdentifierData::Expr(Box::new(var("textureLod"))).into_node();
|
||||
args.push(ast::ExprData::FloatConst(0.0).into_node());
|
||||
}
|
||||
}
|
||||
if self.stage != Stage::Fragment {
|
||||
explicit_lod(e);
|
||||
}
|
||||
bool_to_number(e);
|
||||
}
|
||||
|
||||
/// The legalised source: header, the rewritten program, then a `main`
|
||||
@@ -1775,6 +1985,9 @@ impl<'a> Legaliser<'a> {
|
||||
if self.modulo {
|
||||
out.push_str(&modulo_helpers());
|
||||
}
|
||||
if self.gather {
|
||||
out.push_str(GATHER);
|
||||
}
|
||||
if self.nan {
|
||||
out.push_str("float iris_sqrt(float x) { return sqrt(x); }\n");
|
||||
}
|
||||
|
||||
@@ -368,6 +368,78 @@ void main() {
|
||||
reparse(&fs.wgsl).unwrap();
|
||||
}
|
||||
|
||||
/// What Photon, Chocapic13', MakeUp Ultra Fast and Bliss write and GL takes:
|
||||
/// lookups without a level in a vertex shader, a struct passed between
|
||||
/// stages, `textureGather`, a `bool` given to `vec3(..)`, `const in`
|
||||
/// parameters and an array field in a struct built by its constructor.
|
||||
#[test]
|
||||
fn legalises_what_photon_and_chocapic_write() {
|
||||
let mut link = Link::default();
|
||||
let header = "uniform sampler2D colortex0;
|
||||
uniform sampler2DShadow shadowtex0;
|
||||
struct Clouds { float coverage; vec2 wind; mat3 turn; int layers; };
|
||||
";
|
||||
let vs = run(
|
||||
Stage::Vertex,
|
||||
&format!(
|
||||
"{header}flat varying Clouds clouds;
|
||||
void main() {{
|
||||
clouds.coverage = texture2D(colortex0, vec2(0.5)).r + shadow2D(shadowtex0, vec3(0.5)).r;
|
||||
clouds.wind = textureOffset(colortex0, vec2(0.5), ivec2(1, 0), 2.0).rg;
|
||||
clouds.turn = mat3(1.0);
|
||||
clouds.layers = 2;
|
||||
gl_Position = ftransform();
|
||||
}}
|
||||
"
|
||||
),
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
for wanted in [
|
||||
"textureLod(sampler2D(colortex0_t",
|
||||
"vec2(0.5), 0.)",
|
||||
"textureLodOffset(sampler2D(colortex0_t",
|
||||
"clouds_iriscoverage = clouds.coverage;",
|
||||
"clouds_iristurn_iris1 = clouds.turn[1];",
|
||||
] {
|
||||
assert!(vs.glsl.contains(wanted), "{wanted}\n{}", vs.glsl);
|
||||
}
|
||||
reparse(&vs.wgsl).unwrap();
|
||||
let fs = run(
|
||||
Stage::Fragment,
|
||||
&format!(
|
||||
"{header}flat varying Clouds clouds;
|
||||
struct Spline {{ float slope; float[3] coeffs; }};
|
||||
float edge(const in float x, const float r) {{ return float(x < r); }}
|
||||
void main() {{
|
||||
const Spline spline = Spline(0.0, float[3](1.0, 2.0, 3.0));
|
||||
vec4 four = textureGather(colortex0, vec2(0.5), 1);
|
||||
vec3 disc = vec3(clouds.coverage < 0.5);
|
||||
vec2 cell = vec2(0.0);
|
||||
float part = modf(clouds.coverage * 4.0, cell.y);
|
||||
gl_FragColor = vec4(disc * edge(four.x, spline.coeffs[1]), clouds.wind.x + float(clouds.layers) + part + cell.y);
|
||||
}}
|
||||
"
|
||||
),
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
for wanted in [
|
||||
"iris_gather(colortex0_t",
|
||||
"clouds.coverage = clouds_iriscoverage;",
|
||||
"float edge(in float x, float r)",
|
||||
"cell.y = trunc(",
|
||||
] {
|
||||
assert!(fs.glsl.contains(wanted), "{wanted}\n{}", fs.glsl);
|
||||
}
|
||||
reparse(&fs.wgsl).unwrap();
|
||||
// The vertex program's outputs are the fragment program's inputs.
|
||||
assert_eq!(
|
||||
io(&vs, true).get("clouds_iriswind"),
|
||||
io(&fs, false).get("clouds_iriswind")
|
||||
);
|
||||
}
|
||||
|
||||
/// A fragment output narrower than its four-channel target (Steadfast's
|
||||
/// `final` writes `out vec3 color`) is written through a `vec4` with alpha 1:
|
||||
/// GL fills the missing channels, wgpu refuses the pipeline.
|
||||
|
||||
@@ -62,9 +62,6 @@ impl<E> ProtoBlockEntities<E> {
|
||||
pub fn active(&self, position: BlockPos) -> Option<&E> {
|
||||
self.active.get(&position)
|
||||
}
|
||||
pub fn active_mut(&mut self, position: BlockPos) -> Option<&mut E> {
|
||||
self.active.get_mut(&position)
|
||||
}
|
||||
pub fn pending(&self, position: BlockPos) -> Option<&Tag> {
|
||||
self.pending.get(&position)
|
||||
}
|
||||
|
||||
@@ -157,12 +157,6 @@ impl FaceVisibility {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the state's occlusion face toward `direction` is the full square.
|
||||
pub fn full_face(&self, state: BlockStateId, direction: Direction) -> bool {
|
||||
self.states
|
||||
.get(state.0 as usize)
|
||||
.is_some_and(|s| s.faces[direction as usize] == 1)
|
||||
}
|
||||
/// The state's occlusion face toward `direction` (`getFaceOcclusionShape`) and
|
||||
/// its shared ID: 0 is the empty singleton, 1 the full-block singleton.
|
||||
pub fn occlusion_face(
|
||||
@@ -173,12 +167,6 @@ impl FaceVisibility {
|
||||
let id = self.states.get(state.0 as usize)?.faces[direction as usize];
|
||||
Some((id, &self.faces[id as usize]))
|
||||
}
|
||||
/// Whether the state's occlusion face toward `direction` is empty.
|
||||
pub fn empty_face(&self, state: BlockStateId, direction: Direction) -> bool {
|
||||
self.states
|
||||
.get(state.0 as usize)
|
||||
.is_none_or(|s| s.faces[direction as usize] == 0)
|
||||
}
|
||||
pub fn skip_rendering(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
|
||||
@@ -186,25 +186,6 @@ impl<'r> BlockSupportRegistry<'r> {
|
||||
pub fn registry(&self) -> &'r BlockRegistry {
|
||||
self.cached.registry()
|
||||
}
|
||||
pub fn support_shape<W: DynamicSupportWorld>(
|
||||
&self,
|
||||
world: &mut W,
|
||||
state: BlockStateId,
|
||||
position: [i32; 3],
|
||||
) -> Result<VoxelShape, SupportQueryError<W::Error>> {
|
||||
if let Some(shape) = self
|
||||
.cached
|
||||
.cached_shape(state)
|
||||
.map_err(SupportQueryError::Data)?
|
||||
{
|
||||
return Ok(shape.clone());
|
||||
}
|
||||
self.dynamic
|
||||
.get(&state)
|
||||
.ok_or(SupportQueryError::Unsupported(state))?
|
||||
.shape(world, position)
|
||||
.map_err(SupportQueryError::Dynamic)
|
||||
}
|
||||
pub fn is_face_sturdy<W: DynamicSupportWorld>(
|
||||
&self,
|
||||
world: &mut W,
|
||||
|
||||
@@ -524,14 +524,6 @@ impl<'r> BlockSurvivalRegistry<'r> {
|
||||
bubble_down: tag_states("minecraft:enables_bubble_column_drag_down")?,
|
||||
})
|
||||
}
|
||||
pub fn supported_states(&self) -> usize {
|
||||
self.rules.iter().filter(|r| r.is_some()).count()
|
||||
}
|
||||
pub fn supports_state(&self, state: BlockStateId) -> bool {
|
||||
self.rules
|
||||
.get(state.0 as usize)
|
||||
.is_some_and(Option::is_some)
|
||||
}
|
||||
fn read<W: SurvivalWorld>(
|
||||
&self,
|
||||
world: &mut W,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use crate::{
|
||||
component_patch::{ComponentMap, MissingPrototype, Patch, Prototype},
|
||||
item_durability::DurabilityStorage,
|
||||
item_use_catalog::UseComponents,
|
||||
};
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Key {
|
||||
@@ -52,13 +51,6 @@ impl DurabilityPrototype {
|
||||
}
|
||||
Self(Prototype::new(entries))
|
||||
}
|
||||
pub fn from_defaults(defaults: &UseComponents) -> Self {
|
||||
Self::new([
|
||||
Change::MaxDamage(defaults.max_damage),
|
||||
Change::Damage(defaults.damage),
|
||||
Change::Unbreakable(defaults.unbreakable),
|
||||
])
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DurabilityPatch(Patch<Key, i32>);
|
||||
|
||||
@@ -157,12 +157,6 @@ pub fn map(tag: &Tag) -> Option<&Compound> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn map_mut(tag: &mut Tag) -> Option<&mut Compound> {
|
||||
match tag {
|
||||
Tag::Compound(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `Dynamic.set`: only maps change.
|
||||
pub fn set(tag: &mut Tag, field: &str, value: Tag) {
|
||||
if let Tag::End = tag {
|
||||
@@ -193,12 +187,6 @@ pub fn update(tag: &mut Tag, field: &str, f: impl FnOnce(Tag) -> Tag) {
|
||||
set(tag, field, f(v));
|
||||
}
|
||||
}
|
||||
/// `Dynamic.update` in place.
|
||||
pub fn update_mut(tag: &mut Tag, field: &str, f: impl FnOnce(&mut Tag)) {
|
||||
if let Some(v) = tag.get_mut(field) {
|
||||
f(v);
|
||||
}
|
||||
}
|
||||
/// `Dynamic.renameAndFixField`: the fixed value under the new name, removed when
|
||||
/// the fix gives nothing.
|
||||
pub fn rename_and_fix(tag: &mut Tag, from: &str, to: &str, f: impl FnOnce(Tag) -> Option<Tag>) {
|
||||
@@ -223,15 +211,6 @@ pub fn is_empty_map(tag: &Tag) -> bool {
|
||||
pub fn or_empty_map(tag: Option<&Tag>) -> Tag {
|
||||
tag.cloned().unwrap_or_else(empty_map)
|
||||
}
|
||||
/// Apply `f` to every element of a list field (`Dynamic.createList(asStream().map(f))`).
|
||||
pub fn map_list(tag: &mut Tag, field: &str, mut f: impl FnMut(Tag) -> Tag) {
|
||||
if let Some(v) = tag.get(field)
|
||||
&& is_list(Some(v))
|
||||
{
|
||||
let list = stream(Some(v)).into_iter().map(&mut f).collect();
|
||||
set(tag, field, Tag::List(list));
|
||||
}
|
||||
}
|
||||
/// `NamespacedSchema.ensureNamespaced`.
|
||||
pub fn namespaced(id: &str) -> String {
|
||||
match crate::datafix::identifier(id) {
|
||||
|
||||
@@ -44,9 +44,6 @@ impl MovementHistory {
|
||||
}
|
||||
self.records.push_back(record);
|
||||
}
|
||||
pub fn remove_latest(&mut self) {
|
||||
self.records.pop_back();
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.records.clear();
|
||||
}
|
||||
|
||||
@@ -73,17 +73,3 @@ pub fn is_unobstructed<W: EntityObstructionWorld>(
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Entity obstruction for the unit cube used by the vanilla block predicate.
|
||||
/// Translate the voxel shape in floating point: integer MAX + 1 must not wrap.
|
||||
pub fn is_block_unobstructed<W: EntityObstructionWorld>(
|
||||
world: &mut W,
|
||||
position: [i32; 3],
|
||||
) -> Result<bool, ObstructionError<W::Error>> {
|
||||
let shape = VoxelShape::block().moved(horizon_core::geometry::Vec3::new(
|
||||
f64::from(position[0]),
|
||||
f64::from(position[1]),
|
||||
f64::from(position[2]),
|
||||
));
|
||||
is_unobstructed(world, None, &shape)
|
||||
}
|
||||
@@ -272,13 +272,6 @@ impl FluidStateTable {
|
||||
pub fn get(&self, state: BlockStateId) -> Option<FluidState> {
|
||||
self.0.get(state.0 as usize).copied()
|
||||
}
|
||||
pub fn matching_states(&self, mask: u8) -> impl Iterator<Item = BlockStateId> + '_ {
|
||||
self.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(move |(_, state)| state.kind.mask() & mask != 0)
|
||||
.map(|(i, _)| BlockStateId(i as u32))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound identities for converting fluid states into their legacy liquid blocks.
|
||||
|
||||
@@ -15,9 +15,6 @@ macro_rules! classes {
|
||||
pub fn from_java(name: &str) -> Option<Self> {
|
||||
match name { $($java => Some(Class::$name),)* _ => None }
|
||||
}
|
||||
pub const fn java_name(self) -> &'static str {
|
||||
match self { $(Class::$name => $java,)* }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,7 +148,6 @@ pub mod flags {
|
||||
pub const UPDATE_NEIGHBORS: i32 = 1;
|
||||
pub const UPDATE_CLIENTS: i32 = 2;
|
||||
pub const UPDATE_INVISIBLE: i32 = 4;
|
||||
pub const UPDATE_IMMEDIATE: i32 = 8;
|
||||
pub const UPDATE_KNOWN_SHAPE: i32 = 16;
|
||||
pub const UPDATE_SUPPRESS_DROPS: i32 = 32;
|
||||
pub const UPDATE_MOVE_BY_PISTON: i32 = 64;
|
||||
|
||||
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user