Compare commits

...
Author SHA1 Message Date
Ludovic Marie baa379d717 Merge pull request #3578 from d3grando/fix-3432
🤖 Agent: proven on union/pt1-w3 by pr-triage-1: every vanilla harness this branch reaches passes (mob_ai and the equine ones included), clippy -D warnings and the horizon-client tests pass (the one failing test is main's own, fixed by #3851), and the last commit only reorders an import for rustfmt. Thank you!
2026-10-29 20:31:31 +01:00
Ludovic Marie 28a65af5fc Merge pull request #3848 from horizon-rust/perf/block-light-check-node
🤖 Agent: merging. Block light follows vanilla BlockLightEngine.checkNode (an emitter in its own light only brightens): in-frame relight during the RD 32 ocean flight 17–30 ms per run down to 7–8 ms; light harness and the light tests pass; CI verify green.
2026-10-29 20:26:44 +01:00
Ludovic Marie a6751f2aa9 Merge pull request #2960 from kacpherek/fix/shelf-interaction
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent) on union/pt3-w1 7e05a39a9 (main 62a23ba02 + 15 PRs): pr_ready green (fmt, clippy -D warnings, 1810 tests, doc, content root), parity --changed 192/192, lan_e2e 13 checks on both paths. Mojang rules, supply chain and per-frame cost read.
2026-10-29 20:24:20 +01:00
Ludovic Marie ea60e1e062 Merge pull request #3327 from damnurmum/fix/inline-object-text
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent) on union/pt3-w1 7e05a39a9 (main 62a23ba02 + 15 PRs): pr_ready green (fmt, clippy -D warnings, 1810 tests, doc, content root), parity --changed 192/192, lan_e2e 13 checks on both paths. Mojang rules, supply chain and per-frame cost read.
2026-10-29 20:24:16 +01:00
Ludovic Marie 047815c5d7 Merge pull request #3303 from doodlyyousuf/fix/container-use-swing
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent) on union/pt3-w1 7e05a39a9 (main 62a23ba02 + 15 PRs): pr_ready green (fmt, clippy -D warnings, 1810 tests, doc, content root), parity --changed 192/192, lan_e2e 13 checks on both paths. Mojang rules, supply chain and per-frame cost read.
2026-10-29 20:24:11 +01:00
Ludovic MarieandClaude Opus 5.5 d22aecd526 Format the equine tests' imports
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:20:19 +01:00
Ludovic MarieandClaude Opus 5.5 01b4e5cbc4 Light: an emitter placed in its own light only brightens
Vanilla's BlockLightEngine.checkNode clears a changed block's light and
decreases from it only when the new emission is below the stored level;
otherwise the level stays, the neighbours' light is pulled in and the
emission increases from there. Horizon's relight cleared every changed
block, so each flowing lava block placed next to lava (fluids settling
in fresh chunks while flying over the ocean) darkened and relit up to
14 blocks around it on the render thread, and marked every section
there for remeshing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:13:11 +01:00
Ludovic Marie 7ebf7d26cb Merge pull request #3350 from horizon-rust/draft/remove-wasm-mods-game
🤖 Agent: WebAssembly removal 1/4 (game), merged up to date with main c7065027b. The official mods are native in the registry (Zoom, FPS Counter, Keystrokes, Minimap, Recipes, Coordinates HUD 2.0.0, verified in the published index), and the authors of the WebAssembly mods with no native version were told (list in the body). Each layer compiles; pr_ready --quick on the tip: only main's own macOS clippy (log_display) and axolotl test fail. Parity: 340/340 locally on the previous heads; CI parity on these. Merge before #3351, #3353, #3371.
2026-10-29 19:48:54 +01:00
Ludovic MarieandClaude Opus 5.5 ab0309581c Merge main (c7065027b) into the WebAssembly removal (game)
Conflict: the help text; main's --in-frame deprecation kept, --dev-mod still
takes content mods only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:21:29 +01:00
Ludovic MarieandClaude Opus 5.5 26f099d168 Merge main (d009ebd) into the WebAssembly removal (game)
Conflict: main made chat_tree callable from command blocks, with the
WebAssembly mods for its argument; it stays callable, without them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:47:49 +01:00
Ludovic MarieandClaude Opus 5.5 1fb8e84561 Merge main into the WebAssembly removal (game)
Conflicts: main's changes to mod_render.rs, server_mods.rs and the runtime
part of mods.rs sit in WebAssembly code this branch deletes. Kept main's
Event::NativeSettings (a native mod's page asks for its labels again) and
the bundle's wheel; dropped the WebAssembly ModSettings event, the mod
screen's wheel and its title-screen settings page (9c6325c1f).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:36:42 +01:00
Ludovic MarieandClaude Opus 5.5 38da7d8d6e Merge main into this branch
brush.rs: main's player_generates_container_loot trigger (#33c15a6d7) is
kept in unpack_brush_loot, which now reads the Brushable block entity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:24:59 +01:00
Ludovic MarieandClaude Opus 5.5 5a1c095724 Merge main into this branch
hud.rs: main's name_tag (and its test) and this branch's draw_inline_object
(and its tests) both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:00:51 +01:00
Ludovic MarieandClaude Opus 5.5 bc78cba5b9 Merge main into this branch
remote.rs: main's spectator check and off-hand anchor charge are kept with
this branch's InteractionResult: an anchor charged from the off hand is
SUCCESS (RespawnAnchorBlock.useItemOn) and swings, a jukebox taking a disc
is SUCCESS (JukeboxPlayable.tryInsertIntoJukebox), an anchor left to the
off hand's glowstone is PASS. level/tests.rs: both new tests kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:59:19 +01:00
Ludovic MarieandClaude Opus 5.5 62c0834880 Merge main into this branch (rearing beside the horse finalize tests, one tests module)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:57:27 +01:00
Ludovic MarieandClaude Opus 5.5 98c0883c8f Merge main into this branch; suspicious blocks are not containers
remote.rs: main's spectator check (a spectator's use predicts nothing) and
this branch's shelf prediction are both kept, the spectator first.

machines.rs: the new Brushable block entity made the dispenser harness's
dropper under suspicious sand treat it as an empty container and drop
nothing; BrushableBlockEntity is no Container, so the dropper drops in
front of it as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:55:05 +01:00
Ludovic Marie a7f1f70508 Merge remote-tracking branch 'origin/main' into draft/remove-wasm-mods-game 2026-10-29 13:38:59 +01:00
Ludovic Marie 6e8ee3ed07 Merge remote-tracking branch 'origin/main' into draft/remove-wasm-mods-game
# Conflicts:
#	crates/horizon-client/src/game/mods.rs
2026-10-29 13:23:27 +01:00
damnurmumandCodex a5a0e1bb98 Merge main into inline object text
Resolve HUD text rendering conflict by preserving inline player and atlas objects alongside the new world text shadow and depth behavior.

Co-Authored-By: Codex <[email protected]>
2026-10-29 15:18:19 +03:00
Ludovic MarieandClaude Opus 5.5 a20aaf5396 Text objects: vanilla's default fallback when none is given
ObjectContents.visit uses the object's defaultFallback without one:
[item/porkchop], [porkchop@gui] off the blocks atlas, [Steve head].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:51:40 +01:00
Ludovic MarieandClaude Opus 5.5 1eb34fa68e Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:40 +01:00
d3grandoandClaude Opus 5.5 a30b9525d1 Horses rear when hurt
AbstractHorse.hurtServer had no port: after a hurt that went through,
vanilla draws random.nextInt(3) and on 0 calls standIfPossible, which
rears the horse for 20 ticks unless canPerformRearing is false (llamas,
camels). Mob::damage_with now ends with that roll for every equine, and
make_mad shares the new stand_if_possible.

The stand counter runs down in AbstractHorse.tick, with or without AI,
but Horizon ran it down only in the AI step, so a NoAI horse that reared
from a hit stayed reared for good. A mob with no AI now runs it down too.

The mob_ai harness gets a llama_hurt scenario (the draw without the
rear), which matched for 10 ticks before and all 201 after; the probe
names MountPanic and LlamaHurtBy as vanilla does.

Fixes #3432

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:45:42 +07:00
Ludovic MarieandClaude Opus 5.5 e7614d2197 Game: WebAssembly code mods don't run any more
Horizon's code mods are native now (game/native_hooks.rs, built in by the
launcher). The game drops the WebAssembly runtime and everything around
it: the `mods` feature (wasmtime and cranelift), the mods' hooks, HUD
panels and screens, the item panel and its recipes, input claims, camera
shakes, far terrain, server mods and the extras that moved to WebAssembly
mods. Content mods (blocks, items, data and assets) load as before.

A WebAssembly code mod's row on the Mods page says it doesn't run any more,
or that its native version replaced it. The first world after the update
says once in the chat which stopped and where to get their native
versions; their files stay where they are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:46:57 +01:00
Ludovic MarieandClaude Opus 5.5 cef862471f Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:13 +01:00
Ludovic MarieandClaude Opus 5.5 326719539d Merge main into fix/shelf-interaction
main's #2952 added banner, vault and trial spawner arms to lan::chunks::entity_tag next to this branch's brushable and shelf arms; both stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:32:54 +01:00
damnurmumandCodex dd130f1902 Render player heads and atlas sprites in text components
Parse Minecraft object components into inline spans and draw player heads and atlas sprites with the existing HUD atlas. Resolve head profiles through the skin cache and preserve fallback text for plain output.

Co-Authored-By: Codex <[email protected]>
2026-10-28 22:07:28 +03:00
Muhammad Asif 1a9040ad79 Swing the arm when opening containers
MultiPlayerGameMode.useItemOn swings on InteractionResult.SUCCESS
(SwingSource.CLIENT). The remote client only swung when the held item
was a block, so opening a chest, barrel, furnace, or similar storage
did not start the arm animation.
2026-10-28 22:33:23 +05:00
Kacpherek 42388d2512 Swap items with shelves and powered shelf chains 2026-10-27 22:02:24 +01:00
Kacpherek 2deaab2602 Render items stored in shelves 2026-10-27 22:02:24 +01:00
Kacpherek ca28a0edad Show items revealed by brushing suspicious blocks 2026-10-27 22:02:18 +01:00
56 changed files with 1241 additions and 3172 deletions

No files matched your search

