mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baa379d717 | ||
|
|
28a65af5fc | ||
|
|
a6751f2aa9 | ||
|
|
ea60e1e062 | ||
|
|
047815c5d7 | ||
|
|
d22aecd526 | ||
|
|
01b4e5cbc4 | ||
|
|
7ebf7d26cb | ||
|
|
ab0309581c | ||
|
|
26f099d168 | ||
|
|
1fb8e84561 | ||
|
|
38da7d8d6e | ||
|
|
5a1c095724 | ||
|
|
bc78cba5b9 | ||
|
|
62c0834880 | ||
|
|
98c0883c8f | ||
|
|
a7f1f70508 | ||
|
|
6e8ee3ed07 | ||
|
|
a5a0e1bb98 | ||
|
|
a20aaf5396 | ||
|
|
1eb34fa68e | ||
|
|
a30b9525d1 | ||
|
|
e7614d2197 | ||
|
|
cef862471f | ||
|
|
326719539d | ||
|
|
dd130f1902 | ||
|
|
1a9040ad79 | ||
|
|
42388d2512 | ||
|
|
2deaab2602 | ||
|
|
ca28a0edad |
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(®ion, &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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -801,6 +801,7 @@ impl Game<'_> {
|
||||
self.screen = super::Screen::Inventory;
|
||||
self.capture(false);
|
||||
self.right = false;
|
||||
self.swing_arm();
|
||||
return true;
|
||||
}
|
||||
if sneaking
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")] {
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user