+4 -4
View File
@@ -63,7 +63,7 @@ jobs:
run: |
cargo +stable fmt --all -- --check
# With the game's code mods (on in every release build, off by default).
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
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
@@ -71,11 +71,11 @@ jobs:
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --lib --bins --tests --features horizon-mods/pack
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --lib --features horizon-mods/pack mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc --features horizon-mods/pack
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
+3 -5
View File
@@ -138,8 +138,7 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
@@ -267,9 +266,8 @@ jobs:
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
+106 -2
View File
@@ -1,6 +1,6 @@
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
//! value, `ComponentSerialization` turns it into styled text.
use crate::text::{Style, Text, named_color};
use crate::text::{InlineObject, Style, Text, named_color};
use horizon_world::nbt::Tag;
use serde_json::{Map, Value};
@@ -296,6 +296,10 @@ pub fn to_text_with(
let style = object_style(o);
let content = if let Some(t) = contents(o) {
t
} else if let Some(object) = inline_object(o, resolve, contents) {
let mut text = Text::literal("\u{fffc}");
text.spans[0].style.inline_object = Some(object);
text
} else if let Some(t) = o.get("text") {
match t {
Value::String(s) => Text::literal(s.clone()),
@@ -332,12 +336,74 @@ pub fn to_text_with(
{
out.push(to_text_with(extra, resolve, contents).with_style(&style));
}
out
if o.contains_key("object") || o.contains_key("player") || o.contains_key("sprite") {
out.with_component(Value::Object(o.clone()))
} else {
out
}
}
Value::Null => Text::empty(),
}
}
fn inline_object(
object: &serde_json::Map<String, Value>,
resolve: &mut dyn FnMut(&str) -> Text,
contents: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Option<Text>,
) -> Option<InlineObject> {
let kind = object.get("object").and_then(Value::as_str);
let fallback = object
.get("fallback")
.map(|value| to_text_with(value, resolve, contents).plain());
match kind {
Some("player") | None if object.contains_key("player") => {
let profile = object.get("player")?.clone();
// PlayerSprite.defaultFallback.
let fallback = fallback.unwrap_or_else(|| {
match profile
.as_str()
.or_else(|| profile.get("name").and_then(Value::as_str))
{
Some(name) => format!("[{name} head]"),
None => "[unknown player head]".to_owned(),
}
});
Some(InlineObject::Player {
profile,
hat: object.get("hat").and_then(Value::as_bool).unwrap_or(true),
fallback,
})
}
Some("atlas") | None if object.contains_key("sprite") => {
let atlas = object
.get("atlas")
.and_then(Value::as_str)
.unwrap_or("minecraft:blocks")
.to_owned();
let sprite = object.get("sprite")?.as_str()?.to_owned();
// AtlasSprite.defaultFallback: short names, the atlas unless blocks.
let fallback = fallback.unwrap_or_else(|| {
let short = |id: &str| match id.split_once(':') {
Some(("minecraft", path)) => path.to_owned(),
Some(_) => id.to_owned(),
None => id.to_owned(),
};
if short(&atlas) == "blocks" {
format!("[{}]", short(&sprite))
} else {
format!("[{}@{}]", short(&sprite), short(&atlas))
}
});
Some(InlineObject::Atlas {
atlas,
sprite,
fallback,
})
}
_ => None,
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
@@ -351,6 +417,44 @@ pub fn name_text(tag: &Tag) -> Text {
mod tests {
use super::*;
#[test]
fn player_object_survives_text_parsing_and_keeps_its_siblings() {
let component = serde_json::json!({
"text": "<",
"extra": [
{"object": "player", "player": {"name": "elyapluggin"}, "hat": false,
"fallback": "[head]"},
{"text": " >", "color": "gold"}
]
});
let text = to_text(&component, &mut |_| Text::empty());
assert_eq!(text.plain(), "<[head] >");
assert!(matches!(
&text.spans[1].style.inline_object,
Some(InlineObject::Player { hat: false, .. })
));
assert!(text.spans[2].style.inline_object.is_none());
assert_eq!(text.component(), component);
let sprite = serde_json::json!({"object": "atlas", "sprite": "minecraft:item/porkchop"});
let text = to_text(&sprite, &mut |_| Text::empty());
assert!(matches!(
&text.spans[0].style.inline_object,
Some(InlineObject::Atlas { sprite, .. }) if sprite == "minecraft:item/porkchop"
));
// ObjectContents.visit without a fallback: the sprite's defaultFallback.
assert_eq!(text.plain(), "[item/porkchop]");
let head = serde_json::json!({"object": "player", "player": {"name": "Steve"}});
assert_eq!(
to_text(&head, &mut |_| Text::empty()).plain(),
"[Steve head]"
);
let gui = serde_json::json!({"atlas": "gui", "sprite": "hud/heart/full"});
assert_eq!(
to_text(&gui, &mut |_| Text::empty()).plain(),
"[hud/heart/full@gui]"
);
}
#[test]
fn snbt_and_json_components() {
let input = "{text:'Hi ',color:red,extra:[{text:\"there\",bold:1b}]} rest";
+31 -1
View File
@@ -58,6 +58,28 @@ pub enum ClickEvent {
OpenFile(String),
}
/// A non-character `minecraft:object` embedded in a text component.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InlineObject {
Player {
profile: Value,
hat: bool,
fallback: String,
},
Atlas {
atlas: String,
sprite: String,
fallback: String,
},
}
impl InlineObject {
pub fn fallback(&self) -> &str {
match self {
Self::Player { fallback, .. } | Self::Atlas { fallback, .. } => fallback,
}
}
}
/// Text style; `None` fields inherit from the enclosing style.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Style {
@@ -77,6 +99,8 @@ pub struct Style {
/// The font (`FontDescription.Resource`), a full id like
/// `minecraft:alt`; the default font when unset.
pub font: Option<String>,
/// Content metadata for a single U+FFFC object span; never inherited.
pub inline_object: Option<InlineObject>,
}
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
pub fn font_id(v: &Value) -> Option<String> {
@@ -337,7 +361,13 @@ impl Text {
}
/// Component.getString: the plain characters.
pub fn plain(&self) -> String {
self.spans.iter().map(|s| s.text.as_str()).collect()
self.spans
.iter()
.map(|s| match &s.style.inline_object {
Some(object) if s.text == "\u{fffc}" => object.fallback(),
_ => s.text.as_str(),
})
.collect()
}
pub fn is_empty(&self) -> bool {
self.spans.iter().all(|s| s.text.is_empty())
@@ -74,9 +74,28 @@ pub enum BlockEntity {
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// Suspicious sand and gravel: saved loot and the transient brushed face.
Brushable(Box<Brushable>),
/// A shelf holds three displayed item stacks.
Shelf(Box<Shelf>),
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
#[derive(Clone, Debug, Default)]
pub struct Brushable {
pub loot_table: Option<horizon_world::nbt::Tag>,
pub loot_seed: Option<horizon_world::nbt::Tag>,
pub item: Option<ItemStack>,
/// Direction's legacy network id; not saved to disk by vanilla.
pub hit_direction: Option<i32>,
}
/// `ShelfBlockEntity`'s displayed slots and alignment flag.
#[derive(Clone, Debug, Default)]
pub struct Shelf {
pub items: [Option<ItemStack>; 3],
pub align_to_bottom: bool,
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
+24 -5
View File
@@ -2,8 +2,8 @@
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
@@ -230,6 +230,14 @@ impl BlockEntity {
recipes_used: HashMap::new(),
}));
}
if matches!(
name,
"minecraft:suspicious_sand"
| "minecraft:suspicious_gravel"
| "minecraft:brushable_block"
) {
return Some(Self::Brushable(Box::default()));
}
if name == "minecraft:creaking_heart" {
return Some(Self::CreakingHeart(Box::default()));
}
@@ -292,6 +300,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:shelf" || name.ends_with("_shelf") {
return Some(Self::Shelf(Box::default()));
}
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
@@ -334,8 +345,10 @@ impl BlockEntity {
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Brushable(_) => "minecraft:brushable_block",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
Self::Shelf(_) => "minecraft:shelf",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -354,13 +367,15 @@ impl BlockEntity {
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
| Self::DaylightDetector
| Self::Brushable(_) => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
Self::Shelf(b) => b.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
@@ -376,9 +391,11 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &[],
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::Shelf(b) => &b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -402,9 +419,11 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::Shelf(b) => &mut b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
+5 -6
View File
@@ -72,9 +72,8 @@ pub struct Extras {
pub zoom_smooth: bool,
/// Divide mouse turning by the zoom.
pub zoom_sensitivity: bool,
/// The FPS counter and keystrokes extras are official mods now
/// (`moved_extras`): their old settings stay here until the game has
/// installed the mod for a player who had one on.
/// The FPS counter and keystrokes extras are official mods now: their
/// old settings stay here, kept as they were.
pub fps: bool,
pub fps_corner: Corner,
/// Also show the minimum and 1% low.
@@ -83,8 +82,8 @@ pub struct Extras {
pub keystrokes_corner: Corner,
/// Clicks per second under the mouse buttons.
pub keystrokes_cps: bool,
/// The official mods installed for the extras they replace, by mod id
/// (`movedToMods`): each is done once.
/// The official mods once installed for the extras they replace, by mod
/// id (`movedToMods`).
pub moved: Vec<String>,
pub minimap: bool,
pub minimap_corner: Corner,
@@ -287,7 +286,7 @@ impl Extras {
}
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`native_zoom`). A
/// zoom of 1 leaves it as is.
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
if zoom == 1.0 { vanilla } else { vanilla / zoom }
+3
View File
@@ -896,6 +896,9 @@ impl Font {
std::mem::take(&mut self.cache.borrow_mut().region.pending)
}
pub fn advance(&self, c: char, style: &Style) -> f32 {
if c == '\u{fffc}' && style.inline_object.is_some() {
return 8.0;
}
let g = self.glyph_styled(c, style);
g.advance + if style.is_bold() { g.offset } else { 0.0 }
}
+188 -16
View File
@@ -59,6 +59,8 @@ pub struct HudAtlas {
pub width: u32,
pub height: u32,
pub sprites: HashMap<String, Rect>,
/// Resolved player-profile names and UUIDs for inline head components.
pub object_skins: HashMap<String, String>,
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
/// which puts their particles on vanilla's translucent layer.
pub translucent: HashSet<String>,
@@ -252,6 +254,24 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
out.into_iter().map(|(_, image)| image).collect()
}
impl HudAtlas {
/// Keep a ready profile skin when another source only has its default.
pub fn set_object_skin(&mut self, key: String, skin: String) {
use std::collections::hash_map::Entry;
if self.object_skins.len() >= 1024 && !self.object_skins.contains_key(&key) {
self.object_skins.clear();
}
match self.object_skins.entry(key) {
Entry::Occupied(entry)
if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => {
}
Entry::Occupied(mut entry) => {
entry.insert(skin);
}
Entry::Vacant(entry) => {
entry.insert(skin);
}
}
}
/// A font glyph's atlas cell (8x8 GUI pixels) and advance.
pub fn glyph(&self, ch: char) -> Option<(Rect, f32)> {
let g = self.font.glyph(ch);
@@ -592,6 +612,7 @@ impl HudAtlas {
width,
height,
sprites,
object_skins: HashMap::new(),
translucent,
flags,
sprite_animations,
@@ -641,6 +662,8 @@ pub struct HudBatch {
pub glint_alpha: f32,
/// Screen's first vertex: the preceding HUD is part of its blurred background.
pub blur_at: u32,
/// Player object profiles encountered while painting text this frame.
pub inline_profiles: Vec<serde_json::Value>,
}
/// How a run of HUD vertices blends and tests depth.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -692,6 +715,7 @@ impl HudBatch {
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
blur_at: 0,
inline_profiles: Vec::new(),
}
}
/// Draw the vertices added since `start` with the inverting blend.
@@ -1042,22 +1066,43 @@ impl HudBatch {
let mut cursor = x;
let mut previous = None;
for (i, (ch, style)) in text.chars().enumerate() {
let object = (ch == '\u{fffc}')
.then_some(style.inline_object.as_ref())
.flatten();
if let Some(crate::text::InlineObject::Player { profile, .. }) = object
&& self.inline_profiles.len() < 256
&& !self.inline_profiles.contains(profile)
{
self.inline_profiles.push(profile.clone());
}
// Font.PreparedTextBuilder.accept: lines start 1 left of a
// sequence's first character. Language.getVisualOrder makes a
// sequence of each run of one style, which world text has.
let first = i == 0 || world && previous != Some(style);
previous = Some(style);
// The style's font (`Style.getFont`), the default one unless set.
let mut g = font.glyph_styled(ch, style);
let mut g = if object.is_some() {
crate::font::Glyph {
rect: None,
left: 0.0,
top: 0.0,
right: 0.0,
bottom: 8.0,
advance: 8.0,
offset: 0.0,
}
} else {
font.glyph_styled(ch, style)
};
// Font.StringRenderOutput.accept: obfuscated characters draw a
// random glyph of the same width each frame.
if style.obfuscated == Some(true) && ch != ' ' {
if object.is_none() && style.obfuscated == Some(true) && ch != ' ' {
g = font.random_glyph(style, g.advance);
}
let c = style.color.map_or(color, |rgb| {
rgba((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, alpha)
});
let bold = style.is_bold();
let bold = object.is_none() && style.is_bold();
let advance = g.advance + if bold { g.offset } else { 0.0 };
glyphs.push((
cursor,
@@ -1069,6 +1114,7 @@ impl HudBatch {
style.strikethrough == Some(true),
first,
advance,
object.cloned(),
));
cursor += advance;
}
@@ -1086,20 +1132,26 @@ impl HudBatch {
)
};
for &is_shadow in passes {
for &(gx, g, c, bold, italic, underline, strike, first, advance) in &glyphs {
for (gx, g, c, bold, italic, underline, strike, first, advance, object) in &glyphs {
if let Some(object) = object {
if !is_shadow {
self.draw_inline_object(atlas, object, *gx, y, alpha);
}
continue;
}
// In the world a glyph whose shadow is 0 has none (hasShadow).
let has_shadow = shadow && !(world && shade(c) == 0);
let has_shadow = shadow && !(world && shade(*c) == 0);
if is_shadow && !has_shadow {
continue;
}
let (dx, col) = if is_shadow {
(g.offset, shade(c))
(g.offset, shade(*c))
} else {
(0.0, c)
(0.0, *c)
};
let (px, py) = (gx + dx, y + dx);
let shear = |v: f32| if italic { 1.0 - 0.25 * v } else { 0.0 };
let thick = if bold { 0.1 } else { 0.0 };
let (px, py) = (*gx + dx, y + dx);
let shear = |v: f32| if *italic { 1.0 - 0.25 * v } else { 0.0 };
let thick = if *bold { 0.1 } else { 0.0 };
let draw = |batch: &mut Self, ox: f32| match g.rect {
Some(r) => {
let (x0, x1, y0, y1) = (
@@ -1144,24 +1196,86 @@ impl HudBatch {
let start = self.vertices.len();
draw(self, 0.0);
lift(self, start, base);
if bold {
if *bold {
let start = self.vertices.len();
draw(self, g.offset);
lift(self, start, base + 0.001);
}
let x0 = if first { px - 1.0 } else { px };
let x0 = if *first { px - 1.0 } else { px };
let start = self.vertices.len();
if strike {
self.rect_uv(x0, py + 3.5, px + advance - x0, 1.0, white, col);
if *strike {
self.rect_uv(x0, py + 3.5, px + *advance - x0, 1.0, white, col);
}
if underline {
self.rect_uv(x0, py + 8.0, px + advance - x0, 1.0, white, col);
if *underline {
self.rect_uv(x0, py + 8.0, px + *advance - x0, 1.0, white, col);
}
lift(self, start, base + 0.01);
}
}
cursor - x
}
fn draw_inline_object(
&mut self,
atlas: &HudAtlas,
object: &crate::text::InlineObject,
x: f32,
y: f32,
alpha: u8,
) {
use crate::text::InlineObject;
let tint = rgba(255, 255, 255, alpha);
match object {
InlineObject::Player { profile, hat, .. } => {
let id = crate::skins::parse_profile(profile).id;
let name = inline_profile_name(profile);
let by_id = atlas.object_skins.get(&format!("id:{id:032x}"));
let by_name = name.and_then(|name| {
atlas
.object_skins
.get(&format!("name:{}", name.to_ascii_lowercase()))
});
let resolved = if profile.get("id").is_some() {
by_id.or(by_name)
} else {
by_name.or(by_id)
};
let default = crate::tab_list::default_skin(id);
let skin = resolved
.filter(|path| atlas.sprites.contains_key(*path))
.map_or(default, String::as_str);
let Some(rect) = atlas.sprites.get(skin).copied() else {
return;
};
for u in [8.0, 40.0].into_iter().take(if *hat { 2 } else { 1 }) {
let du = (rect.u1 - rect.u0) / 64.0;
let dv = (rect.v1 - rect.v0) / 64.0;
self.rect_uv(
x,
y,
8.0,
8.0,
Rect {
u0: rect.u0 + du * u,
v0: rect.v0 + dv * 8.0,
u1: rect.u0 + du * (u + 8.0),
v1: rect.v0 + dv * 16.0,
w: 8,
h: 8,
},
tint,
);
}
}
InlineObject::Atlas { sprite, .. } => {
let key = sprite.strip_prefix("minecraft:").unwrap_or(sprite);
if let Some(rect) = atlas.sprites.get(key).copied() {
self.rect_uv(x, y, 8.0, 8.0, rect, tint);
}
}
}
}
/// SubmitNodeCollection.submitNameTag's text in its own space (x right,
/// y down, in GUI pixels): centred at `-Font.width / 2`, drawn as
/// Font.prepareText draws any text (styles, fonts and glyph boxes; no
@@ -1190,6 +1304,13 @@ impl HudBatch {
behind
}
}
fn inline_profile_name(profile: &serde_json::Value) -> Option<&str> {
profile
.as_str()
.or_else(|| profile.get("name").and_then(serde_json::Value::as_str))
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct HudUniform {
@@ -1904,6 +2025,56 @@ mod tests {
assert!(missing.rect.is_none() && missing.right == 5.0);
}
#[test]
fn player_object_draws_an_inline_head() {
let root = two_glyph_font("inline-head");
write_png(&root, "entity/player/slim/alex", 64, 64);
write_png(&root, "entity/player/wide/steve", 64, 64);
let mut atlas = HudAtlas::build(&root).unwrap();
let text = crate::component::to_text(
&serde_json::json!({"object":"player","player":{"id":"00000000-0000-0000-0000-000000000000"}}),
&mut |_| crate::text::Text::empty(),
);
let mut batch = HudBatch::new([320, 240]);
assert_eq!(
batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false),
8.0
);
assert_eq!(batch.vertices.len(), 12); // face and hat, six vertices each
assert_eq!(batch.inline_profiles.len(), 1);
atlas.set_object_skin("name:elyapluggin".into(), "entity/player/wide/steve".into());
let named = crate::component::to_text(
&serde_json::json!({"object":"player","player":{"name":"elyapluggin"}}),
&mut |_| crate::text::Text::empty(),
);
batch.vertices.clear();
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, false);
let skin = atlas.sprites["entity/player/wide/steve"];
assert_eq!(batch.vertices[0].uv[0], skin.u0 + (skin.u1 - skin.u0) / 8.0);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn atlas_object_draws_an_item_sprite_inline() {
let root = two_glyph_font("inline-item");
write_png(&root, "item/porkchop", 16, 16);
let atlas = HudAtlas::build(&root).unwrap();
let text = crate::component::to_text(
&serde_json::json!({
"text": "a",
"extra": [{"object": "atlas", "sprite": "minecraft:item/porkchop"}]
}),
&mut |_| crate::text::Text::empty(),
);
let mut batch = HudBatch::new([320, 240]);
let width = batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false);
assert_eq!(width, atlas.font.glyph('a').advance + 8.0);
assert_eq!(batch.vertices.len(), 12); // glyph and one sprite
let sprite = atlas.sprites["item/porkchop"];
assert_eq!(batch.vertices[6].uv, [sprite.u0, sprite.v0]);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn name_tags_draw_bold_text_and_tall_glyphs_like_any_text() {
// `ascii.png`'s 8x8 'a' (4 pixels wide) and `accented.png`'s 9x12
@@ -2149,6 +2320,7 @@ mod tests {
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
blur_at: 0,
inline_profiles: Vec::new(),
};
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
assert_eq!(
+61
View File
@@ -883,6 +883,24 @@ fn relight_layer(
let Some(old) = level(world, sky, p) else {
continue;
};
// `BlockLightEngine.checkNode`: a block emitting at least the light
// already there keeps it and pulls its neighbours' light in. Clearing
// it would darken and relight everything it lit (flowing lava next to
// lava: thousands of cells per block).
if !sky && !force && old > 0 {
let emission = world
.state(p.0, p.1, p.2)
.map_or(0, |s| tables.cells[s as usize].emission);
if emission >= old {
if emission > old {
set_level(world, sky, p, emission);
changed.push(p);
}
decrease.push_back((p, 0));
increase.push_back(p);
continue;
}
}
if old > 0 {
set_level(world, sky, p, 0);
changed.push(p);
@@ -1089,6 +1107,49 @@ mod tests {
assert!(same(&w, (1, 0)));
assert_eq!(block(&w, 14, 0, 8), 13);
}
/// Emitters placed in light at most as bright as theirs (flowing lava
/// next to lava) only brighten: nothing they lit is darkened and lit
/// again, and the result is the full relight's.
#[test]
fn emitters_placed_in_their_own_light_only_brighten() {
let mut w = world(|c| {
c.set(8, 0, 8, TORCH);
});
let block = |w: &HashMap<(i32, i32), Arc<Chunk>>, p: Pos| {
w[&(p.0 >> 4, p.2 >> 4)].light(p.0 & 15, p.1, p.2 & 15) & 15
};
for (x, y, z) in [(9, 0, 8), (10, 0, 8), (10, 1, 8), (11, 1, 9), (8, 0, 9)] {
let around: Vec<Pos> = (-15..=15)
.flat_map(|dx| {
(-3..=3).flat_map(move |dy| (-15..=15).map(move |dz| (x + dx, y + dy, z + dz)))
})
.collect();
let before: Vec<u8> = around.iter().map(|&p| block(&w, p)).collect();
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, TORCH);
let mut changed = LightChanges::default();
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
for (&p, &was) in around.iter().zip(&before) {
assert!(block(&w, p) >= was, "{p:?} darkened from {was}");
}
}
// An emitter checked again with its block light unchanged changes
// nothing, so no section is remeshed.
let mut changed = LightChanges::default();
relight(&mut w, &tables(), false, &[(8, 0, 8)], false, &mut changed);
assert!(changed.is_empty());
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
});
for (si, s) in light_chunk(&region, &tables(), true).iter().enumerate() {
for i in 0..4096 {
assert_eq!(
s.get(i),
w[&(0, 0)].sections[si].light(i),
"section {si} index {i}"
);
}
}
}
#[test]
fn random_border_relights_match_full_relight() {
// Edits around every border of chunk (0, 0), at the top and bottom of
+1 -5
View File
@@ -36,11 +36,8 @@ pub struct Installed {
pub description: String,
/// Why it is not running, when it failed.
pub problem: Option<String>,
/// Loaded with `--dev-mod`: always on, reloaded when rebuilt.
/// Loaded with `--dev-mod`: always on.
pub dev: bool,
/// The running mod set a page of widgets (capability `ui`): its row
/// gains a Settings button.
pub has_settings: bool,
/// A native mod (`game/native_hooks.rs`): built into the game, on until
/// the player turns it off, and its settings page.
pub native: Option<NativePage>,
@@ -178,7 +175,6 @@ mod tests {
description: String::new(),
problem: None,
dev,
has_settings: false,
native: None,
};
let mut list = ModList {
+6 -16
View File
@@ -1054,8 +1054,7 @@ enum Act {
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
PickShaderPack(usize),
OpenShaderPackFolder,
/// The installed mod's own settings page (capability `ui`), shown as a
/// screen: `Event::ModSettings` has the game open it.
/// The installed native mod's settings page.
ModSettings(usize),
/// A native mod's key (mod, key) waits for a key; a button of its page.
NativeKey(usize, usize),
@@ -1122,9 +1121,6 @@ pub enum Event {
/// A shader pack was picked or turned off: save and apply it.
ShaderPack,
OpenShaderPackFolder,
/// Open the mod's own settings page (capability `ui`): the installed
/// mod's index in `mods.installed`.
ModSettings(usize),
/// Native mod `.0`'s page opened: the game asks the mod for its labels
/// again (they were made with the list, maybe in another language).
NativeSettings(usize),
@@ -1815,9 +1811,9 @@ impl OptionsUi {
label = label.append(" (Dev)");
}
let dead = installed.dev || installed.id.is_empty();
// A running mod with a settings page (capability
// `ui`) gets its own button beside the toggle.
if installed.has_settings || installed.native.is_some() {
// A native mod gets its settings page's button beside
// the toggle.
if installed.native.is_some() {
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
let settings = button(
0.0,
@@ -2874,12 +2870,8 @@ impl OptionsUi {
}
Act::ModSettings(i) => {
events.push(Event::ButtonClick);
if s.mods.installed.get(i).is_some_and(|m| m.native.is_some()) {
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
} else {
events.push(Event::ModSettings(i));
}
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
}
Act::NativeKey(i, k) => {
self.selected_key = Some(NATIVE_KEY);
@@ -3488,7 +3480,6 @@ mod tests {
description: "May show HUD panels.".into(),
problem: id.is_empty().then(|| "Invalid package".into()),
dev,
has_settings: false,
native: None,
});
}
@@ -3556,7 +3547,6 @@ mod tests {
description: String::new(),
problem: None,
dev: false,
has_settings: false,
native: Some(crate::mod_list::NativePage::default()),
});
ui.show(Page::Mods);
-3
View File
@@ -91,9 +91,6 @@ default = ["audio", "controllers"]
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
# (release.yml); the tests and probes don't need it. Content mods load without it.
mods = ["horizon-mods/runtime"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
@@ -234,6 +234,8 @@ fn goal_name(m: &Mob, name: &str) -> String {
"FreezeWhenLookedAt" => "EndermanFreezeWhenLookedAt",
"LookForPlayer" => "EndermanLookForPlayerGoal",
"HealRaiders" => "NearestHealableRaiderTargetGoal",
"MountPanic" => "MountPanicGoal",
"LlamaHurtBy" => "LlamaHurtByTargetGoal",
other => return other.to_owned(),
};
vanilla.to_owned()
+24 -73
View File
@@ -110,14 +110,9 @@ mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mob_sounds;
mod mod_input;
#[cfg(feature = "mods")]
mod mod_panel;
mod mod_render;
mod mods;
mod mount_menu;
mod mounts;
mod moved_extras;
mod moving_pistons;
mod music;
mod names;
@@ -170,10 +165,10 @@ mod screens;
mod sculk;
mod sculk_fixture;
mod server_links;
mod server_mods;
mod server_packs;
mod server_travel;
mod shader_packs;
mod shelf;
mod shield;
mod shoulders;
mod sign_edit;
@@ -326,7 +321,7 @@ struct Options {
dedicated: Option<Vec<String>>,
/// `--benchmark SCENARIO`: run the benchmark, write its report and quit.
benchmark: Option<String>,
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
/// `--dev-mod PATH` (repeatable): content mods always on.
dev_mods: Vec<PathBuf>,
/// Single player runs on an integrated server thread (game/integrated.rs),
/// as vanilla's does: the default. `--in-frame` (or `--no-server-thread`,
@@ -410,7 +405,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
@@ -554,9 +549,6 @@ enum Screen {
DialogWarning,
/// WinScreen: the end poem and the credits (game/win.rs).
Win,
/// A `ui` mod's own screen, or its settings page from Options > Mods
/// (game/mods.rs).
Mod,
}
struct Mining {
pos: [i32; 3],
@@ -4208,9 +4200,6 @@ impl Game<'_> {
correct_tool,
)
});
if removed {
self.mod_block_event(pos, broken, false);
}
if removed && !creative {
let held = self.held().cloned();
self.mine_block_with_held(&facts.name, facts.hardness);
@@ -4412,6 +4401,14 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if let Some(swings) = self.use_shelf(hit) {
let used = held.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
self.trigger_block("minecraft:item_used_on_block", hit.pos, &used);
if swings {
self.swing_arm();
}
return Ok(true);
}
if let Some(swings) = self.use_chiseled_bookshelf(hit, held.as_ref()) {
if swings {
self.swing_arm();
@@ -4673,7 +4670,6 @@ impl Game<'_> {
};
let target = [pos.x, pos.y, pos.z];
let state = self.apply_item_block_state(target, state, &stack)?;
self.mod_block_event(target, state, true);
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
let key = (target[0], target[1], target[2]);
self.world.block_entities.insert(key, entity);
@@ -5315,9 +5311,8 @@ impl Game<'_> {
self.tick_totem();
self.update_totem_ready();
self.tick_command_extras();
self.tick_mods();
self.native_tick();
self.tick_server_mods();
self.tell_native_now();
let camera_eye = self.camera_eye_height();
let stick = self.pad_move();
if self.riding.is_some() && self.player.is_some() {
@@ -6195,6 +6190,7 @@ impl Game<'_> {
self.frame_graph
.draw(self.settings.horizon.overlay, &mut batch, &self.gui.atlas);
self.draw_pad_cursor(&mut batch);
self.resolve_inline_profiles(&batch.inline_profiles);
batch
}
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
@@ -6208,11 +6204,6 @@ impl Game<'_> {
self.draw_options(&mut batch);
return batch;
}
// A mod's settings page opened from Options > Mods at the title.
if self.screen == Screen::Mod && self.title.is_some() {
self.draw_mod_screen(&mut batch);
return batch;
}
if let Some(title) = &mut self.title {
title.poll_backup();
if let Some(sound) = title.update_toast(self.settings.notification_display_time) {
@@ -6305,7 +6296,6 @@ impl Game<'_> {
// Hud: the action bar and titles draw over the hotbar (nextStratum).
self.draw_boss_bars(&mut batch);
self.draw_extras(&mut batch);
self.draw_mod_shapes(&mut batch);
}
if show_hud {
// Hud.nextContextualInfoState: the jump bar, the experience bar or
@@ -6484,7 +6474,6 @@ impl Game<'_> {
self.draw_creative(&mut batch);
}
Screen::Chat => self.draw_chat_screen(&mut batch),
Screen::Mod => self.draw_mod_screen(&mut batch),
Screen::Playing | Screen::Options | Screen::Win => {}
}
self.draw_saving_indicator(&mut batch);
@@ -6552,7 +6541,9 @@ impl Game<'_> {
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_),
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
| BlockEntity::Shelf(_),
) => {}
None => {
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
@@ -6836,7 +6827,9 @@ impl Game<'_> {
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_),
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
| BlockEntity::Shelf(_),
) => {}
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
None => {
@@ -8316,6 +8309,8 @@ impl Game<'_> {
// `CampfireRenderer`.
self.mark_entity(out.len(), "");
self.campfire_geometry(camera, &mut out);
self.brushable_geometry(camera, &mut out);
self.shelf_geometry(camera, &mut out);
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
// Mobs and their projectiles (a span each).
@@ -12043,7 +12038,6 @@ impl Game<'_> {
self.pitch,
),
};
let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch);
self.audio
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
let block = [
@@ -12498,7 +12492,7 @@ impl Game<'_> {
|| self.remote_loading();
let blurred = loading
|| self.title.as_ref().is_some_and(|t| {
matches!(self.screen, Screen::Options | Screen::Mod | Screen::Win)
matches!(self.screen, Screen::Options | Screen::Win)
|| !matches!(t.mode, horizon_client::title::Mode::Main)
})
|| (self.title.is_none()
@@ -12557,9 +12551,6 @@ impl Game<'_> {
}
return Ok(());
}
if self.screen == Screen::Mod && self.mod_screen_key(key) {
return Ok(());
}
if self.sort_key_press(key) {
return Ok(());
}
@@ -12597,7 +12588,6 @@ impl Game<'_> {
Screen::CommandBlock => self.close_command_block(),
Screen::Chat => self.close_chat(),
Screen::Win => self.close_win_screen()?,
Screen::Mod => self.close_mod_screen(true),
},
KeyCode::Digit1
| KeyCode::Digit2
@@ -12727,10 +12717,6 @@ impl Game<'_> {
}
/// A mouse button while playing (window or control socket).
fn play_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
// A mod's claimed item takes the use button (capability `input`).
if self.mod_use(button, pressed) {
return Ok(());
}
if self.sleeping.is_some() {
return Ok(());
}
@@ -12798,19 +12784,11 @@ impl Game<'_> {
}
Ok(())
}
/// LOD Distance and Far Detail Reduced as chosen: the player's LOD
/// Distance when it is on, else a mod's far terrain
/// (`render.set-far-terrain`).
/// LOD Distance and Far Detail Reduced as the player chose them.
fn far_terrain_choice(&self) -> (u32, bool) {
let g = &self.settings.horizon;
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
if g.lod_distance > 0 {
return (g.lod_distance, reduced);
}
match self.mod_far_terrain() {
Some(f) => (f.distance, reduced || f.reduced),
None => (0, reduced),
}
(g.lod_distance, reduced)
}
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
fn lod_distance(&self) -> u32 {
@@ -13319,7 +13297,6 @@ impl ApplicationHandler for Game<'_> {
&& self.sleeping.is_none()
&& self.player.is_some()
&& !self.native_wheel_event(d)
&& !self.mod_wheel(d)
&& let Some((wheel, (_, vertical_steps))) =
self.mouse
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
@@ -13351,9 +13328,6 @@ impl ApplicationHandler for Game<'_> {
if self.screen == Screen::Options && d != 0.0 {
self.options_scroll(d);
}
if self.screen == Screen::Mod && d != 0.0 {
self.mod_screen_scroll(d);
}
// AbstractContainerScreen.mouseScrolled: a bundle under the
// cursor takes the wheel before the screen's own scrolling.
let bundle = bundles::takes_bundle_wheel(self.screen)
@@ -13415,13 +13389,6 @@ impl ApplicationHandler for Game<'_> {
}
}
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
WindowEvent::MouseInput { state, button, .. }
if self.screen == Screen::Mod && self.title.is_some() =>
{
if state == ElementState::Pressed {
self.mod_screen_click(button);
}
}
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
if self.options_waiting_for_key() && state == ElementState::Pressed {
self.options_bind_mouse(button)?;
@@ -13543,7 +13510,6 @@ impl ApplicationHandler for Game<'_> {
return Ok(());
}
self.extras_mouse(button, pressed);
self.mod_mouse_key(button, pressed);
if pressed
&& self.settings.keys.get("key.smoothCamera")
== horizon_client::keybinds::mouse_name(button)
@@ -13646,7 +13612,6 @@ impl ApplicationHandler for Game<'_> {
self.stats_click(size);
}
Screen::Stats if button == MouseButton::Left => self.stats_release(),
Screen::Mod if pressed => self.mod_screen_click(button),
_ => {}
}
}
@@ -13792,16 +13757,6 @@ impl ApplicationHandler for Game<'_> {
self.options_key(key, event.text.as_deref())?;
}
}
// A mod's settings page at the title takes its keys like Options.
WindowEvent::KeyboardInput { event, .. }
if self.screen == Screen::Mod && self.title.is_some() =>
{
if event.state == ElementState::Pressed
&& let PhysicalKey::Code(key) = event.physical_key
{
self.key_pressed(key)?;
}
}
WindowEvent::KeyboardInput { event, .. } if self.title.is_some() => {
if event.state == ElementState::Pressed {
if let Some(shortcut) = self.edit_shortcut(&event) {
@@ -13853,7 +13808,6 @@ impl ApplicationHandler for Game<'_> {
let PhysicalKey::Code(key) = event.physical_key else {
return Ok(());
};
self.mod_key(key, event.state == ElementState::Pressed, event.repeat);
self.native_key_event(key, event.state == ElementState::Pressed, event.repeat);
// key.smoothCamera toggles the cinematic camera.
if self.screen == Screen::Playing
@@ -13958,8 +13912,6 @@ impl ApplicationHandler for Game<'_> {
}
}
self.extras_frame(elapsed);
self.mods_frame(elapsed);
self.poll_moved_extras();
self.extras_retry()?;
self.apply_mouse_look();
self.pad_look(elapsed.as_secs_f32());
@@ -15449,7 +15401,6 @@ fn start_game<'a>(
// Benchmarks measure the game alone.
if game.options.benchmark.is_none() {
game.mods.dev = game.options.dev_mods.clone();
game.start_moved_extras();
game.start_mods();
}
game.start_agent_control()?;
-1
View File
@@ -424,7 +424,6 @@ impl Game<'_> {
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
+1
View File
@@ -801,6 +801,7 @@ impl Game<'_> {
self.screen = super::Screen::Inventory;
self.capture(false);
self.right = false;
self.swing_arm();
return true;
}
if sneaking
+94 -6
View File
@@ -1,8 +1,11 @@
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
//! entity (like a decorated pot's) until the first counted stroke rolls it.
use super::{Game, Hit, decorated_pot, spill, view_vector};
use super::{Game, Hit, decorated_pot, view_vector};
use horizon_client::{
block_entity_render::vertex_light,
containers::BlockEntity,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
save,
};
@@ -114,6 +117,20 @@ impl Game<'_> {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
let direction = match brush.hit_face.unwrap() {
[0, -1, 0] => 0,
[0, 1, 0] => 1,
[0, 0, -1] => 2,
[0, 0, 1] => 3,
[-1, 0, 0] => 4,
[1, 0, 0] => 5,
_ => return false,
};
if let Some(BlockEntity::Brushable(b)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
b.hit_direction = Some(direction);
}
brush.resets_at = time + 40;
if time < brush.cooldown_ends_at {
self.brushing.insert(pos, brush);
@@ -139,13 +156,19 @@ impl Game<'_> {
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
/// block entity's item.
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get(&key) else {
return;
};
let Some(horizon_world::nbt::Tag::String(table)) = tag.get("LootTable") else {
let Some(horizon_world::nbt::Tag::String(table)) = &b.loot_table else {
return;
};
let table = table.to_string_lossy();
let mut tag = save::encode_block_entity(
key,
&BlockEntity::Brushable(b.clone()),
"minecraft:brushable_block",
);
self.trigger_loot_table(&table);
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
decorated_pot::unpack_loot(
@@ -155,7 +178,12 @@ impl Game<'_> {
&mut self.rand_sequences,
self.level.seed,
);
spill::put_saved_entity(&mut self.world, pos, tag);
if let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get_mut(&key) {
b.loot_table = tag.get("LootTable").cloned();
b.loot_seed = tag.get("LootTableSeed").cloned();
b.item = tag.get("item").and_then(save::item_from_nbt);
}
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
@@ -168,8 +196,10 @@ impl Game<'_> {
_ => "minecraft:air",
};
// dropContent: the item at the middle of the block beside that face.
let item = spill::saved_entity(&self.world, pos)
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
let item = match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
Some(BlockEntity::Brushable(b)) => b.item.clone(),
_ => None,
};
if let Some(mut item) = item {
let face = face.unwrap_or([0, 1, 0]);
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
@@ -223,12 +253,70 @@ impl Game<'_> {
if brush.count != 0 {
self.brushing.insert(pos, brush);
self.schedule_brushable_tick(pos);
} else if let Some(BlockEntity::Brushable(b)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
b.hit_direction = None;
}
if before != after {
self.set_dusted(pos, after);
}
}
/// `BrushableBlockRenderer`: reveal the item from the first brushed face.
pub(super) fn brushable_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
use horizon_assets::item_model::DisplayContext;
for (key, entity) in &self.world.block_entities {
let BlockEntity::Brushable(b) = entity else {
continue;
};
let (Some(item), Some(face)) = (&b.item, b.hit_direction) else {
continue;
};
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
if at.distance_squared(camera) >= 64.0 * 64.0 {
continue;
}
let state = self.world.block(key.0, key.1, key.2);
let progress = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("dusted"))
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(0);
if progress == 0 {
continue;
}
let offset = f32::from(progress) * 0.075;
let (translation, adjacent, east_west) = match face {
0 => (glam::Vec3::new(0.5, -0.23 - offset, 0.5), [0, -1, 0], false),
1 => (glam::Vec3::new(0.5, 0.25 + offset, 0.5), [0, 1, 0], false),
2 => (glam::Vec3::new(0.5, 0.0, 0.25 - offset), [0, 0, -1], false),
3 => (glam::Vec3::new(0.5, 0.0, 0.73 + offset), [0, 0, 1], false),
4 => (glam::Vec3::new(0.25 - offset, 0.0, 0.5), [-1, 0, 0], true),
5 => (glam::Vec3::new(0.73 + offset, 0.0, 0.5), [1, 0, 0], true),
_ => continue,
};
let item = self.resolve_item(item, DisplayContext::Fixed, None, 0);
if item.is_empty() {
continue;
}
let pose = glam::Mat4::from_translation(
(at - camera).as_vec3() + glam::Vec3::new(0.0, 0.5, 0.0) + translation,
) * glam::Mat4::from_rotation_y(
(86.0_f32 + if east_west { 90.0 } else { 0.0 }).to_radians(),
) * glam::Mat4::from_scale(glam::Vec3::splat(0.5));
let light = self.world.light(
key.0 + adjacent[0],
key.1 + adjacent[1],
key.2 + adjacent[2],
);
self.submit_item_world(&item, pose, vertex_light(light), out);
}
}
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
/// and break particles.
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
+16 -26
View File
@@ -20,13 +20,12 @@ use std::{
/// The tree the chat parses and completes with: a server's, once it has
/// sent one (ClientPacketListener.commands), else the game's own.
pub(super) fn chat_tree<'a>(
remote: &'a Option<super::remote::Remote>,
mods: &'a super::mods::ModsState,
) -> &'a horizon_client::command::Dispatcher {
pub(super) fn chat_tree(
remote: &Option<super::remote::Remote>,
) -> &horizon_client::command::Dispatcher {
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
Some(tree) => mods.remote_dispatcher().unwrap_or(tree),
None => mods.dispatcher(),
Some(tree) => tree,
None => horizon_client::command::vanilla::game_dispatcher(),
}
}
use winit::{
@@ -479,7 +478,7 @@ impl Game<'_> {
let b = HudBatch::new(size);
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
let mut screen = ChatScreen::new(
@@ -579,13 +578,12 @@ impl Game<'_> {
}
}
/// A line on the window's own chat when the Chat option lets `source`
/// through; running mods hear it.
/// through.
fn local_chat_line(&mut self, text: Text, indicator: Option<u32>, source: ChatSource) {
if !self.ui.chat.shows(source) {
return;
}
let focused = self.screen == Screen::Chat;
let heard = self.mods.running().then(|| text.plain());
self.ui.chat.add(
&self.gui.atlas,
text,
@@ -594,16 +592,13 @@ impl Game<'_> {
self.ticks,
focused,
);
if let Some(line) = heard {
self.mods_chat(&line);
}
}
/// Keys while the chat screen is open.
pub(super) fn chat_key(&mut self, event: &KeyEvent) {
if let Some(shortcut) = self.edit_shortcut(event) {
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -649,7 +644,7 @@ impl Game<'_> {
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
@@ -689,7 +684,7 @@ impl Game<'_> {
) {
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -700,7 +695,7 @@ impl Game<'_> {
pub(super) fn chat_type(&mut self, text: &str) {
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -727,8 +722,8 @@ impl Game<'_> {
return;
}
if let Some(command) = message.strip_prefix('/') {
// A client mod's command first, then a server mod's, as on a server.
if !self.client_mod_command(command) && !self.local_server_mod_command(command) {
// A native mod's command first.
if !self.client_mod_command(command) {
self.run_command(command);
}
} else {
@@ -737,7 +732,6 @@ impl Game<'_> {
&[self.player_display_name(), Text::literal(message)],
);
let focused = self.screen == Screen::Chat;
let heard = self.mods.running().then(|| line.plain());
self.ui.chat.add(
&self.gui.atlas,
line,
@@ -746,11 +740,7 @@ impl Game<'_> {
self.ticks,
focused,
);
if let Some(line) = heard {
self.mods_chat(&line);
}
self.lan_host_chat(message);
self.mod_player_chat(super::server_mods::Who::Local, message);
}
}
pub(super) fn chat_scroll(&mut self, amount: f32) {
@@ -777,7 +767,7 @@ impl Game<'_> {
});
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if self.ui.in_bed_chat && self.sleeping.is_some() {
@@ -922,7 +912,7 @@ impl Game<'_> {
}
if self.remote.is_some() {
self.send_chat(&format!("/{command}"));
} else if !self.local_server_mod_command(command) {
} else {
self.run_command(command);
}
}
@@ -942,7 +932,7 @@ impl Game<'_> {
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
screen.resize(&m, &c, batch.width as i32, batch.height as i32);
@@ -458,7 +458,7 @@ impl Game<'_> {
let (w, h) = self.gui_size();
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
let mut command = ChatScreen::command_block(&m, &c, &block.command, w as i32, h as i32);
@@ -533,7 +533,7 @@ impl Game<'_> {
// The command box, then its suggestions over the widgets.
let m = Measure(&atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
s.command.resize(&m, &c, w as i32, h as i32);
@@ -566,7 +566,7 @@ impl Game<'_> {
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
s.command.server_suggestions(&m, &c, id, range, list);
@@ -580,7 +580,7 @@ impl Game<'_> {
{
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
let (x, y) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
@@ -653,7 +653,7 @@ impl Game<'_> {
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
if s.command.suggestions_key(&m, &c, key, shift) {
@@ -700,7 +700,7 @@ impl Game<'_> {
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
s.command.type_text(&m, &c, &allowed);
+1 -9
View File
@@ -339,13 +339,10 @@ impl Game<'_> {
self.debug_key(key, KeyAction::Release);
}
}
// Mods' keys (capability `input`) see the key as the keyboard
// hook does.
// Native mods see the key as the keyboard hook does.
let action = arg(2)?;
self.mod_key(physical, action != "up", action == "repeat");
self.native_key_event(physical, action != "up", action == "repeat");
if action == "tap" {
self.mod_key(physical, false, false);
self.native_key_event(physical, false, false);
}
// Text screens consume raw presses; releases follow key binds.
@@ -425,19 +422,15 @@ impl Game<'_> {
match arg(2)? {
"down" => {
self.extras_mouse(button, true);
self.mod_mouse_key(button, true);
self.play_mouse(button, true)?
}
"up" => {
self.extras_mouse(button, false);
self.mod_mouse_key(button, false);
self.play_mouse(button, false)?
}
"click" => {
self.extras_mouse(button, true);
self.mod_mouse_key(button, true);
self.extras_mouse(button, false);
self.mod_mouse_key(button, false);
self.play_mouse(button, true)?;
self.play_mouse(button, false)?;
}
@@ -898,7 +891,6 @@ impl Game<'_> {
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
}
}
+9 -27
View File
@@ -1,7 +1,7 @@
//! Horizon's extras in the game (`docs/extras.md`); a mod's minimap
//! (`render.set-minimap`: its tiles, image and HUD) and zoom (`render.set-zoom`) on the camera; clicks
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
//! Each hook does nothing while its extra is off and no mod runs.
//! Horizon's extras in the game (`docs/extras.md`); a native mod's minimap
//! (`native_minimap`: its tiles, image and HUD) and zoom (`native_zoom`) on the camera; clicks
//! and frame times for mods (`native_clicks`, `native_frame_rate`).
//! Each hook does nothing while its extra is off and no mod uses it.
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
use horizon_client::{
extras::{Corner, Cps, FpsCounter, fov_target},
@@ -124,7 +124,7 @@ impl Game<'_> {
}
Ok(())
}
/// The minimap a mod shows (`render.set-minimap`): its corner, its side
/// The minimap a mod shows (`native_minimap`): its corner, its side
/// in GUI pixels and its view of the player.
fn minimap_shown(&self) -> Option<(Corner, f32, View)> {
let p = self.player.as_ref()?;
@@ -142,20 +142,9 @@ impl Game<'_> {
};
Some((m.corner, m.size, view))
}
/// The minimap shown: a native mod's (`native_minimap`), else a
/// WebAssembly mod's.
/// The minimap shown: a native mod's (`native_minimap`).
fn minimap_wanted(&self) -> Option<NativeMinimap> {
if let Some(m) = self.native_minimap_now() {
return Some(m);
}
let m = self.mod_minimap()?;
Some(NativeMinimap {
corner: m.corner,
size: m.size,
blocks_per_pixel: m.blocks_per_pixel,
rotate: m.rotate,
round: m.round,
})
self.native_minimap_now()
}
/// Refresh the minimap's tiles within the frame budget, recompose its
/// image when the view or a tile changed, and queue the upload.
@@ -209,13 +198,10 @@ impl Game<'_> {
rgba: self.extras.minimap.image.clone(),
});
}
/// A mod's zoom in effect (`render.set-zoom`): (factor, smooth, turning
/// A mod's zoom in effect (`native_zoom`): (factor, smooth, turning
/// scale); none is 1.
fn zoom_now(&self) -> (f32, bool, f64) {
if let Some(z) = self.native_zoom_now() {
return (z.factor, z.smooth, f64::from(z.turn));
}
self.mod_zoom().map_or((1.0, true, 1.0), |z| {
self.native_zoom_now().map_or((1.0, true, 1.0), |z| {
(z.factor, z.smooth, f64::from(z.turn))
})
}
@@ -240,7 +226,6 @@ impl Game<'_> {
/// The minimap and mods' corner panels, in the HUD layer (hidden with F1
/// by the caller). The top left corner gives way to the debug screen.
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
let mod_panels = self.mods.has_panels();
let map = self.minimap_shown();
let visible = |corner| !(self.debug && corner == Corner::TopLeft);
let mut corners = Corners::new(batch.width, batch.height, self.effect_rows_height());
@@ -264,9 +249,6 @@ impl Game<'_> {
};
extras_hud::draw_minimap(batch, atlas, &mut corners, &look);
}
if mod_panels {
self.draw_mod_panels(batch, &mut corners);
}
self.native_hud(batch, &mut corners);
}
/// The height the status effect icons take at the top right
+1 -3
View File
@@ -1608,9 +1608,7 @@ impl Game<'_> {
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
self.lan_log_event(event);
}
HostEvent::PlayerChat { sender, text } => {
self.mod_player_chat(super::server_mods::Who::Uuid(sender), &text);
}
HostEvent::PlayerChat { .. } => {}
HostEvent::Joined(conn) => {
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
if self.options.dedicated.is_some()
@@ -1901,10 +1901,6 @@ impl Game<'_> {
);
return;
}
// A server mod's command, for every player whatever their level.
if self.guest_server_mod_command(host, conn, command.trim_start_matches('/')) {
return;
}
// An integrated server runs every player's commands at their own
// permission level (`CommandSourceStack.permissions`); a LAN world
// follows its Allow Commands, and a dedicated server leaves it to
-13
View File
@@ -112,17 +112,6 @@ pub(super) struct Levels {
}
impl Levels {
/// The chunks of each level not in place (capability `world` lends
/// them to server mods for a call).
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
pub(super) fn parked_chunks(
&mut self,
) -> impl Iterator<Item = (Dim, &mut horizon_client::chunk_map::ChunkMap)> {
self.parked
.iter_mut()
.map(|(d, p)| (*d, &mut p.world.chunks))
}
/// The level not in place, in `dim`.
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
self.parked.get_mut(&dim)
@@ -359,8 +348,6 @@ impl Game<'_> {
};
self.update_dedicated_streaming();
self.update_world();
// What server mods set in this level since its last tick.
self.apply_mod_block_writes();
result = if paused {
self.tick_lan(true);
Ok(())
+19 -7
View File
@@ -151,10 +151,14 @@ impl Containers<'_> {
});
}
let entity = self.world.block_entities.get(&pos)?;
// A beehive keeps bees, not items, and a campfire cooks its own: neither
// is a `Container` (HopperBlockEntity.getContainerAt), so a dropper drops
// in front of them and a hopper leaves them alone.
if matches!(entity, BlockEntity::Beehive(_) | BlockEntity::Campfire(_)) {
// A beehive keeps bees, not items, a campfire cooks its own and
// suspicious sand or gravel hides its loot: none is a `Container`
// (HopperBlockEntity.getContainerAt), so a dropper drops in front of
// them and a hopper leaves them alone.
if matches!(
entity,
BlockEntity::Beehive(_) | BlockEntity::Campfire(_) | BlockEntity::Brushable(_)
) {
return None;
}
let kind = match (entity, name) {
@@ -422,15 +426,17 @@ pub(super) fn shelf_signal(
_ => 0,
});
}
let tag = super::spill::saved_entity(world, [pos.x, pos.y, pos.z]);
// Only read from the back: a bit for each of the three slots in use.
let facing = info.properties.get("facing").and_then(|f| dir_from_name(f));
if facing.map(Direction::opposite) != Some(direction) {
return Some(0);
}
let items = horizon_client::save::decode_items(tag.as_ref().and_then(|t| t.get("Items")), 3);
let Some(BlockEntity::Shelf(shelf)) = world.block_entities.get(&(pos.x, pos.y, pos.z)) else {
return Some(0);
};
Some(
items
shelf
.items
.iter()
.enumerate()
.map(|(slot, item)| i32::from(item.is_some()) << slot)
@@ -473,6 +479,12 @@ impl Game<'_> {
return;
}
self.chiseled_bookshelf_synced(pos);
if matches!(
self.world.block_entities.get(&pos),
Some(BlockEntity::Shelf(_))
) {
self.mark_block_entity_updated(pos);
}
// A double chest's other half changes with it.
let halves: Vec<Pos> = std::iter::once(pos)
.chain(self.chest_other_half(pos))
-212
View File
@@ -1,212 +0,0 @@
//! Capability `input` (docs/mods.md): the use button on items a mod claims,
//! the mod's own keys and the mouse wheel; and `render`'s zoom. With no mod
//! claiming items, keys or the wheel, each hook here returns after one
//! check.
use super::Game;
use winit::{event::MouseButton, keyboard::KeyCode};
#[cfg(feature = "mods")]
use {super::Screen, horizon_mods::HeldItem};
/// A claimed item's use while the button is down.
#[cfg(feature = "mods")]
pub(super) struct ModUse {
item: HeldItem,
/// `Game::ticks` when it started.
since: u64,
}
#[cfg(not(feature = "mods"))]
impl Game<'_> {
pub(super) fn mod_use(&mut self, _button: MouseButton, _pressed: bool) -> bool {
false
}
pub(super) fn mod_key(&mut self, _key: KeyCode, _pressed: bool, _repeat: bool) {}
pub(super) fn mod_mouse_key(&mut self, _button: MouseButton, _pressed: bool) {}
pub(super) fn mod_wheel(&mut self, _steps: f32) -> bool {
false
}
pub(super) fn mod_zoom(&self) -> Option<ModZoom> {
None
}
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
None
}
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
None
}
}
/// A mod's zoom (`render.set-zoom`), clamped by the runtime.
#[derive(Clone, Copy)]
pub(super) struct ModZoom {
pub(super) factor: f32,
pub(super) smooth: bool,
pub(super) turn: f32,
}
/// A mod's minimap (`render.set-minimap`), clamped by the runtime.
#[derive(Clone, Copy)]
pub(super) struct ModMinimap {
pub(super) corner: horizon_client::extras::Corner,
pub(super) size: f32,
pub(super) blocks_per_pixel: f32,
pub(super) rotate: bool,
pub(super) round: bool,
}
/// A mod's far terrain (`render.set-far-terrain`), clamped by the runtime.
#[derive(Clone, Copy)]
pub(super) struct ModFarTerrain {
pub(super) distance: u32,
pub(super) reduced: bool,
}
#[cfg(feature = "mods")]
impl Game<'_> {
/// The main hand's stack as `input` shows it; none when it is empty.
pub(super) fn mod_held(&self) -> Option<HeldItem> {
let stack = self.inventory.get(self.selected)?.as_ref()?;
Some(HeldItem {
id: stack.item.to_string(),
count: stack.count,
slot: self.selected as u32,
})
}
/// The use button while playing: true when a mod claimed the held item,
/// so the game does nothing else with it (no placing, eating, swing).
pub(super) fn mod_use(&mut self, button: MouseButton, pressed: bool) -> bool {
if button != MouseButton::Right || !self.mods.mods.claims_any() {
return false;
}
if !pressed {
return self.stop_mod_use();
}
let Some(item) = self.mod_held().filter(|h| self.mods.mods.claims(&h.id)) else {
return false;
};
self.mods.mods.use_start(&item);
self.mods.using = Some(ModUse {
item,
since: self.ticks,
});
true
}
/// Ends a claimed use: the button came up, the player switched slots,
/// or a screen opened. False when none was going.
pub(super) fn stop_mod_use(&mut self) -> bool {
let Some(u) = self.mods.using.take() else {
return false;
};
let ticks = self.ticks.saturating_sub(u.since).min(u64::from(u32::MAX)) as u32;
self.mods.mods.use_stop(&u.item, ticks);
true
}
/// Once a tick: a claimed use stops when its item leaves the hand or a
/// screen opens, as vanilla's `releaseUsingItem`.
pub(super) fn tick_mod_use(&mut self) {
let Some(u) = &self.mods.using else {
return;
};
let still = self
.mod_held()
.is_some_and(|h| h.id == u.item.id && h.slot == u.item.slot);
if !still || self.screen != Screen::Playing {
self.stop_mod_use();
}
}
/// A key went down or up: a mod's registered key while playing (no
/// screen) is its `key-down` / `key-up`. Vanilla's mapping on the same
/// key still runs.
pub(super) fn mod_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
if repeat || !self.mods.mods.has_keys() {
return;
}
// Over an item on a container screen, the mods' keys are `browser`
// events (a recipe viewer's R and U).
if pressed
&& self.screen == Screen::Inventory
&& self.title.is_none()
&& !self.mod_browser_typing()
&& let Some(name) = horizon_client::keybinds::key_name(key)
&& let Some(stack) = self.mod_browser_pointed()
{
let stack = super::mod_panel::browser_stack(stack);
self.mods.mods.browser_key(name, &stack);
return;
}
if pressed && (self.screen != Screen::Playing || self.title.is_some()) {
return;
}
if let Some(name) = horizon_client::keybinds::key_name(key) {
self.mods.mods.key(name, pressed);
}
}
/// A mouse button while playing: a mod's key bound to it
/// (`key.mouse.4`...), as `mod_key`.
pub(super) fn mod_mouse_key(&mut self, button: MouseButton, pressed: bool) {
if self.mods.mods.has_keys() {
let name = horizon_client::keybinds::mouse_name(button);
self.mods.mods.key(&name, pressed);
}
}
/// Wheel steps while playing: true when a mod holds the wheel, so the
/// hotbar doesn't move.
pub(super) fn mod_wheel(&mut self, steps: f32) -> bool {
self.mods.mods.wheel(steps)
}
/// Playing, with nothing over the world: mods' keys and zoom apply.
fn mod_playing(&self) -> bool {
self.screen == Screen::Playing
&& self.title.is_none()
&& self.sign_edit.is_none()
&& self.book.is_none()
}
/// Once a frame, before the mods run: a screen that opened lets their
/// held keys up.
pub(super) fn mod_input_frame(&mut self) {
let playing = self.mod_playing();
if self.mods.playing && !playing {
self.mods.mods.release_keys();
}
self.mods.playing = playing;
}
/// The strongest zoom a mod asks for, while playing; the spyglass's
/// own view wins.
pub(super) fn mod_zoom(&self) -> Option<ModZoom> {
if !self.mod_playing() || self.scoping() {
return None;
}
let z = self.mods.mods.zoom()?;
Some(ModZoom {
factor: z.factor,
smooth: z.smooth,
turn: z.turn,
})
}
/// The minimap the first mod showing one asks for.
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
use horizon_client::extras::Corner;
use horizon_mods::RenderCorner;
let m = self.mods.mods.minimap()?;
Some(ModMinimap {
corner: match m.corner {
RenderCorner::TopLeft => Corner::TopLeft,
RenderCorner::TopRight => Corner::TopRight,
RenderCorner::BottomLeft => Corner::BottomLeft,
RenderCorner::BottomRight => Corner::BottomRight,
},
size: m.size,
blocks_per_pixel: m.blocks_per_pixel,
rotate: m.rotate,
round: m.round,
})
}
/// The far terrain the first mod drawing one asks for.
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
let f = self.mods.mods.far_terrain()?;
Some(ModFarTerrain {
distance: f.distance,
reduced: f.reduced,
})
}
}
-114
View File
@@ -1,114 +0,0 @@
//! Capability `ui`'s item panel of a WebAssembly mod (WIT `interface
//! browser`, docs/mods.md): its items and recipe page as the native panel's
//! (`native_panel.rs`), and the panel's events back in the runtime's types.
use super::{
Game,
native_hooks::{
NativePanelEvent as BrowserEvent, NativeRecipeLayout, NativeRecipeView,
NativeStack as BrowserStack,
},
native_panel::{Owner, Shown},
native_panel_recipes::RecipePage,
};
use horizon_mods as m;
/// A WebAssembly mod's panel, converted once per `set-items` or
/// `set-recipes`.
impl Game<'_> {
pub(super) fn panel_wasm_sync(&mut self) {
let Some((index, version, items)) = self.mods.mods.browser_items() else {
self.panel.wasm = None;
return;
};
let owner = Owner::Wasm(index);
let wasm = self.panel.wasm.as_ref();
if wasm.is_some_and(|s| s.owner == owner && s.version == version) {
return;
}
let items = items.iter().map(stack).collect();
let recipes = self.mods.mods.browser_recipes(index).map(page);
self.panel.wasm = Some(Shown {
owner,
version,
items,
recipes,
});
}
}
pub(super) fn stack(s: &m::BrowserStack) -> BrowserStack {
BrowserStack {
item: s.item.clone(),
count: s.count,
components: s.components.clone(),
}
}
/// A native stack as the runtime's (`browser_key`).
pub(super) fn browser_stack(s: BrowserStack) -> m::BrowserStack {
m::BrowserStack {
item: s.item,
count: s.count,
components: s.components,
}
}
fn layout(l: m::BrowserLayout) -> NativeRecipeLayout {
match l {
m::BrowserLayout::Grid => NativeRecipeLayout::Grid,
m::BrowserLayout::Cooking => NativeRecipeLayout::Cooking,
m::BrowserLayout::Single => NativeRecipeLayout::Single,
m::BrowserLayout::Smithing => NativeRecipeLayout::Smithing,
m::BrowserLayout::Brewing => NativeRecipeLayout::Brewing,
}
}
fn stacks(list: &[m::BrowserStack]) -> Vec<BrowserStack> {
list.iter().map(stack).collect()
}
pub(super) fn page(p: &m::RecipePage) -> RecipePage {
RecipePage {
title: p.title.clone(),
tabs: stacks(&p.tabs),
tab: p.tab,
recipes: p
.recipes
.iter()
.map(|r| NativeRecipeView {
layout: layout(r.layout),
width: r.width,
height: r.height,
slots: r.slots.iter().map(|s| stacks(s)).collect(),
results: stacks(&r.results),
note: r.note.clone(),
action: r.action,
})
.collect(),
}
}
pub(super) fn event(e: BrowserEvent) -> m::BrowserEvent {
match e {
BrowserEvent::Clicked { index, right } => {
m::BrowserEvent::Clicked(m::BrowserClick { index, right })
}
BrowserEvent::Search(text) => m::BrowserEvent::Search(text),
// Not sent to WebAssembly mods: they hear their own keys
// (`browser_key`).
BrowserEvent::Key { key, stack } => m::BrowserEvent::Key(m::BrowserItemKey {
key,
stack: browser_stack(stack),
}),
BrowserEvent::SlotClicked {
recipe,
slot,
stack,
right,
} => m::BrowserEvent::SlotClicked(m::BrowserSlotClick {
recipe,
slot,
stack: browser_stack(stack),
right,
}),
BrowserEvent::Action(i) => m::BrowserEvent::Action(i),
BrowserEvent::Page(i) => m::BrowserEvent::Page(i),
BrowserEvent::Back => m::BrowserEvent::Back,
BrowserEvent::Tab(i) => m::BrowserEvent::Tab(i),
}
}
@@ -1,194 +0,0 @@
//! Capability `render` (docs/mods.md): the shapes mods keep on the HUD,
//! drawn by the game's own HUD renderer, and their camera shakes. Mods
//! never supply shader code. With no mod drawing, each hook here returns
//! after one check.
use super::Game;
use horizon_client::hud::HudBatch;
#[cfg(feature = "mods")]
use {
horizon_client::{hud::rgba, items::ItemStack, text::Text, ui},
horizon_mods::Shape,
std::time::Instant,
};
/// The camera's jolts from `render.shake`, while any is fading.
#[cfg(feature = "mods")]
#[derive(Default)]
pub(super) struct Shake {
jolts: Vec<Jolt>,
}
#[cfg(feature = "mods")]
struct Jolt {
start: Instant,
strength: f32,
seconds: f32,
}
/// The HUD batch's colour (ABGR) from a mod's 0xAARRGGBB.
#[cfg(feature = "mods")]
fn argb(color: u32) -> u32 {
let [a, r, g, b] = color.to_be_bytes();
rgba(r, g, b, a)
}
/// The view's turn at full strength, in degrees: yaw, then pitch.
#[cfg(feature = "mods")]
const SHAKE_DEGREES: (f32, f32) = (3.0, 2.0);
#[cfg(feature = "mods")]
impl Shake {
pub(super) fn add(&mut self, strength: f32, seconds: f32) {
self.jolts.push(Jolt {
start: Instant::now(),
strength,
seconds,
});
}
/// How far the view turns now (yaw, pitch degrees), `scale` being the
/// player's Distortion Effects; fades jolts out and forgets ended ones.
fn offset(&mut self, now: Instant, scale: f32) -> (f32, f32) {
self.jolts
.retain(|j| now.duration_since(j.start).as_secs_f32() < j.seconds);
let mut strength = 0.0;
for j in &self.jolts {
let left = 1.0 - now.duration_since(j.start).as_secs_f32() / j.seconds;
strength += j.strength * left * left;
}
let strength = strength.min(1.0) * scale.clamp(0.0, 1.0);
if strength <= 0.0 {
return (0.0, 0.0);
}
// Smooth noise: sines at unrelated rates, so it never repeats visibly.
let t = self
.jolts
.first()
.map_or(0.0, |j| now.duration_since(j.start).as_secs_f32());
let wave = |a: f32, b: f32, c: f32| {
((t * a).sin() * 0.5 + (t * b).sin() * 0.3 + (t * c).sin() * 0.2).clamp(-1.0, 1.0)
};
(
strength * SHAKE_DEGREES.0 * wave(23.0, 37.3, 51.7),
strength * SHAKE_DEGREES.1 * wave(29.1, 43.9, 61.3),
)
}
}
#[cfg(not(feature = "mods"))]
impl Game<'_> {
pub(super) fn draw_mod_shapes(&self, _batch: &mut HudBatch) {}
pub(super) fn shaken_view(&mut self, yaw: f32, pitch: f32) -> (f32, f32) {
(yaw, pitch)
}
}
#[cfg(feature = "mods")]
impl Game<'_> {
/// The running mods' HUD shapes, in order, over the HUD.
pub(super) fn draw_mod_shapes(&self, batch: &mut HudBatch) {
for shape in self.mods.mods.hud_shapes() {
self.draw_shapes(batch, std::slice::from_ref(shape), (0.0, 0.0));
}
}
/// Shapes with their GUI coordinates moved by `(ox, oy)`.
pub(super) fn draw_shapes(&self, batch: &mut HudBatch, shapes: &[Shape], (ox, oy): (f32, f32)) {
let atlas = &self.gui.atlas;
for shape in shapes {
match shape {
Shape::Fill(f) => {
let r = f.rect;
batch.fill(atlas, ox + r.x, oy + r.y, r.w, r.h, argb(f.color));
}
Shape::Quad(q) => {
let (a, b, c, d) = q.corners;
let corners = [a, b, c, d].map(|p| [ox + p.x, oy + p.y]);
batch.fill_quad(atlas, corners, argb(q.color));
}
Shape::Sprite(s) => {
let r = s.rect;
let name = s.sprite.strip_prefix("minecraft:").unwrap_or(&s.sprite);
let name = format!("gui/sprites/{name}");
// As vanilla's blitSprite: a frame or a button keeps its border.
ui::blit_sprite(
batch,
atlas,
&name,
ox + r.x,
oy + r.y,
r.w,
r.h,
argb(s.color),
);
}
Shape::Item(i) => {
if self.res.items().get(&i.item).is_none() {
continue;
}
let start = batch.vertices.len();
let stack = ItemStack::new(i.item.as_str(), 1);
let (x, y) = (ox + i.at.x, oy + i.at.y);
self.draw_item_model(batch, &stack, x, y);
batch.scale_since(start, x, y, i.scale, i.scale);
}
Shape::Text(t) => {
let text = Text::literal(t.text.clone());
let centre = if t.centered {
(HudBatch::text_width(atlas, &t.text) / 2.0).floor()
} else {
0.0
};
let x = ox + t.at.x - centre;
batch.text_styled(
atlas,
&text,
x,
oy + t.at.y,
argb(t.color | 0xFF00_0000),
t.shadow,
);
}
}
}
}
/// The view's yaw and pitch after the mods' camera shakes; unchanged
/// while none is fading.
pub(super) fn shaken_view(&mut self, yaw: f32, pitch: f32) -> (f32, f32) {
if self.mods.shake.jolts.is_empty() {
return (yaw, pitch);
}
let scale = self.settings.file.double("screenEffectScale") as f32;
let (dy, dp) = self.mods.shake.offset(Instant::now(), scale);
(yaw + dy, (pitch + dp).clamp(-90.0, 90.0))
}
}
#[cfg(all(test, feature = "mods"))]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn a_jolt_fades_out_and_distortion_scales_it() {
let mut shake = Shake::default();
shake.add(1.0, 2.0);
let start = shake.jolts[0].start;
let at = |s: f32| start + Duration::from_secs_f32(s);
let size = |(y, p): (f32, f32)| y.abs() + p.abs();
// Off with Distortion Effects at 0, inside the limits otherwise.
assert_eq!(shake.offset(at(0.3), 0.0), (0.0, 0.0));
let (y, p) = shake.offset(at(0.3), 1.0);
assert!(y.abs() <= SHAKE_DEGREES.0 && p.abs() <= SHAKE_DEGREES.1);
// Fading: the largest turn over a window shrinks.
let mut peak = |from: f32| {
(0..50)
.map(|i| size(shake.offset(at(from + i as f32 * 0.002), 1.0)))
.fold(0.0, f32::max)
};
let early = peak(0.1);
let late = peak(1.6);
assert!(late < early, "{late} !< {early}");
// Gone once it ends.
assert_eq!(shake.offset(at(2.1), 1.0), (0.0, 0.0));
assert!(shake.jolts.is_empty());
}
}
File diff suppressed because it is too large. Load diff
@@ -1,122 +0,0 @@
//! Extras that are official mods now (`horizon_client::moved_extras`): on
//! the first start after the update, a player who had one on gets the mod
//! installed from Horizon's registry, turned on, with their settings, and
//! one line in the chat saying so. The download runs on its own thread; if
//! it fails (offline), the game tries again at the next start.
#[cfg(not(feature = "mods"))]
impl super::Game<'_> {
pub(super) fn start_moved_extras(&mut self) {}
pub(super) fn poll_moved_extras(&mut self) {}
}
#[cfg(feature = "mods")]
mod with_mods {
use super::super::Game;
use horizon_client::{
moved_extras::{self, Moved},
text::{self, Text},
};
use std::sync::mpsc::{Receiver, channel};
type Done = Vec<(&'static Moved, Result<(), String>)>;
#[derive(Default)]
pub(in crate::game) struct MovedState {
/// The installs under way.
running: Option<Receiver<Done>>,
/// Lines for the player, shown once a world is open.
notices: Vec<Text>,
}
impl Game<'_> {
/// At start: remembers the moved extras that were off, and installs
/// the ones that were on, in the background.
pub(in crate::game) fn start_moved_extras(&mut self) {
let off = moved_extras::nothing_to_do(&self.settings.extras);
let pending = moved_extras::pending(&self.settings.extras);
if off.is_empty() && pending.is_empty() {
return;
}
if !off.is_empty() {
let e = &mut self.settings.extras;
e.moved.extend(off.iter().map(|m| m.id.to_owned()));
self.settings.save(&Self::options_path());
}
if pending.is_empty() {
return;
}
let extras = self.settings.extras.clone();
let dir = Self::options_path()
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_default();
let (tx, rx) = channel();
self.mods.moved.running = Some(rx);
let spawned = std::thread::Builder::new()
.name("Moved extras".into())
.spawn(move || {
let done = match moved_extras::fetch_index() {
Ok(index) => pending
.into_iter()
.map(|m| (m, moved_extras::install(&index, m, &extras, &dir)))
.collect(),
Err(e) => pending.into_iter().map(|m| (m, Err(e.clone()))).collect(),
};
let _ = tx.send(done);
});
if spawned.is_err() {
self.mods.moved.running = None;
}
}
/// Once a frame: the installs that finished turn their mods on;
/// their lines wait for a world.
pub(in crate::game) fn poll_moved_extras(&mut self) {
if let Some(rx) = &self.mods.moved.running
&& let Ok(done) = rx.try_recv()
{
self.mods.moved.running = None;
let mut installed = false;
for (m, result) in done {
match result {
Ok(()) => {
self.settings.extras.moved.push(m.id.to_owned());
self.settings.mods.enabled.insert(m.id.to_owned());
// The install wrote the key to horizon-mods.txt;
// the settings save below must keep it.
let keys = self.settings.mods.keys.entry(m.id.to_owned()).or_default();
for (name, key) in m.keys(&self.settings.extras) {
keys.entry(name.to_owned()).or_insert(key);
}
self.settings.mods.keys.retain(|_, k| !k.is_empty());
self.mods.moved.notices.push(Text::literal(m.notice()));
installed = true;
}
Err(e) => {
log::warn!("Horizon: {} not installed, next start retries: {e}", m.id);
self.mods.moved.notices.push(
Text::literal(format!(
"{} moved to the mod browser; it couldn't be installed for you yet, Horizon tries again at the next start",
m.name
))
.with_color(text::GRAY),
);
}
}
}
if installed {
self.settings.save(&Self::options_path());
self.start_mods();
}
}
if !self.mods.moved.notices.is_empty() && self.player.is_some() && self.title.is_none()
{
for line in std::mem::take(&mut self.mods.moved.notices) {
self.system_message(line);
}
}
}
}
}
#[cfg(feature = "mods")]
pub(super) use with_mods::MovedState;
@@ -506,8 +506,8 @@ impl Game<'_> {
pub(super) fn list_native_mods(&mut self) {
let mut mods = Vec::new();
self.native_mods(&mut mods);
// A WebAssembly mod of the same id doesn't run (`start_mods`): the
// native row stands for it, and alone toggles its `mod.<id>` line.
// A WebAssembly mod of the same id: the native row stands for it,
// and alone toggles its `mod.<id>` line.
self.settings
.mods
.installed
@@ -525,7 +525,6 @@ impl Game<'_> {
description: m.description,
problem: None,
dev: false,
has_settings: false,
native: Some(page),
});
}
@@ -575,11 +574,11 @@ impl Game<'_> {
self.native_wheel(steps, &mut claimed);
claimed
}
/// A client mod's command: a native mod's first, then a WebAssembly mod's.
/// A native mod's command.
pub(super) fn client_mod_command(&mut self, command: &str) -> bool {
let mut ran = false;
self.native_command(command, &mut ran);
ran || self.mod_command(command)
ran
}
/// The zoom in effect while playing (a native mod's); the spyglass's own
/// view wins.
+10 -75
View File
@@ -27,21 +27,15 @@ const MARGIN: f32 = 8.0;
/// Items a panel may hold.
const ITEMS: usize = 8192;
/// Whose panel it is: a native mod's id, or a running WebAssembly mod's
/// index.
/// Whose panel it is: a native mod's id.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Owner {
Native(String),
#[cfg(feature = "mods")]
Wasm(usize),
}
impl Owner {
fn native_id(&self) -> Option<&str> {
match self {
Owner::Native(id) => Some(id),
#[cfg(feature = "mods")]
Owner::Wasm(_) => None,
}
fn native_id(&self) -> &str {
let Owner::Native(id) = self;
id
}
}
@@ -61,9 +55,6 @@ pub(super) struct Shown {
pub(super) struct Panel {
/// A native mod's panel: the last one to show something.
native: Option<Shown>,
/// A WebAssembly mod's, converted once per change.
#[cfg(feature = "mods")]
pub(super) wasm: Option<Shown>,
/// The last native change.
versions: u64,
/// The items' owner and version they were made from.
@@ -79,16 +70,9 @@ pub(super) struct Panel {
}
impl Panel {
/// What the panel shows: a native mod's (one `on`), else a
/// WebAssembly mod's.
/// What the panel shows: a native mod's, one `on`.
pub(super) fn shown(&self, on: impl Fn(&str) -> bool) -> Option<&Shown> {
let native = self
.native
.as_ref()
.filter(|s| s.owner.native_id().is_some_and(&on));
#[cfg(feature = "mods")]
let native = native.or(self.wasm.as_ref());
native
self.native.as_ref().filter(|s| on(s.owner.native_id()))
}
/// `native_panel_items`.
pub(super) fn show_items(&mut self, mod_id: &str, mut items: Vec<BrowserStack>) {
@@ -111,7 +95,7 @@ impl Panel {
let own = self
.native
.as_ref()
.is_some_and(|s| s.owner.native_id() == Some(mod_id));
.is_some_and(|s| s.owner.native_id() == mod_id);
if !own {
if !showing {
return None;
@@ -187,7 +171,6 @@ impl Game<'_> {
if self.screen != Screen::Inventory || self.title.is_some() {
return None;
}
self.panel_wasm_sync();
let disabled = &self.settings.mods.disabled;
let Some(shown) = self.panel.shown(|id| !disabled.contains(id)) else {
self.panel.of = None;
@@ -220,14 +203,8 @@ impl Game<'_> {
}
/// The player did `event` on `owner`'s panel.
pub(super) fn panel_event(&mut self, owner: &Owner, event: BrowserEvent) {
match owner {
Owner::Native(id) => self.native_panel_event(id, &event),
#[cfg(feature = "mods")]
Owner::Wasm(i) => self
.mods
.mods
.browser_event(*i, super::mod_panel::event(event)),
}
let Owner::Native(id) = owner;
self.native_panel_event(id, &event);
}
/// The panel's parts: right of the window, from the top of the screen to
/// the search box at its foot. None when the screen has no room for it.
@@ -551,7 +528,7 @@ impl Game<'_> {
if self.screen != Screen::Inventory || self.title.is_some() || self.mod_browser_typing() {
return;
}
let Some(owner) = self.browser_sync().filter(|o| o.native_id().is_some()) else {
let Some(owner) = self.browser_sync() else {
return;
};
if let Some(stack) = self.mod_browser_pointed() {
@@ -583,12 +560,6 @@ pub(super) fn browser_stack(
})
}
/// No WebAssembly mods: no panel of theirs (`mod_panel.rs` with them).
#[cfg(not(feature = "mods"))]
impl Game<'_> {
fn panel_wasm_sync(&mut self) {}
}
#[cfg(test)]
mod tests {
use super::{BrowserStack, Owner, Panel, RecipePage};
@@ -656,40 +627,4 @@ mod tests {
panel.show_items("recipes", Vec::new());
assert!(panel.shown(|_| true).is_none());
}
/// A native mod's panel wins over a WebAssembly mod's, and events go
/// back to a WebAssembly owner in the runtime's form.
#[cfg(feature = "mods")]
#[test]
fn native_owner_wins_over_webassembly() {
use super::{BrowserEvent, Shown};
use crate::game::mod_panel as wasm;
let mut panel = Panel {
wasm: Some(Shown {
owner: Owner::Wasm(0),
version: 7,
items: vec![stack("minecraft:stone")],
recipes: None,
}),
..Panel::default()
};
let owner = |p: &Panel| p.shown(|_| true).map(|s| s.owner.clone());
assert_eq!(owner(&panel), Some(Owner::Wasm(0)));
panel.show_items("recipes", vec![stack("minecraft:dirt")]);
assert_eq!(owner(&panel), Some(Owner::Native("recipes".into())));
panel.show_items("recipes", Vec::new());
assert_eq!(owner(&panel), Some(Owner::Wasm(0)));
let event = BrowserEvent::SlotClicked {
recipe: 2,
slot: 9,
stack: stack("minecraft:oak_planks"),
right: true,
};
let horizon_mods::BrowserEvent::SlotClicked(c) = wasm::event(event) else {
panic!("a slot click");
};
assert_eq!((c.recipe, c.slot, c.right), (2, 9, true));
assert_eq!(c.stack.item, "minecraft:oak_planks");
}
}
@@ -320,78 +320,3 @@ impl Game<'_> {
}
}
}
/// Capability `recipes` of WebAssembly mods: the same tables in the
/// runtime's types.
#[cfg(feature = "mods")]
impl Game<'_> {
/// Capability `recipes`'s tables and known displays for this tick's
/// snapshot; nothing while no running mod reads them.
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<horizon_mods::Recipes>>, Arc<[i32]>) {
if !self.mods.mods.wants_recipes() || self.res.rest.get().is_none() {
return (None, Arc::from([]));
}
if self.mods.recipes.is_none() {
self.mods.recipes = self.made_recipes().map(|t| Arc::new(wasm::recipes(&t)));
}
(self.mods.recipes.clone(), self.known_displays())
}
}
/// The native tables as the WebAssembly runtime's.
#[cfg(feature = "mods")]
mod wasm {
use super::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes};
use horizon_mods as m;
fn kind(k: RecipeKind) -> m::RecipeKind {
match k {
RecipeKind::CraftingShaped => m::RecipeKind::CraftingShaped,
RecipeKind::CraftingShapeless => m::RecipeKind::CraftingShapeless,
RecipeKind::Smelting => m::RecipeKind::Smelting,
RecipeKind::Blasting => m::RecipeKind::Blasting,
RecipeKind::Smoking => m::RecipeKind::Smoking,
RecipeKind::Campfire => m::RecipeKind::Campfire,
RecipeKind::Stonecutting => m::RecipeKind::Stonecutting,
RecipeKind::Smithing => m::RecipeKind::Smithing,
RecipeKind::Brewing => m::RecipeKind::Brewing,
}
}
fn stacks(list: &[RecipeStack]) -> Vec<m::RecipeStack> {
list.iter()
.map(|s| m::RecipeStack {
item: s.item,
count: s.count,
components: s.components.clone(),
})
.collect()
}
fn recipe(r: &Recipe) -> m::Recipe {
m::Recipe {
id: r.id.clone(),
kind: kind(r.kind),
category: r.category.clone(),
group: r.group.clone(),
width: r.width,
height: r.height,
slots: r.slots.iter().map(|s| stacks(s)).collect(),
results: stacks(&r.results),
cooking_time: r.cooking_time,
experience: r.experience,
book_id: r.book_id,
}
}
fn item(i: &RecipeItem) -> m::RecipeItem {
m::RecipeItem {
id: i.id.clone(),
name: i.name.clone(),
tags: i.tags.clone(),
}
}
pub(super) fn recipes(t: &Recipes) -> m::Recipes {
m::Recipes {
items: t.items.iter().map(item).collect(),
recipes: t.recipes.iter().map(recipe).collect(),
}
}
}
@@ -202,7 +202,6 @@ impl Game<'_> {
Event::Changed => {
self.settings.save(&Self::options_path());
self.apply_settings();
self.sync_mods();
self.sync_agent_control();
self.ui.options.agent_status = self.agent_status_lines();
}
@@ -263,7 +262,6 @@ impl Game<'_> {
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::ModSettings(i) => self.open_mod_screen(i, true),
Event::NativeSettings(i) => self.native_settings_refresh(i),
Event::NativeButton(i, b) => self.native_settings_event(i, b),
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
@@ -339,7 +339,7 @@ impl Game<'_> {
ui.client_known = Some(known.clone());
ui.client_version += 1;
}
/// Every entry of the book, in its order (capability `recipes`).
/// Every entry of the book, in its order (`native_recipes`).
pub(super) fn recipe_book_entries(&mut self) -> Vec<Entry> {
self.ensure_book();
self.recipe_ui
@@ -348,7 +348,7 @@ impl Game<'_> {
.map_or_else(Vec::new, |b| b.entries.clone())
}
/// The display ids the player's book holds, and a number that changes
/// whenever they do (capability `recipes`).
/// whenever they do (`native_known_recipes`).
pub(super) fn recipe_book_displays(&mut self) -> (u64, Vec<i32>) {
self.sync_client_book();
(
@@ -862,14 +862,14 @@ impl Game<'_> {
self.recipe_ui.ghost.clear();
self.place_recipe(book_type, entry_index, use_max);
}
/// Whether the open screen takes `recipes.place`: a crafting grid's
/// Whether the open screen takes `native_place_recipe`: a crafting grid's
/// book (the inventory's or a crafting table's).
pub(super) fn mod_recipe_screen(&self) -> bool {
self.screen == Screen::Inventory
&& self.active_book() == Some(BookType::Crafting)
&& !self.spectator()
}
/// `recipes.place`: the book's click on the recipe with this display
/// `native_place_recipe`: the book's click on the recipe with this display
/// id, refused as `handlePlaceRecipe` refuses it (no crafting screen,
/// not in the player's book; an impossible one in `place_recipe`).
pub(super) fn mod_place_recipe(&mut self, display_id: i32, all: bool) {
+81 -27
View File
@@ -39,6 +39,7 @@ use horizon_protocol::{
text::{Language, plain},
};
use horizon_world::{
level::InteractionResult,
nbt::Tag,
registry::BlockStateId,
stepped_interpolation::{Path, PosRot, Start, SteppedInterpolation},
@@ -563,6 +564,7 @@ impl Game<'_> {
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
self.gui.atlas.object_skins.clear();
self.server_packs_disconnect();
self.code_of_conduct = None;
self.reset_tutorial();
@@ -757,9 +759,6 @@ impl Game<'_> {
self.ticks,
self.screen == Screen::Chat,
);
if self.mods.running() {
self.mods_chat(&text);
}
if let Some(r) = self.remote.as_mut() {
r.chat.push((text, Instant::now()));
r.chat_received += 1;
@@ -2891,7 +2890,6 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.commands = Some(tree);
}
self.merge_remote_tree();
}
C::Cooldown(p) => {
// ClientPacketListener.handleItemCooldown: the server starts
@@ -3004,7 +3002,10 @@ impl Game<'_> {
let text = |t: &Tag| {
let json = horizon_client::scoreboard::tag_to_json(t);
let text = horizon_client::scoreboard::comp_text(&json);
(!text.plain().is_empty()).then_some(text)
text.spans
.iter()
.any(|span| !span.text.is_empty())
.then_some(text)
};
self.tab_list.header = text(&p.header);
self.tab_list.footer = text(&p.footer);
@@ -3250,6 +3251,13 @@ impl Game<'_> {
.as_ref()
.and_then(|r| r.skins.get(&p.uuid).cloned())
.unwrap_or_else(|| horizon_client::tab_list::default_skin(p.uuid).to_owned());
self.gui
.atlas
.set_object_skin(format!("id:{:032x}", p.uuid), p.skin.clone());
self.gui.atlas.set_object_skin(
format!("name:{}", p.name.to_ascii_lowercase()),
p.skin.clone(),
);
}
let r = self.remote.as_ref()?;
let upside_down = players
@@ -3262,6 +3270,32 @@ impl Game<'_> {
.collect();
Some((players, r.online_mode, upside_down))
}
/// Resolve inline `object:player` profiles through the same skin cache as
/// player heads on items. The next frame uses the uploaded atlas texture.
pub(super) fn resolve_inline_profiles(&mut self, profiles: &[serde_json::Value]) {
use horizon_client::skins;
for profile in profiles {
let skin = skins::head_skin(
profile,
&mut self.skin_profiles.borrow_mut(),
&mut self.skin_downloads.borrow_mut(),
|location| self.gui.atlas.sprites.contains_key(location),
);
let id = skins::parse_profile(profile).id;
self.gui
.atlas
.set_object_skin(format!("id:{id:032x}"), skin.clone());
let name = profile
.as_str()
.or_else(|| profile.get("name").and_then(serde_json::Value::as_str));
if let Some(name) = name {
self.gui
.atlas
.set_object_skin(format!("name:{}", name.to_ascii_lowercase()), skin);
}
}
}
/// The signed-in account's `textures`, once the services answered.
fn own_textures(&self) -> Option<horizon_client::skins::Textures> {
use horizon_client::skins::{self, NameOrId, Resolve};
@@ -3741,8 +3775,8 @@ impl Game<'_> {
pub(super) fn tick_remote(&mut self) -> Result<()> {
self.tick_remote_level()?;
// Client mods tick on any server, single player's own included.
self.tick_mods();
self.native_tick();
self.tell_native_now();
// Minecraft.tick sends this even when the player cannot tick yet,
// including while waiting for chunks. Keep it outside early returns.
if self.remote.as_ref().is_some_and(|r| r.joined) {
@@ -4916,18 +4950,32 @@ impl Game<'_> {
.and_then(|h| self.res.items().get(&h.item))
.is_some_and(|i| i.block.is_some());
if let Some(hit) = self.pick() {
// ShelfBlock.useItemOn consumes a nonempty main-hand click client-side
// without predicting the inventory swap or swinging.
let shelf_reacts = hand == Hand::Main
&& held.is_some()
&& !(self.keys.contains(&KeyCode::ShiftLeft)
&& (self.stack_in(self.selected).is_some() || self.offhand.is_some()))
&& self.shelf_hit(&hit).is_some();
let spectator = self.spectator();
let (reacts, changes) = if hand == Hand::Main && !spectator {
let (result, changes) = if hand == Hand::Main && !spectator {
self.remote_block_reacts(&hit, held.as_ref())
} else {
// useItemOn runs for the off hand too (useWithoutItem doesn't):
// RespawnAnchorBlock.useItemOn charges an anchor with its glowstone.
// RespawnAnchorBlock.useItemOn charges an anchor with its
// glowstone, SUCCESS.
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
let charges =
!(sneaking && hands_full) && self.anchor_takes(hit.pos, held.as_ref());
(charges, DryUse::default())
let result = if charges {
InteractionResult::Success
} else {
InteractionResult::Pass
};
(result, DryUse::default())
};
let reacts = result.consumes_action();
let seq = self.next_sequence();
let cursor = [
(hit.point[0] - f64::from(hit.pos[0])) as f32,
@@ -4955,9 +5003,17 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.use_target = Some(hit.pos);
}
if shelf_reacts {
return true;
}
if reacts {
self.predict_use(&changes, seq);
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
// MultiPlayerGameMode.useItemOn: SUCCESS SwingSource.CLIENT
// (a chest, barrel, furnace, …). CONSUME does not swing.
if result == InteractionResult::Success {
self.with_hand(hand, Game::swing_arm);
}
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
// CampfireBlock.useItemOn succeeded: this hand swings and the
// click ends, so a shield in the other hand is not raised.
@@ -4996,7 +5052,7 @@ impl Game<'_> {
// server's resend of the target it refused is no placement.
r.placing = None;
}
if reacts || placed.is_some() {
if placed.is_some() {
self.with_hand(hand, Game::swing_arm);
}
// BlockItem.place returns FAIL when placement is rejected:
@@ -5088,20 +5144,20 @@ impl Game<'_> {
&mut self,
hit: &super::Hit,
held: Option<&ItemStack>,
) -> (bool, DryUse) {
) -> (InteractionResult, DryUse) {
// Player.isSecondaryUseActive with something in either hand.
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
return (false, DryUse::default());
return (InteractionResult::Pass, DryUse::default());
}
if self.jukebox_takes(hit.pos, held) {
return (true, DryUse::default());
return (InteractionResult::Success, DryUse::default());
}
// RespawnAnchorBlock.useItemOn: PASS when the off hand's glowstone is to
// charge the anchor, so useWithoutItem doesn't run and the off hand's turn
// comes (glowstone in this hand charges it in `use_item_on`).
if !self.anchor_takes(hit.pos, held) && self.anchor_takes(hit.pos, self.offhand.as_ref()) {
return (false, DryUse::default());
return (InteractionResult::Pass, DryUse::default());
}
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
@@ -5111,7 +5167,7 @@ impl Game<'_> {
.hive_state(hit.pos)
.is_some_and(|(honey, _)| honey == Some(5))
{
return (true, DryUse::default());
return (InteractionResult::Success, DryUse::default());
}
use horizon_core::geometry::Direction;
let face = match hit.face {
@@ -5125,18 +5181,16 @@ impl Game<'_> {
let player = self.use_context();
self.with_level(|b, level, _| {
let mut level = DryLevel::new(level);
let reacts = b
.use_item_on(
&mut level,
&mut horizon_world::level::Ticks::default(),
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
face,
horizon_core::geometry::Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
player,
held.map(|s| &*s.item),
)
.consumes_action();
(reacts, (level.changed.into_iter().collect(), level.sounds))
let result = b.use_item_on(
&mut level,
&mut horizon_world::level::Ticks::default(),
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
face,
horizon_core::geometry::Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
player,
held.map(|s| &*s.item),
);
(result, (level.changed.into_iter().collect(), level.sounds))
})
}
+1 -2
View File
@@ -22,8 +22,7 @@ impl Screen {
| Self::Friends
| Self::ServerLinks
| Self::ConfirmLink
| Self::DialogWarning
| Self::Mod => true,
| Self::DialogWarning => true,
Self::Playing
| Self::GameModeSwitcher
| Self::Chat
@@ -1,576 +0,0 @@
//! Server mods in the game (`docs/mods.md`, "Server mods"): the game that
//! simulates the world (in-frame single player, the server thread, a LAN
//! host, the dedicated server) runs them. At the end of each tick it tells
//! them who joined and left, then what players said, broke and placed since
//! the last one, then ticks them; a player's command reaches them at once.
//! Their lines go to the players they name. With no server mod on, each hook
//! here is one check.
use super::{Game, lan::LanHost};
#[cfg(not(feature = "mods"))]
use horizon_world::registry::BlockStateId;
/// Who did something a server mod hears about, resolved at the tick's end.
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
pub(super) enum Who {
/// The window's own player, in-frame.
Local,
/// A connection's player (the server thread's owner is one).
Conn(u32),
Uuid(u128),
}
/// What players did during a tick, for the server mods at its end.
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
pub(super) enum ModEvent {
Chat(Who, String),
Block {
who: Who,
pos: [i32; 3],
state: String,
placed: bool,
},
}
#[cfg(not(feature = "mods"))]
impl Game<'_> {
pub(super) fn tick_server_mods(&mut self) {}
pub(super) fn mod_block_event(&mut self, _pos: [i32; 3], _state: BlockStateId, _placed: bool) {}
pub(super) fn mod_player_chat(&mut self, _who: Who, _text: &str) {}
pub(super) fn local_server_mod_command(&mut self, _line: &str) -> bool {
false
}
pub(super) fn guest_server_mod_command(
&mut self,
_host: &mut LanHost,
_conn: u32,
_line: &str,
) -> bool {
false
}
pub(super) fn apply_mod_block_writes(&mut self) {}
}
#[cfg(feature = "mods")]
mod run {
use super::{Game, LanHost, ModEvent, Who};
use horizon_client::chunk_map::ChunkMap;
use horizon_client::{
command::args::GameMode as Mode,
lan::data::component_nbt,
text::{self, Text},
};
use horizon_mods::{
ArgumentKind, BlockPos, GameMode, Output, ServerCommand, ServerPlayer, WorldView,
};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::{Value, json};
/// Blocks queued for levels that don't tick (another dimension, in-frame)
/// are kept up to this many; the oldest go first.
const MAX_QUEUED_WRITES: usize = 1 << 16;
/// Capability `world`'s view during a call: the levels' chunks, moved out
/// of the game for the call and back after it (no copy).
struct LentWorld {
registry: &'static BlockRegistry,
biomes: std::sync::Arc<horizon_client::biome::Biomes>,
dims: Vec<(&'static str, ChunkMap)>,
}
impl WorldView for LentWorld {
fn block(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option<String> {
let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?;
let chunk = chunks.get(&(x >> 4, z >> 4))?;
Some(state_string(self.registry, chunk.block(x & 15, y, z & 15)))
}
fn biome(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option<String> {
let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?;
let chunk = chunks.get(&(x >> 4, z >> 4))?;
Some(self.biomes.get(chunk.biome(x, y, z)).name.clone())
}
fn has_dimension(&self, dimension: &str) -> bool {
matches!(
dimension,
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end"
)
}
fn check_state(&self, state: &str) -> Result<(), String> {
crate::game::render_control::parse_state(self.registry, state)
.map(drop)
.map_err(|e| format!("{state}: {e}"))
}
}
/// Server mods' commands as a root of the commands report
/// (`lan::commands`): a literal per command, then its arguments in a
/// chain, each node runnable where only optional arguments follow.
fn command_tree<'a>(commands: impl Iterator<Item = &'a ServerCommand>) -> Value {
let mut root = serde_json::Map::new();
for c in commands {
let mut next: Option<(String, Value)> = None;
for (i, a) in c.arguments.iter().enumerate().rev() {
let executable = c.arguments.get(i + 1).is_none_or(|b| b.optional);
let (parser, properties) = match a.kind {
ArgumentKind::Word => ("brigadier:string", json!({"type": "word"})),
ArgumentKind::Text => ("brigadier:string", json!({"type": "greedy"})),
ArgumentKind::Integer((min, max)) => {
let mut p = serde_json::Map::new();
if let Some(min) = min {
p.insert("min".into(), min.into());
}
if let Some(max) = max {
p.insert("max".into(), max.into());
}
("brigadier:integer", Value::Object(p))
}
ArgumentKind::Number => ("brigadier:double", Value::Null),
ArgumentKind::Boolean => ("brigadier:bool", Value::Null),
ArgumentKind::Player => (
"minecraft:entity",
json!({"amount": "single", "type": "players"}),
),
ArgumentKind::BlockPos => ("minecraft:block_pos", Value::Null),
};
let mut node = json!({
"type": "argument",
"parser": parser,
"properties": properties,
"executable": executable,
});
if let Some((name, child)) = next.take() {
node["children"] = json!({ name: child });
}
next = Some((a.name.clone(), node));
}
let executable = c.arguments.first().is_none_or(|a| a.optional);
let mut literal = json!({"type": "literal", "executable": executable});
if let Some((name, child)) = next {
literal["children"] = json!({ name: child });
}
root.insert(c.name.clone(), literal);
}
if root.is_empty() {
return Value::Null;
}
json!({"type": "root", "children": root})
}
/// `minecraft:oak_log[axis=y]`.
fn state_string(registry: &BlockRegistry, state: BlockStateId) -> String {
let Some(s) = registry.state(state) else {
return "minecraft:air".into();
};
if s.properties.is_empty() {
return s.name.clone();
}
let props: Vec<String> = s
.properties
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
format!("{}[{}]", s.name, props.join(","))
}
/// `UUID.toString()`.
fn uuid_string(u: u128) -> String {
let h = format!("{u:032x}");
format!(
"{}-{}-{}-{}-{}",
&h[..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
fn mode(id: i32) -> GameMode {
match id {
1 => GameMode::Creative,
2 => GameMode::Adventure,
3 => GameMode::Spectator,
_ => GameMode::Survival,
}
}
impl Game<'_> {
/// The players on this game's server: the window's own player
/// in-frame, then every connection's. `host` is the hub when it is
/// out of `self.lan`.
pub(in crate::game) fn server_players(&self, host: Option<&LanHost>) -> Vec<ServerPlayer> {
let mut out = Vec::new();
if self.options.execution_role.is_client()
&& self.remote.is_none()
&& let Some(p) = &self.player
{
out.push(ServerPlayer {
id: uuid_string(self.player_uuid()),
name: crate::game::chat::PLAYER_NAME.to_owned(),
x: p.position.x,
y: p.position.y,
z: p.position.z,
dimension: self.dim.id().to_owned(),
game_mode: match self.mode {
Mode::Survival => GameMode::Survival,
Mode::Creative => GameMode::Creative,
Mode::Adventure => GameMode::Adventure,
Mode::Spectator => GameMode::Spectator,
},
health: p.health.health(),
});
}
let guests = host
.or(self.lan.as_ref())
.into_iter()
.flat_map(|h| &h.hub.guests);
out.extend(guests.filter(|g| g.placed).map(|g| ServerPlayer {
id: uuid_string(g.uuid),
name: g.name.clone(),
x: g.position[0],
y: g.position[1],
z: g.position[2],
dimension: g.dimension.clone(),
game_mode: mode(g.game_mode),
health: g.health,
}));
out
}
/// Whether what players do now reaches server mods: one is running
/// and this game simulates the world.
fn hears_players(&self) -> bool {
self.mods.server && self.remote.is_none()
}
/// The tick's end: joins and leaves, the tick's events, the tick hook.
pub(in crate::game) fn tick_server_mods(&mut self) {
if !self.options.execution_role.is_client() {
// A server has no frames to check its dev mods in.
self.reload_dev_mods();
}
self.send_server_mod_commands();
if !self.mods.server {
return;
}
let players = self.server_players(None);
let snapshot = self.mod_snapshot_with(players.clone());
self.mods.mods.set_snapshot(snapshot);
self.lend_world();
let before = std::mem::take(&mut self.mods.players);
for p in before
.iter()
.filter(|p| !players.iter().any(|q| q.id == p.id))
{
self.mods.mods.player_left(p);
}
for p in players
.iter()
.filter(|p| !before.iter().any(|q| q.id == p.id))
{
self.mods.mods.player_joined(p);
}
for event in std::mem::take(&mut self.mods.events) {
let who = |w: &Who| {
let id = match w {
Who::Local => uuid_string(self.player_uuid()),
Who::Uuid(u) => uuid_string(*u),
Who::Conn(c) => {
let guests = self.lan.iter().flat_map(|h| &h.hub.guests);
let g = guests.into_iter().find(|g| g.conn == *c);
g.map_or_else(String::new, |g| uuid_string(g.uuid))
}
};
players.iter().chain(&before).find(|p| p.id == id).cloned()
};
match event {
ModEvent::Chat(w, text) => {
if let Some(p) = who(&w) {
self.mods.mods.player_chat(&p, &text);
}
}
ModEvent::Block {
who: w,
pos,
state,
placed,
} => {
let Some(p) = who(&w) else { continue };
let pos = BlockPos {
x: pos[0],
y: pos[1],
z: pos[2],
};
if placed {
self.mods.mods.block_placed(&p, pos, &state);
} else {
self.mods.mods.block_broken(&p, pos, &state);
}
}
}
}
self.mods.mods.server_tick(self.ticks);
self.return_world();
self.mods.players = players;
self.show_mod_output();
self.apply_mod_block_writes();
}
/// Players' command trees list the running server mods' commands
/// after vanilla's, whenever they change (or a hub opens).
fn send_server_mod_commands(&mut self) {
let key = self.mods.mods.generation();
let Some(host) = self.lan.as_mut() else {
return;
};
if host.hub.mod_commands_key() == Some(key) {
return;
}
let tree = command_tree(self.mods.mods.server_commands());
host.hub.set_mod_commands(key, tree);
}
/// Lends the levels' chunks to capability `world` for the calls up to
/// [`Game::return_world`], when a running server mod has it.
fn lend_world(&mut self) {
if !self.mods.world {
return;
}
let mut dims = vec![(self.dim.id(), std::mem::take(&mut self.world.chunks))];
for (dim, chunks) in self.levels.parked_chunks() {
dims.push((dim.id(), std::mem::take(chunks)));
}
let registry = self.pack.assets.registry;
let biomes = self.res.biomes().clone();
self.mods.mods.lend_world(Box::new(LentWorld {
registry,
biomes,
dims,
}));
}
/// Puts back the chunks [`Game::lend_world`] lent.
fn return_world(&mut self) {
let Some(view) = self.mods.mods.take_world() else {
return;
};
let view: Box<dyn std::any::Any> = view;
let Ok(lent) = view.downcast::<LentWorld>() else {
return;
};
let mut dims = lent.dims;
if let Some(i) = dims.iter().position(|(d, _)| *d == self.dim.id()) {
self.world.chunks = dims.swap_remove(i).1;
}
for (dim, chunks) in self.levels.parked_chunks() {
if let Some(i) = dims.iter().position(|(d, _)| *d == dim.id()) {
*chunks = dims.swap_remove(i).1;
}
}
}
/// Makes the blocks server mods set in the level in place, in order,
/// as `/setblock` does. Those of other dimensions wait for their level.
pub(in crate::game) fn apply_mod_block_writes(&mut self) {
if self.mods.block_writes.is_empty() {
return;
}
let dim = self.dim.id();
let (now, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.mods.block_writes)
.into_iter()
.partition(|(d, _)| d == dim);
self.mods.block_writes = later;
let registry = self.pack.assets.registry;
for (_, b) in now {
let state = match crate::game::render_control::parse_state(registry, &b.state) {
Ok(state) => state,
Err(e) => {
log::warn!("Horizon: a server mod set {}: {e}", b.state);
continue;
}
};
if let Err(e) = self.place_state([b.x, b.y, b.z], state, false) {
log::debug!(
"Horizon: a server mod's block at {} {} {}: {e:?}",
b.x,
b.y,
b.z
);
}
}
}
/// A player broke (or placed) `state` at `pos`: the player acting now.
pub(in crate::game) fn mod_block_event(
&mut self,
pos: [i32; 3],
state: BlockStateId,
placed: bool,
) {
if !self.hears_players() {
return;
}
let who = self.lan_acting.map_or(Who::Local, Who::Conn);
let state = self.state_string(state);
self.mods.events.push(ModEvent::Block {
who,
pos,
state,
placed,
});
}
/// A chat message a player sent.
pub(in crate::game) fn mod_player_chat(&mut self, who: Who, text: &str) {
if self.hears_players() {
self.mods.events.push(ModEvent::Chat(who, text.to_owned()));
}
}
/// `/line` typed by the window's own player in-frame: true when a
/// server mod owns it.
pub(in crate::game) fn local_server_mod_command(&mut self, line: &str) -> bool {
if !self.hears_players() {
return false;
}
let players = self.server_players(None);
let Some(me) = players.first().cloned() else {
return false;
};
let snapshot = self.mod_snapshot_with(players);
self.mods.mods.set_snapshot(snapshot);
self.lend_world();
let owned = self.mods.mods.server_command(&me, line);
self.return_world();
self.show_mod_output();
self.apply_mod_block_writes();
owned
}
/// `/line` typed by a connection's player: true when a server mod
/// owns it, whatever the player's permission level.
pub(in crate::game) fn guest_server_mod_command(
&mut self,
host: &mut LanHost,
conn: u32,
line: &str,
) -> bool {
if !self.hears_players() {
return false;
}
let Some(g) = host.hub.guest(conn) else {
return false;
};
let id = uuid_string(g.uuid);
let players = self.server_players(Some(host));
let Some(me) = players.iter().find(|p| p.id == id).cloned() else {
return false;
};
let snapshot = self.mod_snapshot_with(players);
self.mods.mods.set_snapshot(snapshot);
self.lend_world();
let owned = self.mods.mods.server_command(&me, line);
self.return_world();
if !owned {
return false;
}
self.route_mod_output(Some(host));
self.apply_mod_block_writes();
true
}
/// Sends a server mod's line to the player with this id: the
/// window's own chat for the local player, a system message for a
/// connection's. `host` is the hub when it is out of `self.lan`.
pub(in crate::game) fn tell_server_player(
&mut self,
host: Option<&mut LanHost>,
id: &str,
line: Text,
) {
let local = self.options.execution_role.is_client()
&& self.remote.is_none()
&& self.player.is_some();
if local && uuid_string(self.player_uuid()) == id {
self.add_mod_chat_line(line);
return;
}
let Some(host) = host.or(self.lan.as_mut()) else {
return;
};
let to = host
.hub
.guests
.iter()
.find(|g| g.placed && uuid_string(g.uuid) == id);
if let Some(conn) = to.map(|g| g.conn) {
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: component_nbt(&line.component()),
overlay: false,
}),
);
}
}
/// Routes a server mod's output: `Tell` and `Reply` to their player,
/// `Broadcast` to everyone and the log.
pub(in crate::game) fn route_server_output(
&mut self,
mut host: Option<&mut LanHost>,
output: Output,
) {
let named = |name: &str, text: String| {
let mut line = Text::literal(format!("[{name}] ")).with_color(text::GRAY);
line.push(Text::literal(text));
line
};
match output {
Output::Tell { name, player, text } => {
self.tell_server_player(host, &player, named(&name, text));
}
Output::Reply {
player,
text,
error,
} => {
let line = Text::literal(text);
let line = if error {
line.with_color(text::RED)
} else {
line
};
self.tell_server_player(host, &player, line);
}
Output::Broadcast { name, text } => {
let plain = format!("[{name}] {text}");
if self.options.dedicated.is_some() {
super::super::dedicated::log("INFO", &plain);
} else {
log::info!("{plain}");
}
for p in self.server_players(host.as_deref()) {
let line = named(&name, text.clone());
self.tell_server_player(host.as_deref_mut(), &p.id, line);
}
}
Output::SetBlocks {
name,
dimension,
blocks,
} => {
let queued = &mut self.mods.block_writes;
queued.extend(blocks.into_iter().map(|b| (dimension.clone(), b)));
if queued.len() > MAX_QUEUED_WRITES {
let over = queued.len() - MAX_QUEUED_WRITES;
queued.drain(..over);
log::warn!(
"Horizon: mod {name} set more blocks than wait for their level: {over} dropped"
);
}
}
_ => {}
}
}
}
}
+260
View File
@@ -0,0 +1,260 @@
//! The displayed items of `ShelfBlockEntity`.
use super::{Game, Hit, machines::dir_from_name};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{block_entity_render::vertex_light, containers::BlockEntity, mesher::Vertex};
use horizon_core::{
geometry::{Direction, Vec3},
position::BlockPos,
};
use horizon_world::level::hit_slot;
impl Game<'_> {
/// Slot and facing for a click on a shelf's front face.
pub(super) fn shelf_hit(&self, hit: &Hit) -> Option<(usize, Direction, bool)> {
let p = hit.pos;
if !matches!(
self.world.block_entities.get(&(p[0], p[1], p[2])),
Some(BlockEntity::Shelf(_))
) {
return None;
}
let state = self.world.block(p[0], p[1], p[2]);
let data = self.pack.assets.registry.state(state)?;
let facing = data
.properties
.get("facing")
.and_then(|s| dir_from_name(s))?;
let face = Direction::ALL
.into_iter()
.find(|d| d.normal() == hit.face)?;
let slot = hit_slot(
facing,
face,
BlockPos::new(p[0], p[1], p[2]),
Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
1,
3,
)?;
Some((
slot,
facing,
data.properties.get("powered").is_some_and(|s| s == "true"),
))
}
/// `SideChainPartBlock.getAllBlocksConnectedTo`: left to right, at most three.
fn shelf_chain(&self, pos: [i32; 3], facing: Direction) -> Vec<[i32; 3]> {
let left = match facing {
Direction::North => Direction::East,
Direction::East => Direction::South,
Direction::South => Direction::West,
Direction::West => Direction::North,
_ => return Vec::new(),
};
let state = self.world.block(pos[0], pos[1], pos[2]);
let behaviours = self.res.behaviours();
if !behaviours
.tag("minecraft:wooden_shelves")
.is_some_and(|tag| tag.contains(behaviours.block(state)))
{
return Vec::new();
}
let mut result = vec![pos];
for (dir, end, prepend) in [(left, "left", true), (left.opposite(), "right", false)] {
let normal = dir.normal();
for step in 1..3 {
let p = [pos[0] + normal[0] * step, pos[1], pos[2] + normal[2] * step];
let state = self.world.block(p[0], p[1], p[2]);
let Some(info) = self.pack.assets.registry.state(state) else {
break;
};
let behaviours = self.res.behaviours();
let connectable = behaviours
.tag("minecraft:wooden_shelves")
.is_some_and(|tag| tag.contains(behaviours.block(state)))
&& info.properties.get("powered").is_some_and(|s| s == "true")
&& info.properties.get("facing").and_then(|s| dir_from_name(s)) == Some(facing);
if !connectable {
break;
}
let part = info
.properties
.get("side_chain")
.map(String::as_str)
.unwrap_or("unconnected");
if part == "center" || part == end {
if prepend {
result.insert(0, p);
} else {
result.push(p);
}
}
if part != "center" {
break;
}
}
}
result
}
/// `ShelfBlock.useItemOn`: a single swap, or a connected hotbar swap while powered.
pub(super) fn use_shelf(&mut self, hit: &Hit) -> Option<bool> {
use horizon_client::first_person::Hand;
if self.hand != Hand::Main {
return None;
}
let (slot, facing, powered) = self.shelf_hit(hit)?;
let pos = hit.pos;
let state = self.world.block(pos[0], pos[1], pos[2]);
if powered {
let chain = self.shelf_chain(pos, facing);
let mut swapped = false;
let count = chain.len();
for (i, p) in chain.into_iter().enumerate() {
let key = (p[0], p[1], p[2]);
let Some(BlockEntity::Shelf(shelf)) = self.world.block_entities.get_mut(&key)
else {
continue;
};
for n in 0..3 {
let index = 9_i32 - (count as i32 - i as i32) * 3 + n as i32;
if !(0..9).contains(&index) {
continue;
}
let index = index as usize;
swapped |= self.inventory[index].is_some() || shelf.items[n].is_some();
std::mem::swap(&mut self.inventory[index], &mut shelf.items[n]);
}
self.container_changed(key);
let s = self.world.block(p[0], p[1], p[2]);
self.post_block_game_event("minecraft:entity_interact", p, None, Some(s));
}
if !swapped {
return Some(false);
}
self.audio.play(
"minecraft:block.shelf.multi_swap",
Some(pos.map(|n| f64::from(n) + 0.5)),
1.0,
1.0,
);
Some(true)
} else {
let held = self.inventory[self.selected].clone();
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Shelf(shelf)) = self.world.block_entities.get_mut(&key) else {
return None;
};
let removed = std::mem::replace(&mut shelf.items[slot], held.clone());
self.inventory[self.selected] = if self.creative && removed.is_none() {
held.clone()
} else {
removed.clone()
};
self.container_changed(key);
let vibrations = self.inventory[self.selected].as_ref().is_none_or(|stack| {
let default = self
.res
.items()
.get(&stack.item)
.is_none_or(|item| item.interact_vibrations);
let Some(data) = stack.data() else {
return default;
};
if data
.other
.iter()
.any(|(key, _)| key == "!minecraft:use_effects")
{
return true;
}
data.other
.iter()
.find(|(key, _)| key == "minecraft:use_effects")
.map_or(default, |(_, tag)| {
horizon_client::save::number(tag.get("interact_vibrations")).unwrap_or(1)
!= 0
})
});
if vibrations {
self.post_block_game_event(
"minecraft:item_interact_finish",
pos,
None,
Some(state),
);
}
let sound = match (held.is_some(), removed.is_some()) {
(false, false) => return None,
(true, false) => "minecraft:block.shelf.place_item",
(false, true) => "minecraft:block.shelf.take_item",
(true, true) => "minecraft:block.shelf.single_swap",
};
self.audio
.play(sound, Some(pos.map(|n| f64::from(n) + 0.5)), 1.0, 1.0);
Some(true)
}
}
/// `ShelfRenderer`: three items face out, aligned to the bottom or centre.
pub(super) fn shelf_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
for (key, entity) in &self.world.block_entities {
let BlockEntity::Shelf(shelf) = entity else {
continue;
};
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
if at.distance_squared(camera) >= 64.0 * 64.0 {
continue;
}
let block = self.world.block(key.0, key.1, key.2);
let facing = self
.pack
.assets
.registry
.state(block)
.and_then(|s| s.properties.get("facing"))
.map(String::as_str)
.unwrap_or("north");
let yaw: f32 = match facing {
"south" => 0.0,
"west" => -90.0,
"north" => -180.0,
"east" => -270.0,
_ => 180.0,
};
let packed = BlockPos::new(key.0, key.1, key.2).pack() as u64;
let seed = (packed ^ (packed >> 32)) as i32;
let light = vertex_light(self.world.light(key.0, key.1, key.2));
for (slot, stack) in shelf.items.iter().enumerate() {
let Some(stack) = stack else { continue };
let item = self.resolve_item(
stack,
DisplayContext::OnShelf,
None,
seed.wrapping_add(slot as i32),
);
if item.is_empty() {
continue;
}
let (lo, hi) = item.bounding_box();
let offset_y = -lo.y
- if shelf.align_to_bottom {
0.0
} else {
(hi.y - lo.y) / 2.0
};
let pose =
glam::Mat4::from_translation((at - camera).as_vec3() + glam::Vec3::splat(0.5))
* glam::Mat4::from_rotation_y(yaw.to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(
(slot as f32 - 1.0) * 0.3125,
if shelf.align_to_bottom { -0.25 } else { 0.0 },
-0.25,
))
* glam::Mat4::from_scale(glam::Vec3::splat(0.25))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, offset_y, 0.0));
self.submit_item_world(&item, pose, light, out);
}
}
}
}
@@ -425,7 +425,7 @@ impl Loading {
}
}
// Mods' settings pages open only in a running game.
Event::ModSettings(_) | Event::NativeSettings(_) | Event::NativeButton(..) => {}
Event::NativeSettings(_) | Event::NativeButton(..) => {}
// The startup menu runs before the game opens a control socket.
Event::ButtonClick | Event::CopyAgentDetails => {}
}
+17
View File
@@ -940,6 +940,23 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
match entity {
BlockEntity::Sign(sign) => Some(Tag::compound(sign.encode())),
BlockEntity::Spawner(s) => Some(s.update_tag()),
BlockEntity::Brushable(b) => {
let mut entries = Vec::new();
if let Some(face) = b.hit_direction {
entries.push(("hit_direction", Tag::Byte(face as i8)));
}
if let Some(item) = &b.item {
entries.push(("item", crate::save::item_to_nbt(item)));
}
Some(Tag::compound(entries))
}
BlockEntity::Shelf(s) => Some(Tag::compound([
("Items", crate::save::encode_items(&s.items)),
(
"align_items_to_bottom",
Tag::Byte(i8::from(s.align_to_bottom)),
),
])),
// BannerBlockEntity.getUpdateTag: saveWithoutMetadata, so the patterns,
// the custom name and the components.
BlockEntity::Banner(b) => {
-1
View File
@@ -74,7 +74,6 @@ pub mod merchant;
pub mod mobs;
pub mod mod_content;
pub mod motion;
pub mod moved_extras;
pub mod multiplayer;
pub mod music;
pub mod net;
+4
View File
@@ -2313,6 +2313,10 @@ impl Mob {
{
aquatic::spawn_ink(self);
}
// AbstractHorse.hurtServer: the rear roll after the hurt.
if equine::is_equine(self.species()) {
equine::after_hurt_server(self);
}
true
}
/// A splash or cloud's harming effect on this mob: the potion's
+7
View File
@@ -507,6 +507,13 @@ pub fn tick(
if m.species() == Species::Endermite {
endermite_lifetime(m);
}
// AbstractHorse.tick runs the stand counter down with or without AI.
if super::equine::is_equine(m.species())
&& !super::camel::is_camel(m.species())
&& !super::llama::is_llama(m.species())
{
super::equine::stand_tick(m);
}
} else {
ai_step(m, w, level_random, events);
}
+71 -5
View File
@@ -304,15 +304,27 @@ impl Goal for RunAroundLikeCrazy {
make_mad(m, cx.events);
}
}
/// `AbstractHorse.standIfPossible`: `setStanding(20)` (which also stops the
/// eating) when `canPerformRearing`.
fn stand_if_possible(m: &mut Mob) {
if can_rear(m.species()) {
m.extra.counter = 20;
m.extra.flag = false;
}
}
/// `AbstractHorse.hurtServer`, after the hurt went through: one hit in three
/// rears the horse. Llamas and camels make the draw too, but never rear.
pub fn after_hurt_server(m: &mut Mob) {
if m.random.next_int_bound(3).unwrap_or(1) == 0 {
stand_if_possible(m);
}
}
/// `AbstractHorse.makeMad`: rear with the angry sound (`makeSound`).
pub fn make_mad(m: &mut Mob, events: &mut Vec<MobEvent>) {
if m.extra.counter > 0 {
return;
}
if can_rear(m.species()) {
m.extra.counter = 20;
m.extra.flag = false;
}
stand_if_possible(m);
// getAngrySound: null (silent) for the skeleton horse.
let sound = match m.species() {
Species::SkeletonHorse => return,
@@ -663,10 +675,15 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.extra.flag = false;
}
}
stand_tick(m);
animate(m);
}
/// `AbstractHorse.tick`: the stand counter runs down to `clearStanding`. It is
/// in `tick`, not `aiStep`, so a horse with no AI stops rearing too.
pub fn stand_tick(m: &mut Mob) {
if m.extra.counter > 0 {
m.extra.counter -= 1;
}
animate(m);
}
/// `AbstractHorse.tick`'s eat, stand and mouth animations, which follow the
/// eating and standing flags (`flag` and `counter > 0`).
@@ -856,10 +873,13 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mobs::Damage;
use crate::mobs::finalize::base_value;
use crate::mobs::sensor_scan_tests::kind;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
@@ -889,4 +909,50 @@ mod tests {
finalize(&mut m);
assert_eq!(base_value(&m, Attr::MovementSpeed), m.attrs.speed);
}
fn equine(id: u32, species: Species, name: &str) -> Mob {
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = name.into();
k.species = species;
k.max_health = 30.0;
Mob::new(id, kind, Vec3::new(f64::from(id), 64.0, 0.0), 0.0)
}
/// Hits `n` fresh mobs of a species once each; how many reared.
fn rears(species: Species, name: &str, n: u32) -> u32 {
(1..=n)
.filter(|&id| {
let mut m = equine(id, species, name);
assert!(m.damage(1.0, Damage::player([0.0, 64.0, 0.0])));
m.extra.counter == 20
})
.count() as u32
}
#[test]
fn a_hurt_horse_rears_one_hit_in_three() {
// AbstractHorse.hurtServer: nextInt(3) == 0 -> standIfPossible (#3432).
let n = rears(Species::Horse, "minecraft:horse", 300);
assert!((60..=140).contains(&n), "{n} of 300 hits reared");
}
#[test]
fn the_rear_ends_after_twenty_ticks() {
// AbstractHorse.tick: standCounter runs down, then clearStanding.
let mut m = equine(1, Species::Horse, "minecraft:horse");
stand_if_possible(&mut m);
for _ in 0..19 {
stand_tick(&mut m);
}
assert!(m.extra.counter > 0);
stand_tick(&mut m);
assert_eq!(m.extra.counter, 0);
}
#[test]
fn llamas_and_camels_never_rear_when_hurt() {
// Llama and Camel: canPerformRearing is false.
assert_eq!(rears(Species::Llama, "minecraft:llama", 60), 0);
assert_eq!(rears(Species::Camel, "minecraft:camel", 60), 0);
}
}
-286
View File
@@ -1,286 +0,0 @@
//! Extras that became official mods (docs/mods.md, "Horizon's official
//! mods"). For a player who had one on, the game installs the mod from
//! Horizon's registry once, checked as the launcher checks it, turns it on
//! and carries the extra's settings over into the mod's storage. A player
//! who had it off gets nothing: the mod is in the mod browser.
use crate::{extras::Extras, mod_list::ModList};
use horizon_mods::index::{self, Index};
use std::{
fs,
io::Read,
path::{Path, PathBuf},
};
/// Horizon's registry; `HORIZON_MOD_INDEX` points elsewhere (a local file
/// for trials, whose files may then be `file://`), as for the launcher.
pub const INDEX_URL: &str = "https://horizon-rust.github.io/horizon-mods/index.json";
const MAX_INDEX: u64 = 16 << 20;
/// An extra that is an official mod now.
#[derive(Debug, PartialEq)]
pub struct Moved {
/// The mod's id in the registry.
pub id: &'static str,
/// The extra's name, for the line the player reads.
pub name: &'static str,
}
pub const MOVED: [Moved; 4] = [
Moved {
id: "fps_overlay",
name: "FPS counter",
},
Moved {
id: "keystrokes",
name: "Keystrokes",
},
Moved {
id: "zoom",
name: "Zoom",
},
Moved {
id: "minimap",
name: "Minimap",
},
];
impl Moved {
fn was_on(&self, e: &Extras) -> bool {
match self.id {
"fps_overlay" => e.fps,
"keystrokes" => e.keystrokes,
"zoom" => e.zoom,
_ => e.minimap,
}
}
/// The extra's settings as the mod keeps them (its storage keys).
pub fn settings(&self, e: &Extras) -> Vec<(&'static str, String)> {
let flag = |b: bool| if b { "true" } else { "false" }.to_owned();
match self.id {
"fps_overlay" => vec![
("corner", e.fps_corner.name().into()),
("lows", flag(e.fps_lows)),
],
"keystrokes" => vec![
("corner", e.keystrokes_corner.name().into()),
("cps", flag(e.keystrokes_cps)),
],
"zoom" => vec![
("level", e.zoom_level.to_string()),
("smooth", flag(e.zoom_smooth)),
("sensitivity", flag(e.zoom_sensitivity)),
],
_ => vec![
("corner", e.minimap_corner.name().into()),
("size", e.minimap_size.to_string()),
(
"shape",
if e.minimap_round { "round" } else { "square" }.into(),
),
("zoom", e.minimap_zoom.to_string()),
("rotate", flag(e.minimap_rotate)),
],
}
}
/// The extra's key as the mod's key (`horizon-mods.txt`), when the
/// player had changed it from the mod's default.
pub fn keys(&self, e: &Extras) -> Vec<(&'static str, String)> {
match self.id {
"zoom" if e.zoom_key != "key.keyboard.z" => vec![("zoom", e.zoom_key.clone())],
_ => Vec::new(),
}
}
/// The line the player reads once it is installed.
pub fn notice(&self) -> String {
format!("{} moved to the mod browser: installed for you", self.name)
}
}
/// The moved extras still to install: on, and not done yet.
pub fn pending(e: &Extras) -> Vec<&'static Moved> {
MOVED
.iter()
.filter(|m| m.was_on(e) && !e.moved.iter().any(|d| d == m.id))
.collect()
}
/// The moved extras that were off and not marked yet: nothing to install,
/// only to remember.
pub fn nothing_to_do(e: &Extras) -> Vec<&'static Moved> {
MOVED
.iter()
.filter(|m| !m.was_on(e) && !e.moved.iter().any(|d| d == m.id))
.collect()
}
fn location() -> String {
std::env::var("HORIZON_MOD_INDEX")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| INDEX_URL.to_owned())
}
/// `url`'s bytes, at most `max`: `https://` from the network; `file://` only
/// with a local index (a trial registry).
fn get(url: &str, local: bool, max: u64) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
if url.starts_with("https://") {
let response = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(60))
.build()
.get(url)
.call()
.map_err(|e| format!("{url}: {e}"))?;
response
.into_reader()
.take(max + 1)
.read_to_end(&mut out)
.map_err(|e| e.to_string())?;
} else {
let path = match url.strip_prefix("file://") {
Some(p) if local => p,
_ if local && !url.contains("://") => url,
_ => return Err(format!("{url}: not an https address")),
};
out = fs::read(path).map_err(|e| format!("{path}: {e}"))?;
}
if out.len() as u64 > max {
return Err(format!("{url}: over {max} bytes"));
}
Ok(out)
}
/// The registry's index, from the network (or `HORIZON_MOD_INDEX`).
pub fn fetch_index() -> Result<Index, String> {
let at = location();
let local = !at.starts_with("https://");
let text = String::from_utf8(get(&at, local, MAX_INDEX)?).map_err(|e| e.to_string())?;
if local {
Index::parse_local(&text)
} else {
Index::parse(&text)
}
}
/// Installs `m` into the game folder `dir` (where `options.txt` is), for a
/// player whose extras were `e`: its newest version for this game that
/// wasn't taken down and that this Horizon's mod API level runs, checked
/// against the index (size, SHA-256, signature, manifest), as
/// `horizon-mods/<id>.hmod`, turned on, with the extra's settings in its
/// storage unless the mod has its own already.
pub fn install(index: &Index, m: &Moved, e: &Extras, dir: &Path) -> Result<(), String> {
let project = index
.project(m.id)
.ok_or_else(|| format!("{} isn't in Horizon's registry yet", m.id))?;
let version = index
.installable(
project,
horizon_core::version::VERSION_ID,
horizon_mods::api_level::LEVEL,
)
.ok_or_else(|| format!("{} has no version this Horizon can run", m.id))?;
let mods = dir.join(crate::mod_list::DIR);
fs::create_dir_all(&mods).map_err(|e| format!("{}: {e}", mods.display()))?;
let target = mods.join(format!("{}.hmod", m.id));
let current = index::sha256_file(&target).ok();
if current.as_deref() != Some(version.file.sha256.as_str()) {
let local = !location().starts_with("https://");
let bytes = get(&version.file.url, local, index::MAX_FILE)?;
let partial: PathBuf = mods.join(format!(".{}.hmod.download", m.id));
fs::write(&partial, bytes).map_err(|e| format!("{}: {e}", partial.display()))?;
if let Err(e) = index::check_file(index, project, version, &partial) {
let _ = fs::remove_file(&partial);
return Err(format!("{}: {e}", m.id));
}
fs::rename(&partial, &target).map_err(|e| format!("{}: {e}", target.display()))?;
}
let storage = mods.join(horizon_mods::manifest::STORAGE_DIR).join(m.id);
fs::create_dir_all(&storage).map_err(|e| format!("{}: {e}", storage.display()))?;
for (key, value) in m.settings(e) {
let path = storage.join(key);
if !path.exists() {
fs::write(&path, value).map_err(|e| format!("{}: {e}", path.display()))?;
}
}
let mut list = ModList::load(dir);
list.enabled.insert(m.id.to_owned());
// A key the player already chose for the mod stays.
let keys = list.keys.entry(m.id.to_owned()).or_default();
for (name, key) in m.keys(e) {
keys.entry(name.to_owned()).or_insert(key);
}
list.keys.retain(|_, k| !k.is_empty());
list.save(dir);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extras::Corner;
#[test]
fn only_extras_that_were_on_are_installed_and_each_once() {
let mut e = Extras::default();
assert!(pending(&e).is_empty());
assert_eq!(nothing_to_do(&e).len(), 4);
e.fps = true;
e.fps_corner = Corner::BottomRight;
e.fps_lows = true;
assert_eq!(pending(&e), [&MOVED[0]]);
assert_eq!(
MOVED[0].settings(&e),
[("corner", "bottom_right".into()), ("lows", "true".into())]
);
assert_eq!(
MOVED[1].settings(&e),
[("corner", "top_left".into()), ("cps", "true".into())]
);
e.moved.push("fps_overlay".into());
assert!(pending(&e).is_empty());
assert_eq!(nothing_to_do(&e), [&MOVED[1], &MOVED[2], &MOVED[3]]);
// Zoom: its settings, and its key only when the player changed it.
e.zoom = true;
e.zoom_level = 8;
e.zoom_smooth = false;
assert_eq!(pending(&e), [&MOVED[2]]);
assert_eq!(
MOVED[2].settings(&e),
[
("level", "8".into()),
("smooth", "false".into()),
("sensitivity", "true".into())
]
);
assert!(MOVED[2].keys(&e).is_empty());
e.zoom_key = "key.mouse.4".into();
assert_eq!(MOVED[2].keys(&e), [("zoom", "key.mouse.4".into())]);
// The minimap: its five settings, in the extra's values.
e.minimap = true;
e.minimap_corner = Corner::BottomLeft;
e.minimap_size = 128;
e.minimap_round = true;
e.minimap_zoom = 0.5;
assert_eq!(pending(&e), [&MOVED[2], &MOVED[3]]);
assert_eq!(
MOVED[3].settings(&e),
[
("corner", "bottom_left".into()),
("size", "128".into()),
("shape", "round".into()),
("zoom", "0.5".into()),
("rotate", "false".into())
]
);
assert_eq!(
MOVED[0].notice(),
"FPS counter moved to the mod browser: installed for you"
);
}
#[test]
fn a_file_url_needs_a_local_index() {
assert!(get("file:///etc/hosts", false, 1 << 20).is_err());
assert!(get("http://example.com/x", true, 1 << 20).is_err());
}
}
+33
View File
@@ -483,6 +483,13 @@ pub fn encode_block_entity(
}
}
BlockEntity::Sculk(s) => entries.extend(s.to_nbt()),
BlockEntity::Shelf(shelf) => {
entries.push(("Items", encode_items(&shelf.items)));
entries.push((
"align_items_to_bottom",
Tag::Byte(i8::from(shelf.align_to_bottom)),
));
}
// ChiseledBookShelfBlockEntity.saveAdditional.
BlockEntity::ChiseledBookshelf(b) => {
entries.push(("Items", encode_items(&b.items)));
@@ -547,6 +554,16 @@ pub fn encode_block_entity(
BlockEntity::Vault(v) => entries.extend(v.save()),
BlockEntity::Beehive(h) => entries.extend(h.to_nbt()),
BlockEntity::CommandBlock(c) => entries.extend(c.save()),
BlockEntity::Brushable(b) => {
if let Some(table) = &b.loot_table {
entries.push(("LootTable", table.clone()));
if let Some(seed) = &b.loot_seed {
entries.push(("LootTableSeed", seed.clone()));
}
} else if let Some(item) = &b.item {
entries.push(("item", item_to_nbt(item)));
}
}
BlockEntity::DaylightDetector => {}
}
Tag::compound(entries)
@@ -724,8 +741,24 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
_ => 1.0,
};
}
BlockEntity::Brushable(b) => {
b.loot_table = tag.get("LootTable").cloned();
b.loot_seed = tag.get("LootTableSeed").cloned();
b.item = if b.loot_table.is_none() {
tag.get("item").and_then(item_from_nbt)
} else {
None
};
b.hit_direction = int("hit_direction").filter(|v| (0..=5).contains(v));
}
BlockEntity::Sign(sign) => sign.decode(tag),
BlockEntity::Sculk(s) => s.load(tag),
BlockEntity::Shelf(shelf) => {
for (slot, item) in decode_items(tag.get("Items"), 3).into_iter().enumerate() {
shelf.items[slot] = item;
}
shelf.align_to_bottom = int("align_items_to_bottom").unwrap_or(0) != 0;
}
// ChiseledBookShelfBlockEntity.loadAdditional.
BlockEntity::ChiseledBookshelf(b) => {
for (slot, item) in decode_items(tag.get("Items"), 6).into_iter().enumerate() {
+1 -1
View File
@@ -694,7 +694,7 @@ impl ChunkGenerator for VanillaTerrain {
crate::structure_blocks::block_entities(&chunk, self.loot.as_deref());
out.block_entities = block_entities;
// The block entities Horizon does not model (decorated pots with their
// sherds and loot table, brushable blocks) stay as placed, like a
// sherds and loot table) stay as placed, like a
// loaded chunk's.
let mut unmodelled: Vec<Tag> = chunk
.block_entities
+5
View File
@@ -107,6 +107,8 @@ pub struct ItemInfo {
pub weapon_damage: Option<u32>,
/// `use_effects.speed_multiplier` (UseEffects.DEFAULT: 0.2).
pub use_speed: f32,
/// `use_effects.interact_vibrations` (default true).
pub interact_vibrations: bool,
/// `glider` (elytra).
pub glider: bool,
/// `equippable.swappable` and `equip_sound`.
@@ -663,6 +665,9 @@ impl Items {
use_speed: c["minecraft:use_effects"]["speed_multiplier"]
.as_f64()
.unwrap_or(0.2) as f32,
interact_vibrations: c["minecraft:use_effects"]["interact_vibrations"]
.as_bool()
.unwrap_or(true),
blocks_attacks: BlocksAttacks::parse(&c["minecraft:blocks_attacks"]),
disable_blocking_seconds: c["minecraft:weapon"]["disable_blocking_for_seconds"]
.as_f64()
+1
View File
@@ -567,6 +567,7 @@ props! {
creaking_heart_state = "creaking_heart_state",
cracked = "cracked",
has_book = "has_book",
has_record = "has_record",
signal_fire = "signal_fire",
orientation = "orientation",
crafting = "crafting",
@@ -397,9 +397,11 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
| Class::BrewingStand
| Class::CartographyTable
| Class::Chest
| Class::CopperChest
| Class::Crafter
| Class::CraftingTable
| Class::Dispenser
| Class::Dropper
| Class::EnchantingTable
| Class::EnderChest
| Class::Grindstone
@@ -409,10 +411,16 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
| Class::Sign
| Class::SmithingTable
| Class::Stonecutter
| Class::TrappedChest
| Class::Lectern => {
self.player_effect(pos, state, PlayerEffect::OpenMenu);
Success
}
// DecoratedPotBlock.useWithoutItem: the wobble always succeeds.
Class::DecoratedPot => Success,
// JukeboxBlock.useWithoutItem: ejecting a disc is SUCCESS (the
// insert is useItemOn). An empty jukebox passes.
Class::Jukebox if table.bool(state, p.has_record) => Success,
Class::Command | Class::Structure | Class::Jigsaw => {
if player.game_master {
self.player_effect(pos, state, PlayerEffect::OpenMenu);
+38
View File
@@ -1474,6 +1474,44 @@ fn big_dripleaf_tilts_under_an_entity_standing_on_it() {
assert_eq!(w.prop(pos, "tilt"), "none");
}
#[test]
fn using_storage_and_interactable_blocks_succeeds() {
let mut w = world!();
let pos = BlockPos::new(0, 1, 0);
let cases: &[(&str, &[(&str, &str)])] = &[
("chest", &[("facing", "north"), ("type", "single")]),
("trapped_chest", &[("facing", "north"), ("type", "single")]),
("barrel", &[("facing", "up")]),
("ender_chest", &[("facing", "north")]),
("shulker_box", &[("facing", "up")]),
("dispenser", &[("facing", "north")]),
("dropper", &[("facing", "north")]),
("hopper", &[("facing", "down")]),
("crafter", &[("orientation", "north_up")]),
("furnace", &[("facing", "north")]),
("blast_furnace", &[("facing", "north")]),
("smoker", &[("facing", "north")]),
("brewing_stand", &[]),
("beacon", &[]),
("decorated_pot", &[]),
("jukebox", &[("has_record", "true")]),
];
for &(name, props) in cases {
w.put(pos, w.id(name, props));
assert_eq!(
w.use_block(pos),
InteractionResult::Success,
"{name} should succeed (client swing)"
);
}
w.put(pos, w.id("jukebox", &[("has_record", "false")]));
assert_eq!(
w.use_block(pos),
InteractionResult::Pass,
"an empty jukebox passes"
);
}
#[test]
fn creative_breaking_piston_head_does_not_drop_piston() {
for (piston_name, head_type) in [("piston", "normal"), ("sticky_piston", "sticky")] {
+3 -3
View File
@@ -9,7 +9,7 @@ PACKAGES = {"horizon-core": "crates/horizon-core", "horizon-client": "crates/hor
"horizon-theme": "crates/horizon-theme", "xtask": "xtask"}
LIBRARIES = set(PACKAGES) - {"xtask"}
DEPENDENCIES = {"horizon-client": {"horizon-core"}}
MODS = " --features horizon-client/mods,horizon-mods/pack"
MODS = " --features horizon-mods/pack"
CLIPPY = pr_ready.CI_CLIPPY + " && " + pr_ready.CI_CLIPPY_SERVER
@@ -52,7 +52,7 @@ class PrReadyTests(unittest.TestCase):
by_name = {step.name: step for step in steps}
self.assertEqual(by_name["clippy"].command, CLIPPY)
self.assertEqual(by_name["tests: horizon-client"].command,
"cargo test --locked -p horizon-client --features horizon-client/mods --tests")
"cargo test --locked -p horizon-client --tests")
self.assertEqual(by_name["doc tests"].command, "cargo test --locked -p horizon-client --doc")
self.assertIsNone(by_name["doc tests"].status)
@@ -81,7 +81,7 @@ class PrReadyTests(unittest.TestCase):
def test_tests_get_the_features_ci_s_workspace_run_gives_them(self):
self.assertEqual(pr_ready.mod_features(["horizon-world"]), "")
self.assertEqual(pr_ready.mod_features(["horizon-client"]), " --features horizon-client/mods")
self.assertEqual(pr_ready.mod_features(["horizon-client"]), "")
self.assertEqual(pr_ready.mod_features(["horizon-mods"]),
" --features horizon-mods/runtime,horizon-mods/pack,horizon-mods/index")
self.assertEqual(pr_ready.mod_features(None), MODS)
+8 -10
View File
@@ -269,24 +269,22 @@ def parity_summary(output):
return text
# CI's verify job (.github/workflows/ci.yml): clippy over the whole workspace with the game's code mods, and over the
# server build (the game without sound or controllers); tests with code mods and mod packaging.
CI_CLIPPY = "cargo clippy --workspace --all-targets --locked --features horizon-client/mods -- -D warnings"
# CI's verify job (.github/workflows/ci.yml): clippy over the whole workspace, and over the server build (the game
# without sound or controllers); tests with mod packaging.
CI_CLIPPY = "cargo clippy --workspace --all-targets --locked -- -D warnings"
CI_CLIPPY_SERVER = "cargo clippy -p horizon-client --all-targets --locked --no-default-features -- -D warnings"
CI_TEST_FEATURES = "horizon-client/mods,horizon-mods/pack"
CI_TEST_FEATURES = "horizon-mods/pack"
def mod_features(crates):
"""The features CI's workspace-wide test run gives these crates (code mods are off by default: docs/mods.md).
Cargo unifies features over the packages it builds, so a crate tested alone needs the ones the workspace would
have turned on for it: horizon-client's code mods, and horizon-mods' runtime, packaging and index."""
"""The features CI's workspace-wide test run gives these crates. Cargo unifies features over the packages it
builds, so a crate tested alone needs the ones the workspace would have turned on for it: horizon-mods' runtime,
packaging and index."""
if crates is None:
return f" --features {CI_TEST_FEATURES}"
features = []
if "horizon-mods" in crates:
features += ["horizon-mods/runtime", "horizon-mods/pack", "horizon-mods/index"]
if "horizon-client" in crates:
features.append("horizon-client/mods")
return f" --features {','.join(features)}" if features else ""
@@ -294,7 +292,7 @@ def mod_features(crates):
# they are #[ignore]d and run here only when one of these changes.
CONTENT_ROOT = ("crates/horizon-client/src/mod_content", "crates/horizon-client-render/src/assets.rs", "crates/horizon-items/",
"crates/horizon-mods/", "crates/horizon-assets/")
CONTENT_ROOT_TESTS = "cargo test --locked -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored"
CONTENT_ROOT_TESTS = "cargo test --locked -p horizon-client --lib mod_content::tests:: -- --ignored"
def plan(args, base, changed, crates_of, libraries, dependencies):
+5
View File
@@ -310,6 +310,11 @@ def scenarios():
('witch', 176), ('vindicator', 177), ('zombie', 178)]:
add(f'{species}_deep_water', 160, ['fill -4 -64 -4 4 -61 4 minecraft:water',
f'mob minecraft:{species} 0.5 -62 0.5 0 {seed} persistent'])
# AbstractHorse.hurtServer (#3432): every hurt draws nextInt(3), and a 0 rears the horse
# (standIfPossible). A llama makes the draw too but never rears (canPerformRearing). A horse
# scenario waits for #2720: a rearing horse is immobile (AbstractHorse.isImmobile).
add('llama_hurt', 200, ['player 4.5 -60 0.5', 'mob minecraft:llama 0.5 -60 0.5 0 182 persistent']
+ [f'hurt {t} 0' for t in range(10, 190, 25)])
# Axolotls (#64): idle swimming, hunting a cod, and playing dead after hits, in a pool sunk into
# the ground so no water flows out of it (the probe doesn't tick fluids).
POOL = 'fill -5 -63 -5 5 -61 5 minecraft:water'