mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f56e3165a | ||
|
|
98b4d049fa | ||
|
|
6c2498236d | ||
|
|
7ddbb41f86 | ||
|
|
4b38e08b7f | ||
|
|
30c20a7a54 | ||
|
|
f96d9e89b5 | ||
|
|
bca5833f8d | ||
|
|
4a8a101391 | ||
|
|
07aab11e71 | ||
|
|
bc21da03bc | ||
|
|
93fc4b2ba9 | ||
|
|
f3cc9257df | ||
|
|
0a407714e0 | ||
|
|
3bb4e7827c | ||
|
|
546eab04b7 | ||
|
|
4e6920a319 | ||
|
|
c214f59711 | ||
|
|
4a374440f4 | ||
|
|
b99e2087d3 | ||
|
|
b84c97088f | ||
|
|
57da7aecbc | ||
|
|
e93afc5ade | ||
|
|
a7296b75e0 | ||
|
|
36584650ea | ||
|
|
aee2c63c3b | ||
|
|
ef23545cef | ||
|
|
92c708b3fe | ||
|
|
c6f935414d | ||
|
|
73231e735d | ||
|
|
a71408736f | ||
|
|
8d2900e6ce | ||
|
|
cd1318bf02 | ||
|
|
2f661c08ed | ||
|
|
1bf8766c1d | ||
|
|
1f9493c4fc | ||
|
|
ebf06768f1 | ||
|
|
fd03679d85 | ||
|
|
ca896819d7 | ||
|
|
0a8df72a16 | ||
|
|
074aa1124f | ||
|
|
703c0ea18b | ||
|
|
8b583a15e1 | ||
|
|
c0a3af67b0 | ||
|
|
aed804a26d | ||
|
|
8ce0848a31 | ||
|
|
3c73be2517 | ||
|
|
917ce67f0d | ||
|
|
b11fe1da84 | ||
|
|
fdb23b8ac6 | ||
|
|
a505caa32a | ||
|
|
c7a2daa593 | ||
|
|
3a967d8838 | ||
|
|
bd967852e5 | ||
|
|
ecdecc8fd2 | ||
|
|
c4c084be94 | ||
|
|
6e72e2de7d | ||
|
|
7c34cfa6b2 | ||
|
|
2877c7fbfa | ||
|
|
5be2aba2ae | ||
|
|
1b63e0abc2 | ||
|
|
56ec50a875 | ||
|
|
a20d3296b4 | ||
|
|
eadbb41095 | ||
|
|
bc59734a29 | ||
|
|
626c60740d | ||
|
|
de0132b638 | ||
|
|
3d5216e717 | ||
|
|
14ba691e53 | ||
|
|
877cb37978 | ||
|
|
34194ab7ec | ||
|
|
ec77214342 | ||
|
|
bec6ac6448 | ||
|
|
a2b3f669ef | ||
|
|
ce4f0d7074 | ||
|
|
031037cee5 | ||
|
|
70342ed68b | ||
|
|
ef6fc399f5 | ||
|
|
7f51885a85 | ||
|
|
5ecd155b76 | ||
|
|
5fe374444f | ||
|
|
ff50de2553 | ||
|
|
d4ea299fdd | ||
|
|
5383881562 | ||
|
|
1c402c47a7 | ||
|
|
75dd0dde7d | ||
|
|
8c4e3bb507 | ||
|
|
dabae908cd | ||
|
|
2d94afaa61 | ||
|
|
14a5193b41 | ||
|
|
7225a84b10 | ||
|
|
939d492535 | ||
|
|
41934f3d93 | ||
|
|
85a6a8b9dc | ||
|
|
b97f0f61d7 | ||
|
|
e01adc67e3 | ||
|
|
32137ae44f | ||
|
|
9503a3cc7d | ||
|
|
7c4a6d1ea6 | ||
|
|
3c4d936707 | ||
|
|
44f5a37e64 | ||
|
|
5ffd5c6515 | ||
|
|
ea99e6572c | ||
|
|
c5dbb620c2 | ||
|
|
92bedb26d0 | ||
|
|
721d4cfa44 | ||
|
|
26496db7c3 | ||
|
|
d5f931ca64 | ||
|
|
c0806b9ee3 | ||
|
|
70bc17720c | ||
|
|
6fa7f8896a | ||
|
|
fb3f1bce90 | ||
|
|
dddf351a05 | ||
|
|
ce25d100ef | ||
|
|
8b577fc24c | ||
|
|
974305151d | ||
|
|
e6941baa5a | ||
|
|
7cd9dbc7a2 | ||
|
|
2dd6879bc4 | ||
|
|
ebfcad98f6 | ||
|
|
381c5a1e62 | ||
|
|
2688eb16d6 | ||
|
|
ac4859eab4 | ||
|
|
087c763432 | ||
|
|
6d5c7c51a6 | ||
|
|
ab6900b4c9 | ||
|
|
96c7d68d0d | ||
|
|
47dc19fa20 | ||
|
|
a33e5b574a | ||
|
|
78cf6e36a0 | ||
|
|
bd991e7917 |
No files matched your search
@@ -168,7 +168,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
|
||||
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pad_craft.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
|
||||
@@ -412,10 +412,11 @@ pub fn to_text_with(
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let translated = Text::translate(key, &args);
|
||||
// TranslatableContents.decompose: Language.getOrDefault(key, fallback), so the
|
||||
// fallback is the template itself, never looked up as another key.
|
||||
match o.get("fallback").and_then(Value::as_str) {
|
||||
Some(fallback) if translated.plain() == key => Text::translate(fallback, &args),
|
||||
_ => translated,
|
||||
Some(fallback) => Text::translate_with_fallback(key, fallback, &args),
|
||||
None => Text::translate(key, &args),
|
||||
}
|
||||
} else if let Some(sel) = o.get("selector").and_then(Value::as_str) {
|
||||
resolve(sel)
|
||||
@@ -513,6 +514,43 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lang::{self, Language, ResourceSource};
|
||||
|
||||
struct Mem(std::collections::HashMap<String, Vec<u8>>);
|
||||
impl ResourceSource for Mem {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.0.get(path).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// TranslatableContents.decompose: Language.getOrDefault(key, fallback). A missing key
|
||||
/// shows the fallback as written, even when the fallback is another key the language has.
|
||||
#[test]
|
||||
fn a_translatable_fallback_stays_literal() {
|
||||
let pack = Mem([(
|
||||
"assets/minecraft/lang/en_us.json".to_owned(),
|
||||
br#"{"real.key": "Translated", "greet": "Hello %s"}"#.to_vec(),
|
||||
)]
|
||||
.into());
|
||||
let ((), _) = lang::scoped(Language::load(&[&pack], "en_us"), || {
|
||||
let parse = |v: Value| to_text(&v, &mut |_| Text::empty()).plain();
|
||||
let missing = serde_json::json!({"translate": "missing.key", "fallback": "real.key"});
|
||||
assert_eq!(parse(missing), "real.key");
|
||||
// The fallback is a template: its arguments are filled in.
|
||||
let args = serde_json::json!({"translate": "missing.key", "fallback": "Hi %s", "with": ["Steve"]});
|
||||
assert_eq!(parse(args), "Hi Steve");
|
||||
// A key the language has wins over its fallback.
|
||||
let found =
|
||||
serde_json::json!({"translate": "greet", "fallback": "real.key", "with": ["Alex"]});
|
||||
assert_eq!(parse(found), "Hello Alex");
|
||||
// A component built with the constructor keeps its meaning through its JSON.
|
||||
let built = Text::translate_with_fallback("missing.key", "real.key", &[]);
|
||||
let again = to_text(&built.component(), &mut |_| Text::empty());
|
||||
assert_eq!(again.plain(), built.plain());
|
||||
assert_eq!(again.component(), built.component());
|
||||
});
|
||||
}
|
||||
|
||||
/// SnbtGrammar: a compound or list may end with one comma; two in a row
|
||||
/// stay an error.
|
||||
#[test]
|
||||
|
||||
@@ -583,6 +583,10 @@ pub struct PadSettings {
|
||||
/// The sneak button flips sneaking (Bedrock's default) instead of
|
||||
/// holding it, unless vanilla's toggle crouch is on already.
|
||||
pub toggle_sneak: bool,
|
||||
/// LT + A on a recipe crafts on every server, not only in single player and on LAN worlds
|
||||
/// found in the server list. Off by default: a server's rules may count a repeated craft as a
|
||||
/// macro.
|
||||
pub craft_on_servers: bool,
|
||||
pub glyphs: GlyphStyle,
|
||||
/// A Nintendo pad's face buttons go by their letters (A confirms and jumps, as on the
|
||||
/// Switch's own menus and keyboard) instead of their places (B, at the bottom, does): on
|
||||
@@ -614,6 +618,7 @@ impl Default for PadSettings {
|
||||
look_deadzone: 0.12,
|
||||
trigger_threshold: 0.4,
|
||||
toggle_sneak: true,
|
||||
craft_on_servers: false,
|
||||
glyphs: GlyphStyle::Auto,
|
||||
nintendo_letters: cfg!(target_os = "horizon"),
|
||||
keyboard: TextKeyboard::Auto,
|
||||
@@ -684,6 +689,7 @@ impl PadSettings {
|
||||
s.trigger_threshold = unit(s.trigger_threshold).clamp(0.1, 0.9)
|
||||
}
|
||||
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
|
||||
"craftOnServers" => s.craft_on_servers = flag(s.craft_on_servers),
|
||||
"glyphs" => s.glyphs = GlyphStyle::parse(value),
|
||||
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
|
||||
"keyboard" => s.keyboard = TextKeyboard::parse(value),
|
||||
@@ -745,7 +751,8 @@ impl PadSettings {
|
||||
self.keyboard.name(),
|
||||
);
|
||||
text.push_str(&format!(
|
||||
"gameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
|
||||
"craftOnServers:{}\ngameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
|
||||
bool_text(self.craft_on_servers),
|
||||
self.guide_detail.name(),
|
||||
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
|
||||
));
|
||||
|
||||
@@ -83,6 +83,8 @@ pub enum BlockEntity {
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// `BellBlockEntity`: saved identity only; ringing state is kept separately.
|
||||
Bell,
|
||||
/// Suspicious sand and gravel: saved loot and the transient brushed face.
|
||||
Brushable(Box<Brushable>),
|
||||
/// A shelf holds three displayed item stacks.
|
||||
@@ -452,6 +454,9 @@ pub struct Jukebox {
|
||||
pub items: [Option<ItemStack>; 1],
|
||||
/// `ticks_since_song_started`, while a song plays.
|
||||
pub ticks: i64,
|
||||
/// `JukeboxBlockEntity.saveAdditional` writes the clock only when its song
|
||||
/// reference exists. Keep this separate from legacy playback adoption.
|
||||
pub song_clock_present: bool,
|
||||
pub playing: bool,
|
||||
}
|
||||
impl Jukebox {
|
||||
|
||||
@@ -392,6 +392,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:end_gateway" {
|
||||
return Some(Self::EndGateway(EndGateway::default()));
|
||||
}
|
||||
if name == "minecraft:bell" {
|
||||
return Some(Self::Bell);
|
||||
}
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
@@ -487,6 +490,7 @@ impl BlockEntity {
|
||||
Self::Crafter(_) => name == "minecraft:crafter",
|
||||
Self::EndGateway(_) => name == "minecraft:end_gateway",
|
||||
Self::DaylightDetector => name == "minecraft:daylight_detector",
|
||||
Self::Bell => name == "minecraft:bell",
|
||||
Self::StructureBlock(_) => name == "minecraft:structure_block",
|
||||
Self::TestBlock(_) => name == "minecraft:test_block",
|
||||
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
|
||||
@@ -525,6 +529,7 @@ impl BlockEntity {
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Bell => "minecraft:bell",
|
||||
Self::Brushable(_) => "minecraft:brushable_block",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
@@ -559,6 +564,7 @@ impl BlockEntity {
|
||||
| Self::TestBlock(_)
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -589,6 +595,7 @@ impl BlockEntity {
|
||||
| Self::TestBlock(_)
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Jukebox(j) => &j.items,
|
||||
Self::Crafter(c) => &c.items,
|
||||
@@ -624,6 +631,7 @@ impl BlockEntity {
|
||||
| Self::TestBlock(_)
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Jukebox(j) => &mut j.items,
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
|
||||
@@ -191,6 +191,10 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
Opt::PadGameplayGuide,
|
||||
"Show the buttons for what you can do, while you play with a controller.",
|
||||
),
|
||||
option(
|
||||
Opt::PadCraftOnServers,
|
||||
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.",
|
||||
),
|
||||
option(
|
||||
Opt::PadTriggerThreshold,
|
||||
"How far a trigger is pulled before it counts as pressed.",
|
||||
@@ -344,6 +348,7 @@ impl Item {
|
||||
Item::Option(Opt::PadAdaptiveTriggers) => Some(c.adaptive_triggers),
|
||||
Item::Option(Opt::PadInvertX) => Some(c.invert_x),
|
||||
Item::Option(Opt::PadInvertY) => Some(c.invert_y),
|
||||
Item::Option(Opt::PadCraftOnServers) => Some(c.craft_on_servers),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1149,7 +1149,7 @@ mod tests {
|
||||
for pack in ["void", "islands"] {
|
||||
let dir = game.join(NEW_WORLD_PACKS).join(pack);
|
||||
std::fs::create_dir_all(dir.join("data/v/function")).unwrap();
|
||||
std::fs::write(dir.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(dir.join("pack.mcmeta"), r#"{"pack":{"description":""}}"#).unwrap();
|
||||
std::fs::write(dir.join("data/v/function/a.mcfunction"), "say a").unwrap();
|
||||
}
|
||||
let (temp, ids) = seed_create_world(&game).unwrap();
|
||||
|
||||
@@ -162,6 +162,7 @@ mod arena;
|
||||
pub mod capture;
|
||||
pub mod compositor;
|
||||
pub(crate) mod core_lines;
|
||||
pub(crate) mod core_sky;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
pub mod entity_skin;
|
||||
@@ -1214,6 +1215,8 @@ pub struct Renderer {
|
||||
lines_secondary: wgpu::RenderPipeline,
|
||||
/// A resource pack's `core/rendertype_lines`, which draws the block outline in their place.
|
||||
core_lines: Option<core_lines::CoreLines>,
|
||||
/// The packs' `core/sky` and `core/stars`, drawn in place of the native sky and stars.
|
||||
core_sky: Option<core_sky::CoreSky>,
|
||||
/// Gizmo lines: opaque ones (`RenderTypes.lines`, which writes depth),
|
||||
/// translucent ones (`RenderTypes.linesTranslucentNoDepthWrite`) and the
|
||||
/// always-on-top ones.
|
||||
@@ -1510,6 +1513,9 @@ pub struct Renderer {
|
||||
/// The block sprites as one atlas, while a shader pack is loaded.
|
||||
pack_atlas: Option<pack_atlas::PackAtlas>,
|
||||
pack_material_atlases: Option<[pack_atlas::PackAtlas; 2]>,
|
||||
/// The shared atlas's LabPBR normal and specular paged maps (`set_shared_material_maps`),
|
||||
/// which shader packs read for entities, items and the hand.
|
||||
shared_material_maps: [Option<wgpu::Texture>; 2],
|
||||
/// The pack's terrain program reads flat draws' vertices (`pack_terrain_inputs`).
|
||||
pack_terrain_flat: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
@@ -4605,6 +4611,10 @@ impl Renderer {
|
||||
});
|
||||
let static_entity_index_quads = 1 << 14;
|
||||
let static_entity_indices = Self::quad_indices(&device, static_entity_index_quads);
|
||||
let indirect_execution = adapter
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
|
||||
let flat_wanted = flat::wanted(&adapter, &device);
|
||||
// Metal draws solid terrain in two passes (`split_solid`): meshes put all
|
||||
// their opaque quads first, so each pass draws its part in a run or two.
|
||||
@@ -4643,6 +4653,7 @@ impl Renderer {
|
||||
&bindings,
|
||||
arena.page_vertices(),
|
||||
min_sections,
|
||||
indirect_execution,
|
||||
|layout, label, vertex, fragment, blend, cull, pulled| {
|
||||
let buffers: &[Option<wgpu::VertexBufferLayout<'_>>] =
|
||||
if pulled { &[] } else { &buffers };
|
||||
@@ -4703,10 +4714,7 @@ impl Renderer {
|
||||
let multi_draw = device
|
||||
.features()
|
||||
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE)
|
||||
&& adapter
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
|
||||
&& indirect_execution;
|
||||
let batchable = translucent_batchable(&adapter.get_info(), multi_draw);
|
||||
crate::graphics::offer_translucent_batching(batchable);
|
||||
let translucent_batching = batchable && translucent_batching;
|
||||
@@ -4865,6 +4873,7 @@ impl Renderer {
|
||||
lines,
|
||||
lines_secondary,
|
||||
core_lines: None,
|
||||
core_sky: None,
|
||||
debug_lines_opaque,
|
||||
debug_lines,
|
||||
debug_lines_on_top,
|
||||
@@ -5044,6 +5053,7 @@ impl Renderer {
|
||||
pack_weather: None,
|
||||
pack_atlas: None,
|
||||
pack_material_atlases: None,
|
||||
shared_material_maps: [None, None],
|
||||
pack_terrain_flat: false,
|
||||
separate_ao: false,
|
||||
pack_lava_layers: Vec::new(),
|
||||
@@ -5135,6 +5145,47 @@ impl Renderer {
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
/// The shared atlas's LabPBR maps (`HudAtlas::material_maps`), uploaded for the shader packs
|
||||
/// loaded from now on; a map too large for this GPU is left out.
|
||||
pub fn set_shared_material_maps(&mut self, maps: &[Option<crate::paged_maps::PagedMap>; 2]) {
|
||||
let limit = self.device.limits().max_texture_dimension_2d;
|
||||
let sizes: Vec<String> = (maps.iter().zip(["normals", "specular"]))
|
||||
.filter_map(|(m, name)| {
|
||||
m.as_ref()
|
||||
.map(|m| format!("{name} {}x{}", m.width, m.height))
|
||||
})
|
||||
.collect();
|
||||
if !sizes.is_empty() {
|
||||
let bytes: usize = maps.iter().flatten().map(|m| m.pixels.len()).sum();
|
||||
log::info!(
|
||||
"shared atlas LabPBR maps: {} ({:.1} MiB)",
|
||||
sizes.join(", "),
|
||||
bytes as f64 / f64::from(1 << 20)
|
||||
);
|
||||
}
|
||||
self.shared_material_maps = maps.each_ref().map(|map| {
|
||||
let map = map.as_ref().filter(|m| m.width.max(m.height) <= limit)?;
|
||||
Some(self.device.create_texture_with_data(
|
||||
&self.queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Shared atlas material map"),
|
||||
size: wgpu::Extent3d {
|
||||
width: map.width,
|
||||
height: map.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&map.pixels,
|
||||
))
|
||||
});
|
||||
}
|
||||
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
|
||||
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
|
||||
self.custom_sky.set_pack(&self.device, &self.queue, pack);
|
||||
@@ -5151,6 +5202,15 @@ impl Renderer {
|
||||
self.core_lines =
|
||||
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
|
||||
}
|
||||
/// The resource packs' `core/sky` and `core/stars` (`HudAtlas::sky_shader`,
|
||||
/// `stars_shader`), or the built-in sky and stars.
|
||||
pub fn set_sky_shaders(
|
||||
&mut self,
|
||||
disc: Option<&horizon_shaders::CoreProgram>,
|
||||
stars: Option<&horizon_shaders::CoreProgram>,
|
||||
) {
|
||||
self.core_sky = core_sky::CoreSky::new(&self.device, self.frame_format, disc, stars);
|
||||
}
|
||||
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
|
||||
/// secondary stroke first when high contrast is on.
|
||||
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
|
||||
@@ -6959,7 +7019,7 @@ impl Renderer {
|
||||
dimension: i32,
|
||||
post: std::result::Result<Option<pack_post::PackPost>, pack_post::Refused>,
|
||||
) -> std::result::Result<Option<(Vec<String>, Vec<String>)>, pack_post::Refused> {
|
||||
let post = match post {
|
||||
let mut post = match post {
|
||||
Ok(post) => post,
|
||||
Err(e) => {
|
||||
self.pack_post = Some((dimension, None));
|
||||
@@ -6968,6 +7028,12 @@ impl Renderer {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if let Some(post) = post.as_mut() {
|
||||
post.set_paged_maps(
|
||||
(self.shared_material_maps.each_ref())
|
||||
.map(|t| t.as_ref().map(|t| t.create_view(&Default::default()))),
|
||||
);
|
||||
}
|
||||
let report = post.as_ref().map(|p| {
|
||||
let programs = p.programs().into_iter().map(str::to_owned).collect();
|
||||
(programs, p.notes.clone())
|
||||
@@ -9076,6 +9142,39 @@ impl Renderer {
|
||||
self.uploads.buffer(&mut encoder, core.uniform(), 0, &data);
|
||||
}
|
||||
}
|
||||
// A pack's core/sky and core/stars: the disc with the sky colour, the stars with their
|
||||
// brightness, both turned with the camera only (SkyRenderer's model view).
|
||||
if let Some(core) = &self.core_sky
|
||||
&& !frame.title
|
||||
{
|
||||
let size = [self.config.width, self.config.height];
|
||||
let block = crate::post_chain::Globals {
|
||||
glint_alpha: frame.glint_alpha,
|
||||
..frame.post_effects.globals
|
||||
}
|
||||
.bytes(size, frame.post_effects.menu_blur_radius as i32);
|
||||
let [sky_end, clouds_end, ..] = globals.fog_ends;
|
||||
let fog = [fog[0], fog[1], fog[2], fog[3], sky_end, clouds_end];
|
||||
let view = views.primary();
|
||||
// Far enough for the disc's rim, 512 out: the sky draws without a depth test.
|
||||
let fov = (2.0 * view.tan_half_fov.atan()).to_degrees();
|
||||
let projection = jitter * world_projection(fov, view.aspect, 2048.0) * pose;
|
||||
let [r, g, b, ..] = frame.sky.sky_color;
|
||||
let sky =
|
||||
core_sky::uniforms(block, projection, view.view, [r, g, b, 1.0], fog_color, fog);
|
||||
let stars = frame.stars.map(|(rotation, brightness)| {
|
||||
let b = brightness.clamp(0.0, 1.0);
|
||||
core_sky::uniforms(
|
||||
block,
|
||||
projection,
|
||||
view.view * rotation,
|
||||
[b; 4],
|
||||
fog_color,
|
||||
fog,
|
||||
)
|
||||
});
|
||||
core.prepare(&mut self.uploads, &mut encoder, &sky, stars.as_deref());
|
||||
}
|
||||
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
|
||||
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
|
||||
None => false,
|
||||
@@ -10165,8 +10264,16 @@ impl Renderer {
|
||||
{
|
||||
Some((sky, native)) => sky.draw(&mut pass, native),
|
||||
None => {
|
||||
pass.set_pipeline(&self.sky_base);
|
||||
pass.draw(0..3, 0..1);
|
||||
match self.core_sky.as_ref().filter(|c| c.draws_disc()) {
|
||||
Some(core) => {
|
||||
core.draw_disc(&mut pass);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
None => {
|
||||
pass.set_pipeline(&self.sky_base);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
if frame.sky.sunrise_color[3] > 0.001 {
|
||||
pass.set_pipeline(&self.sunrise_pipeline);
|
||||
pass.set_vertex_buffer(0, self.sunrise_buffer.slice(..));
|
||||
@@ -10217,7 +10324,10 @@ impl Renderer {
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.sky.is_some() && !n.vanilla_stars);
|
||||
if !frame.title && frame.stars.is_some() && !pack_draws_sky && !own_stars {
|
||||
self.stars.draw(&mut pass);
|
||||
match self.core_sky.as_ref().filter(|c| c.draws_stars()) {
|
||||
Some(core) => core.draw_stars(&mut pass),
|
||||
None => self.stars.draw(&mut pass),
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !frame.title && frame.dark_disc && !pack_draws_sky {
|
||||
@@ -12498,7 +12608,7 @@ struct Overlay {
|
||||
hand_view_proj: Mat4,
|
||||
}
|
||||
|
||||
fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
|
||||
pub(crate) fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
|
||||
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, NEAR_PLANE)
|
||||
}
|
||||
|
||||
|
||||
@@ -1047,7 +1047,15 @@ mod tests {
|
||||
hdr: None,
|
||||
}
|
||||
};
|
||||
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid);
|
||||
let mut flat = flat::Flat::new(
|
||||
&device,
|
||||
&terrain_layout,
|
||||
4 * GRAIN,
|
||||
0,
|
||||
false,
|
||||
pipeline,
|
||||
solid,
|
||||
);
|
||||
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
|
||||
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
|
||||
translucent.page_limit = 4 * GRAIN;
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
//! A resource pack's `core/sky` and `core/stars` shaders: the overworld's sky disc and stars drawn
|
||||
//! with the pack's GLSL (`SkyRenderer.renderSkyDisc`, `renderStars`), as a pack that adds a haze
|
||||
//! to the horizon or tints the night sky expects. Without one the sky draws natively (`fs_sky` in
|
||||
//! terrain.wgsl, stars.rs).
|
||||
|
||||
use glam::Mat4;
|
||||
use horizon_shaders::{CoreProgram, Glue};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// `DefaultVertexFormat.POSITION`: the disc's and the stars' vertices.
|
||||
pub(crate) const GLUE: Glue = Glue {
|
||||
inputs: &[("Position", "vec3")],
|
||||
prelude: "layout(location = 0) in vec3 hz_position;",
|
||||
setup: "Position = hz_position;",
|
||||
// 26.3's ProjMat already has wgpu's reversed 0 to 1 depth (Projection: zZeroToOne).
|
||||
finish: "",
|
||||
vertex_id: "int(gl_VertexIndex)",
|
||||
};
|
||||
|
||||
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
|
||||
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
|
||||
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
|
||||
|
||||
/// `SkyRenderer.buildSkyDisc(16)`: a fan around the point 16 above the camera, out to 512 every
|
||||
/// 45 degrees, as a triangle list.
|
||||
pub(crate) fn disc_triangles() -> Vec<[f32; 3]> {
|
||||
let rim: Vec<[f32; 3]> = (-180..=180)
|
||||
.step_by(45)
|
||||
.map(|i: i32| {
|
||||
let a = (i as f32).to_radians();
|
||||
[512.0 * a.cos(), 16.0, 512.0 * a.sin()]
|
||||
})
|
||||
.collect();
|
||||
rim.windows(2)
|
||||
.flat_map(|edge| [[0.0, 16.0, 0.0], edge[0], edge[1]])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One of the sky's pack pipelines with its vertices and uniforms.
|
||||
struct Part {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
vertices: wgpu::Buffer,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
impl Part {
|
||||
fn new(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
program: &CoreProgram,
|
||||
name: &str,
|
||||
blend: Option<wgpu::BlendState>,
|
||||
triangles: &[[f32; 3]],
|
||||
) -> Option<Self> {
|
||||
// A pack's shader is untrusted: the backend failing to compile it is an internal error.
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = |stage, module: &naga::Module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&format!("Resource pack {name} ({stage})")),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
|
||||
})
|
||||
};
|
||||
let vertex = module("vertex", &program.vertex);
|
||||
let fragment = module("fragment", &program.fragment);
|
||||
// Vanilla's SKY and STARS pipelines have no samplers: a pack that reads one is not used.
|
||||
let entries: Vec<wgpu::BindGroupLayoutEntry> = (0..4)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(BLOCK_SIZES[binding as usize]),
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect();
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(name),
|
||||
entries: &entries,
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(name),
|
||||
size: 1024,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let resources: Vec<wgpu::BindGroupEntry> = (0..4)
|
||||
.map(|i| wgpu::BindGroupEntry {
|
||||
binding: i as u32,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &uniform,
|
||||
offset: BLOCK_OFFSETS[i],
|
||||
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some(name),
|
||||
layout: &layout,
|
||||
entries: &resources,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(name),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(name),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3],
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
// The sky is drawn first, behind everything: no depth test, no depth write.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
|
||||
log::warn!("the resource pack's {name} shader is not used: {e}");
|
||||
return None;
|
||||
}
|
||||
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(name),
|
||||
contents: bytemuck::cast_slice(triangles),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
Some(Self {
|
||||
pipeline,
|
||||
group,
|
||||
uniform,
|
||||
vertices,
|
||||
count: triangles.len() as u32,
|
||||
})
|
||||
}
|
||||
|
||||
fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
pass.draw(0..self.count, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The packs' sky disc and stars pipelines, each present when a pack replaces that shader.
|
||||
pub(super) struct CoreSky {
|
||||
disc: Option<Part>,
|
||||
stars: Option<Part>,
|
||||
}
|
||||
|
||||
impl CoreSky {
|
||||
/// `None` when neither shader is replaced or neither builds.
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
disc: Option<&CoreProgram>,
|
||||
stars: Option<&CoreProgram>,
|
||||
) -> Option<Self> {
|
||||
let disc = disc.and_then(|program| {
|
||||
// RenderPipelines.SKY: ColorTargetState.DEFAULT, no blending.
|
||||
Part::new(device, format, program, "sky", None, &disc_triangles())
|
||||
});
|
||||
let stars = stars.and_then(|program| {
|
||||
// RenderPipelines.STARS: BlendFunction.OVERLAY (src alpha and one; alpha one, zero).
|
||||
let overlay = wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::Zero,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
};
|
||||
let triangles = super::stars::star_triangles();
|
||||
Part::new(device, format, program, "stars", Some(overlay), &triangles)
|
||||
});
|
||||
(disc.is_some() || stars.is_some()).then_some(Self { disc, stars })
|
||||
}
|
||||
|
||||
pub(super) fn draws_disc(&self) -> bool {
|
||||
self.disc.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn draws_stars(&self) -> bool {
|
||||
self.stars.is_some()
|
||||
}
|
||||
|
||||
/// Uploads the disc's and the stars' blocks: `sky` and `stars` from [`uniforms`].
|
||||
pub(super) fn prepare(
|
||||
&self,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
sky: &[u8],
|
||||
stars: Option<&[u8]>,
|
||||
) {
|
||||
if let Some(disc) = &self.disc {
|
||||
uploads.buffer(encoder, &disc.uniform, 0, sky);
|
||||
}
|
||||
if let (Some(part), Some(data)) = (&self.stars, stars) {
|
||||
uploads.buffer(encoder, &part.uniform, 0, data);
|
||||
}
|
||||
}
|
||||
|
||||
/// The disc, in place of the native sky colour. The caller sets its own group 0 again after.
|
||||
pub(super) fn draw_disc(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
if let Some(disc) = &self.disc {
|
||||
disc.draw(pass);
|
||||
}
|
||||
}
|
||||
|
||||
/// The stars, in place of the native ones. The caller sets its own group 0 again after.
|
||||
pub(super) fn draw_stars(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
if let Some(stars) = &self.stars {
|
||||
stars.draw(pass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The std140 blocks for one sky draw: `Globals`, `Projection` (`projection`, far enough for the
|
||||
/// disc's 512-block rim), `DynamicTransforms` (`model_view`: the camera's rotation, times the
|
||||
/// stars' rotation for them; `color`: the sky colour, or the star brightness in every channel)
|
||||
/// and `Fog` (`fog_color`, `fog`: environmental start and end, render distance start and end,
|
||||
/// sky end, clouds end).
|
||||
pub(super) fn uniforms(
|
||||
globals: [u8; 48],
|
||||
projection: Mat4,
|
||||
model_view: Mat4,
|
||||
color: [f32; 4],
|
||||
fog_color: [f32; 4],
|
||||
fog: [f32; 6],
|
||||
) -> Vec<u8> {
|
||||
let mut data = vec![0f32; 256];
|
||||
data[64..80].copy_from_slice(&projection.to_cols_array());
|
||||
let transforms = 128;
|
||||
data[transforms..transforms + 16].copy_from_slice(&model_view.to_cols_array());
|
||||
data[transforms + 16..transforms + 32].copy_from_slice(&Mat4::IDENTITY.to_cols_array());
|
||||
data[transforms + 32..transforms + 36].copy_from_slice(&color);
|
||||
data[192..196].copy_from_slice(&fog_color);
|
||||
data[196..202].copy_from_slice(&fog);
|
||||
let mut bytes: Vec<u8> = bytemuck::cast_slice(&data).to_vec();
|
||||
bytes[..48].copy_from_slice(&globals);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `buildSkyDisc(16)`: eight triangles from the point above the camera to the rim, which
|
||||
/// starts and ends at x -512 (cos 180°) and passes through z -512, x 512 and z 512.
|
||||
#[test]
|
||||
fn the_disc_is_vanillas_fan_as_triangles() {
|
||||
let triangles = disc_triangles();
|
||||
assert_eq!(triangles.len(), 24);
|
||||
assert!(triangles.chunks(3).all(|t| t[0] == [0.0, 16.0, 0.0]));
|
||||
let rim: Vec<[f32; 3]> = triangles.chunks(3).map(|t| t[1]).collect();
|
||||
let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3);
|
||||
assert!(near(rim[0], [-512.0, 16.0, 0.0]));
|
||||
assert!(near(rim[2], [0.0, 16.0, -512.0]));
|
||||
assert!(near(rim[4], [512.0, 16.0, 0.0]));
|
||||
assert!(near(rim[6], [0.0, 16.0, 512.0]));
|
||||
assert!(near(triangles[23], [-512.0, 16.0, 0.0]));
|
||||
}
|
||||
|
||||
/// The prepared resources (`HORIZON_RESOURCES`, or `.local/horizon-client/<version>`), where
|
||||
/// vanilla's sky shader and its includes are.
|
||||
fn resources() -> std::path::PathBuf {
|
||||
std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
})
|
||||
}
|
||||
|
||||
/// Vanilla's `sky.vsh` with a pack's fragment shader that paints `ColorModulator` (the sky
|
||||
/// colour) where the disc is: looking at the horizon, the top half of the target is the
|
||||
/// disc, the bottom half stays clear.
|
||||
#[test]
|
||||
fn a_pack_sky_shader_paints_the_disc_above_the_horizon() {
|
||||
let root = resources();
|
||||
let Ok(vertex) =
|
||||
std::fs::read_to_string(root.join("assets/minecraft/shaders/core/sky.vsh"))
|
||||
else {
|
||||
eprintln!("skipping: no prepared resources at {}", root.display());
|
||||
return;
|
||||
};
|
||||
let include = |id: &str| {
|
||||
let (namespace, path) = id.split_once(':')?;
|
||||
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
|
||||
};
|
||||
let fragment = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n\
|
||||
layout(location = 0) out vec4 fragColor;\n\
|
||||
void main() {\n fragColor = ColorModulator;\n}\n";
|
||||
let program = horizon_shaders::translate_core(&vertex, fragment, &[], &include, &GLUE)
|
||||
.expect("the sky shaders translate");
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Some((device, queue)) =
|
||||
pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.ok()
|
||||
.and_then(|adapter| {
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
})
|
||||
else {
|
||||
eprintln!("no GPU: skipping");
|
||||
return;
|
||||
};
|
||||
const SIZE: u32 = 64;
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let sky = CoreSky::new(&device, format, Some(&program), None).expect("the sky pipeline");
|
||||
let projection = crate::gfx::world_projection(90.0, 1.0, 2048.0);
|
||||
let data = uniforms(
|
||||
[0; 48],
|
||||
projection,
|
||||
Mat4::IDENTITY,
|
||||
[0.25, 0.5, 1.0, 1.0],
|
||||
[0.0; 4],
|
||||
[1e6; 6],
|
||||
);
|
||||
queue.write_buffer(&sky.disc.as_ref().unwrap().uniform, 0, &data);
|
||||
let target = |format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = target(format, wgpu::TextureUsages::COPY_SRC);
|
||||
let depth = target(
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::empty(),
|
||||
);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(SIZE * SIZE * 4),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let color_view = color.create_view(&Default::default());
|
||||
let depth_view = depth.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
sky.draw_disc(&mut pass);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(SIZE * 4),
|
||||
rows_per_image: Some(SIZE),
|
||||
},
|
||||
},
|
||||
color.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let pixels = read.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
let at = |x: usize, y: usize| pixels[(y * 64 + x) * 4..(y * 64 + x) * 4 + 4].to_vec();
|
||||
// Row 0 is the top: the disc, in the sky colour; the bottom row is below the horizon.
|
||||
let top = at(32, 4);
|
||||
assert!(
|
||||
top.iter()
|
||||
.zip([64u8, 128, 255, 255])
|
||||
.all(|(&p, w)| p.abs_diff(w) <= 1),
|
||||
"{top:?}"
|
||||
);
|
||||
assert_eq!(at(32, 60), vec![0, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,8 @@ pub struct Flat {
|
||||
phase2_calls: wgpu::Buffer,
|
||||
phase2_call_capacity: u64,
|
||||
phase2_call_data: Vec<[u32; 5]>,
|
||||
/// GPU culling's phase 2 uses indirect draws; flat draws without culling do not.
|
||||
indirect_execution: bool,
|
||||
/// The copy of phase 2's draws, and its ranges.
|
||||
phase2_group: Option<(wgpu::BindGroup, u32)>,
|
||||
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
|
||||
@@ -365,6 +367,7 @@ impl Flat {
|
||||
terrain_layout: &wgpu::BindGroupLayout,
|
||||
page_vertices: u64,
|
||||
min_sections: usize,
|
||||
indirect_execution: bool,
|
||||
pipeline: impl Fn(
|
||||
&wgpu::PipelineLayout,
|
||||
&str,
|
||||
@@ -489,10 +492,16 @@ impl Flat {
|
||||
device,
|
||||
"Flat section draw calls, phase 2",
|
||||
20,
|
||||
wgpu::BufferUsages::INDIRECT | storage_usage,
|
||||
storage_usage
|
||||
| if indirect_execution {
|
||||
wgpu::BufferUsages::INDIRECT
|
||||
} else {
|
||||
wgpu::BufferUsages::empty()
|
||||
},
|
||||
),
|
||||
phase2_call_capacity: 1,
|
||||
phase2_call_data: Vec::new(),
|
||||
indirect_execution,
|
||||
phase2_group: None,
|
||||
draw_layout,
|
||||
blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
@@ -645,6 +654,7 @@ impl Flat {
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
let phase2 = phase2.filter(|_| self.indirect_execution);
|
||||
let phase1_draws = if phase2.is_some() {
|
||||
phase1_draws
|
||||
} else {
|
||||
@@ -753,7 +763,12 @@ impl Flat {
|
||||
device,
|
||||
"Flat section draw calls, phase 2",
|
||||
self.phase2_call_capacity * 20,
|
||||
wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE,
|
||||
wgpu::BufferUsages::STORAGE
|
||||
| if self.indirect_execution {
|
||||
wgpu::BufferUsages::INDIRECT
|
||||
} else {
|
||||
wgpu::BufferUsages::empty()
|
||||
},
|
||||
);
|
||||
}
|
||||
if phase2_calls > 0 {
|
||||
|
||||
@@ -73,10 +73,15 @@ pub struct WorldDrawn<'a> {
|
||||
pub entities: &'a [Vertex],
|
||||
pub spans: &'a [EntitySpan],
|
||||
/// Which entity each run of `entities` is, recorded every frame (`Frame::entity_motion`):
|
||||
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
|
||||
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
|
||||
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
|
||||
/// frame to frame (API 371).
|
||||
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player,
|
||||
/// `(2 << 32) | id` for a dropped item, `(3 << 32) | id` for another entity (boat,
|
||||
/// minecart, armor stand, item frame, painting, primed TNT, falling block) and
|
||||
/// `(4 << 32) | id` for an item frame's held item (their entity ids), `(5 << 32) | p` for a
|
||||
/// block entity (`p`: its position, x and z in 11 bits each, y in 10), `(6 << 32) | p`
|
||||
/// for a block a piston moves (`p`: the cell it moves to), `7 << 32` for the player's own
|
||||
/// model, 0 for anything else.
|
||||
/// A run lasts until the next. Unlike `spans`, whose ids name a type, these follow one
|
||||
/// entity from frame to frame (API 371).
|
||||
pub motion: &'a [(u32, u64)],
|
||||
/// The shared atlas entity UVs point into.
|
||||
pub atlas: Option<&'a wgpu::Texture>,
|
||||
|
||||
@@ -757,6 +757,7 @@ impl PackAtlas {
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
}),
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
|
||||
paged_maps: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +192,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ pub(super) fn format(f: Format, packed_float: bool) -> wgpu::TextureFormat {
|
||||
_ => rgba,
|
||||
};
|
||||
match (f.kind, f.bits) {
|
||||
(Kind::Unorm, 10) => T::Rgb10a2Unorm,
|
||||
(Kind::Uint, 10) => T::Rgb10a2Uint,
|
||||
(Kind::Snorm, 16) => pick(T::R16Snorm, T::Rg16Snorm, T::Rgba16Snorm),
|
||||
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
|
||||
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
|
||||
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
|
||||
|
||||
@@ -52,6 +52,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Paged maps (`paged_maps.rs`) through the translator's `hz_pagedTexture` and the pass's
|
||||
//! binding: a pack reads the map's texels at the colour's coordinates.
|
||||
use super::*;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// The atlas the fixture's coordinates are in, and the one mapped texture's place.
|
||||
const ATLAS: (u32, u32) = (64, 32);
|
||||
const SPRITE: [u32; 4] = [20, 4, 20, 20];
|
||||
|
||||
/// The fixture's normal map texel at texel (x, y) of its texture.
|
||||
fn texel(x: u32, y: u32) -> [u8; 4] {
|
||||
[x as u8 * 10, y as u8 * 10, 7, 255]
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; missing adapter is a failure"]
|
||||
fn a_pack_reads_paged_maps_at_the_colours_coordinates() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let root = std::env::temp_dir().join(format!("horizon-paged-pack-{}", std::process::id()));
|
||||
std::fs::create_dir_all(root.join("shaders")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("shaders/gbuffers_terrain.vsh"),
|
||||
"#version 120\nvoid main(){gl_Position=gl_Vertex;}",
|
||||
)
|
||||
.unwrap();
|
||||
// Each pixel reads one atlas texel's centre: in the texture (on four pages), beside it,
|
||||
// on a page without maps, and the specular map, which no texture has.
|
||||
std::fs::write(
|
||||
root.join("shaders/gbuffers_terrain.fsh"),
|
||||
r#"#version 120
|
||||
uniform sampler2D normals;
|
||||
uniform sampler2D specular;
|
||||
vec2 at(float x, float y) { return (vec2(x, y) + 0.5) / vec2(64.0, 32.0); }
|
||||
void main() {
|
||||
int x = int(floor(gl_FragCoord.x));
|
||||
if (x == 0) gl_FragData[0] = texture2D(normals, at(20.0, 4.0));
|
||||
else if (x == 1) gl_FragData[0] = texture2D(normals, at(39.0, 23.0));
|
||||
else if (x == 2) gl_FragData[0] = texture2D(normals, at(31.0, 15.0), 2.0);
|
||||
else if (x == 3) gl_FragData[0] = texture2DLod(normals, at(32.0, 16.0), 3.0);
|
||||
else if (x == 4) gl_FragData[0] = texture2D(normals, at(19.0, 4.0));
|
||||
else if (x == 5) gl_FragData[0] = texture2D(normals, at(50.0, 30.0));
|
||||
else if (x == 6) gl_FragData[0] = texture2D(specular, at(25.0, 10.0));
|
||||
else gl_FragData[0] = texture2D(normals, at(33.0, 5.0));
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let [x0, y0, w, h] = SPRITE;
|
||||
let normal: Vec<u8> = (0..w * h).flat_map(|i| texel(i % w, i / w)).collect();
|
||||
let [normals, specular] = crate::paged_maps::build(
|
||||
ATLAS,
|
||||
&[crate::paged_maps::SpriteMaps {
|
||||
rect: SPRITE,
|
||||
maps: [Some(normal), None],
|
||||
}],
|
||||
16384,
|
||||
);
|
||||
assert!(specular.is_none());
|
||||
let normals = normals.unwrap();
|
||||
let map = device
|
||||
.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Fixture paged normal map"),
|
||||
size: wgpu::Extent3d {
|
||||
width: normals.width,
|
||||
height: normals.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&normals.pixels,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let pack = horizon_shaders::Pack::open(&root).unwrap();
|
||||
let macros = horizon_shaders::Macros::standard();
|
||||
let properties = horizon_shaders::Properties::parse("alphaTest.gbuffers_terrain=off", ¯os);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
|
||||
paged_maps: true,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs);
|
||||
assert!(post.terrain.is_some(), "{:?}", post.notes);
|
||||
let world = World {
|
||||
sprite_rects: &[],
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: map.clone(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &[],
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
celestial: None,
|
||||
gui_size: [1, 1],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
};
|
||||
let mut post = PackPost::new(
|
||||
&device,
|
||||
&queue,
|
||||
&adapter,
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
world,
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
let mut pass = post
|
||||
.terrain
|
||||
.take()
|
||||
.unwrap_or_else(|| panic!("terrain pipeline must compile: {:?}", post.notes));
|
||||
// As the entity passes are (`paged`), with the maps the renderer hands over.
|
||||
pass.paged = true;
|
||||
post.set_paged_maps([Some(map), None]);
|
||||
post.copied |= 1;
|
||||
post.resize(&device, [8, 1]);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Initialize fixture depth"),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &post.targets.as_ref().unwrap().depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let group = post.bind(&device, &queue, &mut encoder, &mut pass, [8, 1]);
|
||||
{
|
||||
let mut render = post.begin(&mut encoder, &pass);
|
||||
render.set_pipeline(pass.render());
|
||||
render.set_bind_group(0, &group, &[]);
|
||||
if pass.draw {
|
||||
post.draws.bind(&mut render, "TERRAIN_SOLID");
|
||||
}
|
||||
render.draw(0..3, 0..1);
|
||||
}
|
||||
let output = post.targets.as_ref().unwrap().color[0].as_ref().unwrap()[0].texture();
|
||||
encoder.copy_texture_to_buffer(
|
||||
output.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
bytes_per_row: Some(256),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 8,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
let local = |x: u32, y: u32| texel(x - x0, y - y0);
|
||||
let expected = [
|
||||
local(20, 4),
|
||||
local(39, 23),
|
||||
local(31, 15),
|
||||
local(32, 16),
|
||||
crate::sprites::FLAT_NORMAL,
|
||||
crate::sprites::FLAT_NORMAL,
|
||||
crate::sprites::NO_SPECULAR,
|
||||
local(33, 5),
|
||||
];
|
||||
for (i, e) in expected.iter().enumerate() {
|
||||
assert_eq!(&data[i * 4..i * 4 + 4], e, "pixel {i}");
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -61,6 +61,9 @@ mod shadow_faces_tests;
|
||||
#[cfg(test)]
|
||||
mod r11f_image_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod work_group_fold_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod forced_flip_tests;
|
||||
|
||||
@@ -430,6 +433,9 @@ struct Pass {
|
||||
/// What `texture` reads instead of the block atlas.
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
material_maps: bool,
|
||||
/// Draws the shared atlas's geometry: `normals` and `specular` are its paged maps
|
||||
/// (`PackPost::set_paged_maps`), as its programs were translated to read them.
|
||||
paged: bool,
|
||||
/// Reads `mc_Entity`'s table (`PackPost::block_ids`).
|
||||
ids: bool,
|
||||
/// Bind groups by the sides read (bits 0–15), the buffers whose mip chain is current (bits
|
||||
@@ -674,6 +680,8 @@ pub struct PackPost {
|
||||
custom: horizon_shaders::Custom,
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
material_maps: Option<[wgpu::TextureView; 2]>,
|
||||
/// The shared atlas's normal and specular paged maps (`set_paged_maps`).
|
||||
paged_maps: [Option<wgpu::TextureView>; 2],
|
||||
lightmap: wgpu::TextureView,
|
||||
deferred_pre: u16,
|
||||
composite_pre: u16,
|
||||
@@ -2179,6 +2187,7 @@ fn build_pass(
|
||||
draw,
|
||||
atlas: geometry.and_then(|g| g.atlas.clone()),
|
||||
material_maps: geometry.is_some_and(|g| g.material_maps),
|
||||
paged: false,
|
||||
ids: geometry.is_some_and(|g| g.ids.is_some()),
|
||||
groups: HashMap::new(),
|
||||
portal_groups: HashMap::new(),
|
||||
@@ -2319,6 +2328,7 @@ fn build_compute(
|
||||
draw: false,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
paged: false,
|
||||
ids: false,
|
||||
groups: HashMap::new(),
|
||||
portal_groups: HashMap::new(),
|
||||
@@ -2357,6 +2367,13 @@ impl Refused {
|
||||
}
|
||||
|
||||
impl PackPost {
|
||||
/// The shared atlas's normal and specular paged maps (`paged_maps.rs`), which the programs
|
||||
/// drawing its geometry read as `normals` and `specular`; set before the first frame (a pass's
|
||||
/// bind groups are kept once made). A missing one reads as the dummy (flat, no specular).
|
||||
pub fn set_paged_maps(&mut self, maps: [Option<wgpu::TextureView>; 2]) {
|
||||
self.paged_maps = maps;
|
||||
}
|
||||
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
|
||||
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
|
||||
///
|
||||
@@ -2780,6 +2797,13 @@ impl PackPost {
|
||||
ids: None,
|
||||
cull: false,
|
||||
});
|
||||
// Programs drawing the shared atlas's geometry read its paged maps (`pack_entities::inputs`).
|
||||
let paged = |pass: Option<Pass>| {
|
||||
pass.map(|mut pass| {
|
||||
pass.paged = true;
|
||||
pass
|
||||
})
|
||||
};
|
||||
let entity_passes = programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
|
||||
if p.name == "gbuffers_damagedblock" {
|
||||
@@ -2792,15 +2816,15 @@ impl PackPost {
|
||||
ids: g.ids,
|
||||
cull: false,
|
||||
};
|
||||
build(p, Some(&decal))
|
||||
paged(build(p, Some(&decal)))
|
||||
} else {
|
||||
build(p, Some(g))
|
||||
paged(build(p, Some(g)))
|
||||
}
|
||||
})
|
||||
});
|
||||
let shadow_entities = shadow_programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)))
|
||||
.and_then(|(p, g)| paged(build(p, Some(g))))
|
||||
});
|
||||
let entities = entity_vertices
|
||||
.filter(|_| (entity_passes.iter().chain(&shadow_entities)).any(Option::is_some))
|
||||
@@ -2843,7 +2867,7 @@ impl PackPost {
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
build(p, Some(&g)).map(|pass| (pass, geometry))
|
||||
paged(build(p, Some(&g))).map(|pass| (pass, geometry))
|
||||
});
|
||||
// Translucent held items, drawn from the hand's own buffer.
|
||||
let hand_water = post.hand_water.and_then(|p| {
|
||||
@@ -2857,7 +2881,7 @@ impl PackPost {
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
build(p, Some(&g))
|
||||
paged(build(p, Some(&g)))
|
||||
});
|
||||
let weather = post
|
||||
.weather
|
||||
@@ -3296,6 +3320,7 @@ impl PackPost {
|
||||
custom,
|
||||
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
|
||||
material_maps: world.material_maps,
|
||||
paged_maps: [None, None],
|
||||
lightmap: world.lightmap,
|
||||
deferred_pre: mask(&post.deferred_pre),
|
||||
composite_pre: mask(&post.composite_pre),
|
||||
@@ -3656,6 +3681,12 @@ impl PackPost {
|
||||
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
|
||||
&self.dummy_1d
|
||||
}
|
||||
Source::Normals if pass.paged => {
|
||||
self.paged_maps[0].as_ref().unwrap_or(&self.dummy_normal)
|
||||
}
|
||||
Source::Specular if pass.paged => {
|
||||
self.paged_maps[1].as_ref().unwrap_or(&self.dummy_black)
|
||||
}
|
||||
Source::Normals => self
|
||||
.material_maps
|
||||
.as_ref()
|
||||
@@ -3756,6 +3787,16 @@ impl PackPost {
|
||||
[n(0), n(1), 1]
|
||||
});
|
||||
let (pipeline, groups) = (pipeline.clone(), groups);
|
||||
// wgpu panics on a dispatch over its limit. The translator reshapes a large
|
||||
// `workGroups`; a `workGroupsRender` that large is not run.
|
||||
let most = device.limits().max_compute_workgroups_per_dimension;
|
||||
if groups.iter().any(|&n| n > most) {
|
||||
log::warn!(
|
||||
"shader pack: {}: {groups:?} work groups is over {most} per dimension; not run",
|
||||
pass.name
|
||||
);
|
||||
return;
|
||||
}
|
||||
for image in (0..self.images.len()).filter(|k| pass.snapshots & 1 << k != 0) {
|
||||
pack_images::snapshot(encoder, &self.images[image]);
|
||||
}
|
||||
@@ -6256,6 +6297,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_is_made_in_its_layout_qualifiers_format() {
|
||||
// `image.x = … RGB10_A2 …` read through `layout(rgb10_a2) image2D x`.
|
||||
for q in [
|
||||
"rgba32f",
|
||||
"rgba16f",
|
||||
"rg16f",
|
||||
"r11f_g11f_b10f",
|
||||
"r32f",
|
||||
"rgba16",
|
||||
"rgb10_a2",
|
||||
"rgba8",
|
||||
"rg8",
|
||||
"r16",
|
||||
"rgba16_snorm",
|
||||
"rgba8_snorm",
|
||||
"r8_snorm",
|
||||
"rgba32i",
|
||||
"r16i",
|
||||
"rgb10_a2ui",
|
||||
"rgba8ui",
|
||||
"r32ui",
|
||||
] {
|
||||
let def = Format::parse(q).unwrap();
|
||||
assert_eq!(Some(pack_images::format(def, true)), image_format(q), "{q}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samplers_read_custom_textures_of_their_type() {
|
||||
use wgpu::TextureSampleType as T;
|
||||
@@ -6301,6 +6370,10 @@ mod destination_tests;
|
||||
#[path = "pack_material_tests.rs"]
|
||||
mod material_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_paged_maps_tests.rs"]
|
||||
mod paged_maps_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod rgb_alpha_tests;
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
|
||||
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
|
||||
let inputs=horizon_shaders::GameInputs {
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false }),
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false }),
|
||||
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
|
||||
};
|
||||
let mut post = gpu.load(
|
||||
@@ -719,6 +719,7 @@ fn shadow_depth_map_reads(
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ fn pack() -> Post {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
|
||||
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! A `workGroups` over wgpu's 65535 per dimension runs, and its programs read the work group
|
||||
//! built-ins of the dispatch they declared. The programs are original.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
// 70000 x 1 x 2 groups; the last 64 of the second layer each paint one pixel, green when
|
||||
// the built-ins agree with that dispatch.
|
||||
add(
|
||||
"composite.csh",
|
||||
"#version 430\nlayout(local_size_x=1) in;\nconst ivec3 workGroups=ivec3(70000,1,2);\n\
|
||||
layout(rgba8) writeonly uniform image2D colorimg0;\n\
|
||||
void main() {\n uint first = 70000u - 64u;\n\
|
||||
if (gl_WorkGroupID.z != 1u || gl_WorkGroupID.x < first) return;\n\
|
||||
uint k = gl_WorkGroupID.x - first;\n\
|
||||
bool ok = gl_WorkGroupID.y == 0u && gl_NumWorkGroups == uvec3(70000u, 1u, 2u)\n\
|
||||
&& gl_GlobalInvocationID == gl_WorkGroupID;\n\
|
||||
imageStore(colorimg0, ivec2(k % 8u, k / 8u), ok ? vec4(0, 1, 0, 1) : vec4(1, 0, 0, 1));\n}\n",
|
||||
);
|
||||
add(
|
||||
"final.vsh",
|
||||
"#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n",
|
||||
);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform sampler2D colortex0;\nin vec2 uv;\n\
|
||||
void main() { gl_FragColor = texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0); }\n",
|
||||
);
|
||||
let pack =
|
||||
horizon_shaders::Pack::from_zip_bytes("original-fold", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dispatch_over_the_limit_reads_its_declared_work_groups() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let post = pack();
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
let mut post = gpu.build(post);
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
for (k, got) in pixels.iter().enumerate() {
|
||||
assert_eq!(*got, [0, 255, 0, 255], "pixel {k}; notes {:?}", post.notes);
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
far_depth: true,
|
||||
paged_maps: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +132,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
far_depth: true,
|
||||
paged_maps: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
}),
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,12 +100,18 @@ pub struct HudAtlas {
|
||||
/// A resource pack's own `core/rendertype_lines` shader for the block outline, translated;
|
||||
/// none draws the outline natively.
|
||||
pub lines_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
/// The packs' `core/sky` and `core/stars`, when a pack replaces them.
|
||||
pub sky_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
pub stars_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
|
||||
pub random_textures: horizon_optifine::random_textures::RandomTextures,
|
||||
/// Their emissive overlays (`_e`), packed here too.
|
||||
pub emissive: std::sync::Arc<horizon_optifine::emissive::Emissive>,
|
||||
/// ETF's blinking frames (`_blink`), packed here too.
|
||||
pub blinks: horizon_optifine::blink::Blinks,
|
||||
/// Of the shared atlas's textures, their LabPBR normal and specular maps as paged maps
|
||||
/// (`paged_maps.rs`), read while loading for shader packs (`sprites::labpbr_maps`).
|
||||
pub material_maps: std::sync::Arc<[Option<crate::paged_maps::PagedMap>; 2]>,
|
||||
white: Rect,
|
||||
}
|
||||
/// Atlas rows reserved for glyphs rasterized at run time.
|
||||
@@ -888,6 +894,10 @@ impl HudAtlas {
|
||||
&[],
|
||||
&crate::gfx::core_lines::GLUE,
|
||||
);
|
||||
let sky =
|
||||
|name| pack_text::load_core(root, defaults, name, &[], &crate::gfx::core_sky::GLUE);
|
||||
atlas.sky_shader = sky("sky");
|
||||
atlas.stars_shader = sky("stars");
|
||||
Ok(atlas)
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
@@ -967,6 +977,7 @@ impl HudAtlas {
|
||||
revision: Self::next_revision(),
|
||||
pixels: Vec::new(),
|
||||
packed: None,
|
||||
material_maps: Default::default(),
|
||||
galactic_widths: [0; 128],
|
||||
width: 1,
|
||||
height: 1,
|
||||
@@ -981,6 +992,8 @@ impl HudAtlas {
|
||||
glint_armor: (Vec::new(), 1, 1),
|
||||
text_shader: None,
|
||||
lines_shader: None,
|
||||
sky_shader: None,
|
||||
stars_shader: None,
|
||||
random_textures: Default::default(),
|
||||
emissive: Arc::default(),
|
||||
blinks: Default::default(),
|
||||
@@ -1029,6 +1042,7 @@ impl HudAtlas {
|
||||
(glyph_top, height - glyph_top),
|
||||
white,
|
||||
),
|
||||
material_maps: Default::default(),
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
@@ -1045,6 +1059,8 @@ impl HudAtlas {
|
||||
white,
|
||||
text_shader: None,
|
||||
lines_shader: None,
|
||||
sky_shader: None,
|
||||
stars_shader: None,
|
||||
random_textures: Default::default(),
|
||||
emissive: Default::default(),
|
||||
blinks: Default::default(),
|
||||
@@ -1269,7 +1285,16 @@ impl HudAtlas {
|
||||
extra.sort();
|
||||
files.extend(extra);
|
||||
}
|
||||
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
|
||||
// A texture's LabPBR maps, as decoded (width, height, RGBA8), for shader packs.
|
||||
type Maps = [Option<(u32, u32, Vec<u8>)>; 2];
|
||||
let read_maps = full && crate::sprites::labpbr_maps();
|
||||
type Decoded = (
|
||||
PackedImage,
|
||||
Option<TextureFlags>,
|
||||
Option<(u32, u32)>,
|
||||
bool,
|
||||
Option<Box<Maps>>,
|
||||
);
|
||||
let decode_one = |path: &PathBuf| -> Option<Decoded> {
|
||||
let name = if let Some(name) = named.get(path) {
|
||||
name.clone()
|
||||
@@ -1317,11 +1342,21 @@ impl HudAtlas {
|
||||
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
|
||||
let animated = frames && animation.is_some();
|
||||
let (gw, gh) = gui_size.unwrap_or((w, h));
|
||||
let maps = read_maps
|
||||
.then(|| {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
let maps: Maps = ["_n", "_s"].map(|suffix| {
|
||||
decode(&path.with_file_name(format!("{stem}{suffix}.png"))).ok()
|
||||
});
|
||||
maps.iter().any(Option::is_some).then(|| Box::new(maps))
|
||||
})
|
||||
.flatten();
|
||||
(gw <= 1024 && (gh <= 512 || name.starts_with("font/"))).then_some((
|
||||
(name, w, h, pixels),
|
||||
flags,
|
||||
gui_size,
|
||||
animated,
|
||||
maps,
|
||||
))
|
||||
};
|
||||
let threads = std::thread::available_parallelism().map_or(2, |n| n.get());
|
||||
@@ -1342,7 +1377,11 @@ impl HudAtlas {
|
||||
let mut gui_sizes = HashMap::new();
|
||||
let mut animated: Vec<String> = Vec::new();
|
||||
let mut images: Vec<PackedImage> = Vec::with_capacity(decoded.len());
|
||||
for (image, image_flags, gui_size, is_animated) in decoded {
|
||||
let mut maps: HashMap<String, Box<Maps>> = HashMap::new();
|
||||
for (image, image_flags, gui_size, is_animated, image_maps) in decoded {
|
||||
if let Some(m) = image_maps {
|
||||
maps.insert(image.0.clone(), m);
|
||||
}
|
||||
if let Some(f) = image_flags {
|
||||
flags.insert(image.0.clone(), f);
|
||||
}
|
||||
@@ -1517,6 +1556,28 @@ impl HudAtlas {
|
||||
}
|
||||
sprite_animations.push((rect, animation));
|
||||
}
|
||||
// Each mapped texture's maps at its size in the atlas, paged.
|
||||
let material_maps = if maps.is_empty() {
|
||||
Default::default()
|
||||
} else {
|
||||
let mapped: Vec<crate::paged_maps::SpriteMaps> = maps
|
||||
.iter()
|
||||
.filter_map(|(name, m)| {
|
||||
let rect = sprites.get(name)?;
|
||||
let (tw, th) = rect.texels((width, height));
|
||||
let x = (rect.u0 * width as f32).round() as u32;
|
||||
let y = (rect.v0 * height as f32).round() as u32;
|
||||
Some(crate::paged_maps::SpriteMaps {
|
||||
rect: [x, y, tw, th],
|
||||
maps: m.each_ref().map(|map| {
|
||||
let (mw, mh, pixels) = map.as_ref()?;
|
||||
crate::paged_maps::fit(pixels, (*mw, *mh), (tw, th))
|
||||
}),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
crate::paged_maps::build((width, height), &mapped, MAX_ATLAS_SIZE)
|
||||
};
|
||||
let white = sprites["horizon/white"];
|
||||
let glint = Self::glint_texture(root, defaults, "enchanted_glint_item");
|
||||
let glint_armor = Self::glint_texture(root, defaults, "enchanted_glint_armor");
|
||||
@@ -1531,6 +1592,7 @@ impl HudAtlas {
|
||||
);
|
||||
Ok(Self {
|
||||
revision: Self::next_revision(),
|
||||
material_maps: std::sync::Arc::new(material_maps),
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
@@ -1546,6 +1608,8 @@ impl HudAtlas {
|
||||
glint_armor,
|
||||
text_shader: None,
|
||||
lines_shader: None,
|
||||
sky_shader: None,
|
||||
stars_shader: None,
|
||||
random_textures,
|
||||
emissive,
|
||||
blinks,
|
||||
|
||||
@@ -84,6 +84,7 @@ pub mod pack_screen;
|
||||
pub mod packs;
|
||||
pub mod pad_cursor;
|
||||
pub mod pad_glyph;
|
||||
pub mod paged_maps;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
|
||||
@@ -559,7 +559,8 @@ pub(crate) fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
}
|
||||
/// Blocks that fill their cell but neither draw solid nor collide as a full
|
||||
/// block: far terrain draws them as blocks, or patches of powder snow leave
|
||||
/// holes in mountains (and paths and fields dips).
|
||||
/// holes in mountains (and paths and fields dips). Slabs use the same
|
||||
/// full-cell approximation as stairs, so distant slab builds remain visible.
|
||||
fn nearly_full(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
@@ -568,6 +569,7 @@ fn nearly_full(name: &str) -> bool {
|
||||
| "minecraft:farmland"
|
||||
| "minecraft:honey_block"
|
||||
) || name.ends_with("_stairs")
|
||||
|| name.ends_with("_slab")
|
||||
|| name.contains("fence")
|
||||
|| name.contains("_wall")
|
||||
|| name.contains("bars")
|
||||
@@ -3977,6 +3979,73 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn slab_materials_survive_chunk_summaries_and_draw_far_faces() -> crate::Result<()> {
|
||||
use crate::{assets::Assets, biome::Biomes, light::LightTables, mesher::MeshTables};
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let context = WorkerContext {
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
|
||||
tables: Arc::new(MeshTables::new(
|
||||
&assets,
|
||||
&render,
|
||||
&facts,
|
||||
crate::models::load_visibility(&root, assets.registry)?,
|
||||
)?),
|
||||
assets: assets.clone(),
|
||||
render,
|
||||
biomes: Arc::new(Biomes::load(&root)?),
|
||||
};
|
||||
let kinds = state_kinds(&facts);
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
let mut mesher = mesh::Mesher::default();
|
||||
let mut materials = FxHashSet::default();
|
||||
// Real top, bottom, double and waterlogged states: a waterlogged slab
|
||||
// must keep its material rather than become a column of plain water.
|
||||
for (i, f) in facts.states.iter().enumerate() {
|
||||
if !f.name.ends_with("_slab") {
|
||||
continue;
|
||||
}
|
||||
materials.insert(f.name.as_ref());
|
||||
let state = BlockStateId(i as u32);
|
||||
chunk.set(3, 90, 3, state.0);
|
||||
let columns = summarize(&chunk, &kinds);
|
||||
let column = &columns[3 * 16 + 3];
|
||||
assert_eq!(column.spans.len(), 1, "{} state {i}", f.name);
|
||||
assert_eq!(column.spans[0].state, state, "{} state {i}", f.name);
|
||||
assert_eq!((column.spans[0].min, column.spans[0].max), (90, 91));
|
||||
assert_eq!(Column::reduced(&columns, 0, 0, 4).spans, column.spans);
|
||||
let mesh = mesher.mesh(
|
||||
TileKey {
|
||||
x: 0,
|
||||
z: 0,
|
||||
level: 0,
|
||||
},
|
||||
&columns.iter().collect::<Vec<_>>(),
|
||||
&FxHashSet::default(),
|
||||
None,
|
||||
&context,
|
||||
&std::array::from_fn(|_| Vec::new()),
|
||||
&[],
|
||||
);
|
||||
// Like stairs, far slabs use the existing full-cell approximation.
|
||||
assert_eq!(mesh.solid.len(), 6 * 4, "{} state {i}", f.name);
|
||||
assert!(mesh.translucent.is_empty(), "{} state {i}", f.name);
|
||||
}
|
||||
for name in [
|
||||
"minecraft:smooth_stone_slab",
|
||||
"minecraft:oak_slab",
|
||||
"minecraft:cut_copper_slab",
|
||||
] {
|
||||
assert!(materials.contains(name), "missing fixture {name}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn real_mesh_clips_near_chunks_and_preserves_water_and_edited_surfaces() -> crate::Result<()> {
|
||||
use crate::{
|
||||
assets::Assets, biome::Biomes, light::LightTables, mesher::MeshTables,
|
||||
|
||||
@@ -9,19 +9,22 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
|
||||
}
|
||||
// Stage 1: (-nearest, furthest, nearest device depth, nearest opaque device
|
||||
// depth), combined by max blending.
|
||||
@fragment fn fs_oit_bounds(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_oit_bounds(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
let color = oit_terrain_color(in);
|
||||
let linear = oit_linear_depth(in.clip.z);
|
||||
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
|
||||
return vec4(-linear, linear, in.clip.z, opaque);
|
||||
}
|
||||
// Stage 2.
|
||||
@fragment fn fs_oit_transmittance(in: TerrainOut) -> OitCoefficients {
|
||||
@fragment fn fs_oit_transmittance(wire: TerrainWire) -> OitCoefficients {
|
||||
let in = from_wire(wire);
|
||||
let color = oit_terrain_color(in);
|
||||
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
|
||||
}
|
||||
// Stage 3: premultiplied colour, weighted by the transmittance in front.
|
||||
@fragment fn fs_oit_accumulate(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_oit_accumulate(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
let color = oit_terrain_color(in);
|
||||
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
|
||||
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
|
||||
|
||||
@@ -312,6 +312,7 @@ impl Opt {
|
||||
Opt::PadRepeatInterval => "horizon.options.repeat_interval",
|
||||
Opt::PadInvertX => "horizon.options.invert_x",
|
||||
Opt::PadInvertY => "horizon.options.invert_y",
|
||||
Opt::PadCraftOnServers => "horizon.options.craft_on_servers",
|
||||
Opt::PadToggleSneak => "horizon.options.sneak_button",
|
||||
Opt::PadMoveDeadzone => "horizon.options.move_deadzone",
|
||||
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
|
||||
@@ -849,6 +850,7 @@ impl Opt {
|
||||
}),
|
||||
Opt::PadInvertX => on_off(s.controller.invert_x),
|
||||
Opt::PadInvertY => on_off(s.controller.invert_y),
|
||||
Opt::PadCraftOnServers => on_off(s.controller.craft_on_servers),
|
||||
Opt::PadToggleSneak => tr(if s.controller.toggle_sneak {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
@@ -1156,6 +1158,9 @@ impl Opt {
|
||||
}
|
||||
Opt::PadInvertX => s.controller.invert_x = !s.controller.invert_x,
|
||||
Opt::PadInvertY => s.controller.invert_y = !s.controller.invert_y,
|
||||
Opt::PadCraftOnServers => {
|
||||
s.controller.craft_on_servers = !s.controller.craft_on_servers
|
||||
}
|
||||
Opt::PadToggleSneak => s.controller.toggle_sneak = !s.controller.toggle_sneak,
|
||||
Opt::PadNintendoButtons => {
|
||||
s.controller.nintendo_letters = !s.controller.nintendo_letters
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! The shared atlas's LabPBR maps (`_n`, `_s` beside an item's, entity's or block's texture),
|
||||
//! for shader packs: a map the size of the atlas would be mostly empty (only some textures have
|
||||
//! maps) and as large as the atlas itself, so each is a paged map instead. The atlas is cut into
|
||||
//! 16x16 pages; only pages holding a mapped texture get one in the map's pool, found through a
|
||||
//! page table at the map's start. Packs read the maps with the colour's coordinates, as for
|
||||
//! terrain: the translator turns their reads into `hz_pagedTexture` (horizon-shaders,
|
||||
//! `PAGED_READ`, which documents the layout this writes).
|
||||
|
||||
/// A page's side, in texels.
|
||||
pub const PAGE: u32 = 16;
|
||||
/// Texels before the page table: the atlas's size, and the map's value where it has none.
|
||||
const HEADER: u32 = 2;
|
||||
|
||||
/// One paged map, RGBA8.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PagedMap {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A texture's maps: its place in the atlas (x, y, width, height, in texels) and its normal and
|
||||
/// specular images at that size (RGBA8), each where it has one.
|
||||
pub struct SpriteMaps {
|
||||
pub rect: [u32; 4],
|
||||
pub maps: [Option<Vec<u8>>; 2],
|
||||
}
|
||||
|
||||
/// The normal and specular paged maps of an atlas of `size` texels, each `None` where no texture
|
||||
/// has that map; `max_side` is the GPU's largest texture side.
|
||||
pub fn build(size: (u32, u32), sprites: &[SpriteMaps], max_side: u32) -> [Option<PagedMap>; 2] {
|
||||
[
|
||||
(0, crate::sprites::FLAT_NORMAL),
|
||||
(1, crate::sprites::NO_SPECULAR),
|
||||
]
|
||||
.map(|(kind, default)| {
|
||||
let mapped = sprites
|
||||
.iter()
|
||||
.filter_map(|s| s.maps[kind].as_deref().map(|m| (s.rect, m)));
|
||||
build_one(size, mapped, default, max_side)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_one<'a>(
|
||||
(width, height): (u32, u32),
|
||||
sprites: impl Iterator<Item = ([u32; 4], &'a [u8])>,
|
||||
default: [u8; 4],
|
||||
max_side: u32,
|
||||
) -> Option<PagedMap> {
|
||||
let (pages_x, pages_y) = (width.div_ceil(PAGE), height.div_ceil(PAGE));
|
||||
// Each atlas page's place in the pool, plus one (0: none).
|
||||
let mut table = vec![0u32; (pages_x * pages_y) as usize];
|
||||
let mut pool: Vec<Vec<u8>> = Vec::new();
|
||||
let blank: Vec<u8> = default.repeat((PAGE * PAGE) as usize);
|
||||
for ([x, y, w, h], map) in sprites {
|
||||
if w == 0 || h == 0 || map.len() < (w * h * 4) as usize || x + w > width || y + h > height {
|
||||
continue;
|
||||
}
|
||||
for py in y / PAGE..=(y + h - 1) / PAGE {
|
||||
for px in x / PAGE..=(x + w - 1) / PAGE {
|
||||
let entry = &mut table[(py * pages_x + px) as usize];
|
||||
if *entry == 0 {
|
||||
pool.push(blank.clone());
|
||||
*entry = pool.len() as u32;
|
||||
}
|
||||
let page = &mut pool[*entry as usize - 1];
|
||||
// The sprite's texels on this page.
|
||||
let (x0, x1) = (x.max(px * PAGE), (x + w).min((px + 1) * PAGE));
|
||||
let (y0, y1) = (y.max(py * PAGE), (y + h).min((py + 1) * PAGE));
|
||||
for ty in y0..y1 {
|
||||
let src = (((ty - y) * w + (x0 - x)) * 4) as usize;
|
||||
let dst = (((ty - py * PAGE) * PAGE + (x0 - px * PAGE)) * 4) as usize;
|
||||
let len = ((x1 - x0) * 4) as usize;
|
||||
page[dst..dst + len].copy_from_slice(&map[src..src + len]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if pool.is_empty() || pool.len() >= 1 << 24 {
|
||||
return None;
|
||||
}
|
||||
let entries = HEADER + pages_x * pages_y;
|
||||
// The narrowest map width (whole pages) that keeps it within the GPU's side.
|
||||
let (side, rows, map_height) = [1024, 2048, 4096, 8192, 16384]
|
||||
.into_iter()
|
||||
.filter(|&s| s <= max_side)
|
||||
.map(|s| {
|
||||
let rows = entries.div_ceil(s).next_multiple_of(PAGE);
|
||||
let per_row = s / PAGE;
|
||||
(s, rows, rows + (pool.len() as u32).div_ceil(per_row) * PAGE)
|
||||
})
|
||||
.find(|&(_, _, h)| h <= max_side)?;
|
||||
let mut pixels = vec![0u8; (side * map_height * 4) as usize];
|
||||
let header = [
|
||||
width as u8,
|
||||
(width >> 8) as u8,
|
||||
height as u8,
|
||||
(height >> 8) as u8,
|
||||
];
|
||||
pixels[..4].copy_from_slice(&header);
|
||||
pixels[4..8].copy_from_slice(&default);
|
||||
for (i, &k) in table.iter().enumerate() {
|
||||
let at = (HEADER as usize + i) * 4;
|
||||
pixels[at..at + 4].copy_from_slice(&[k as u8, (k >> 8) as u8, (k >> 16) as u8, 255]);
|
||||
}
|
||||
let per_row = side / PAGE;
|
||||
for (k, page) in pool.iter().enumerate() {
|
||||
let k = k as u32;
|
||||
let (ox, oy) = (k % per_row * PAGE, rows + k / per_row * PAGE);
|
||||
for row in 0..PAGE {
|
||||
let dst = (((oy + row) * side + ox) * 4) as usize;
|
||||
let src = (row * PAGE * 4) as usize;
|
||||
pixels[dst..dst + (PAGE * 4) as usize]
|
||||
.copy_from_slice(&page[src..src + (PAGE * 4) as usize]);
|
||||
}
|
||||
}
|
||||
Some(PagedMap {
|
||||
width: side,
|
||||
height: map_height,
|
||||
pixels,
|
||||
})
|
||||
}
|
||||
|
||||
/// `map` (RGBA8, `mw` by `mh`) at `w` by `h`, nearest: a map drawn for a strip of animation
|
||||
/// frames keeps its first, the colour's shape from its top.
|
||||
pub fn fit(map: &[u8], (mw, mh): (u32, u32), (w, h): (u32, u32)) -> Option<Vec<u8>> {
|
||||
if mw == 0 || mh == 0 || w == 0 || h == 0 || map.len() < (mw * mh * 4) as usize {
|
||||
return None;
|
||||
}
|
||||
// The first frame of a strip as tall as the colour's frame is wide, scaled.
|
||||
let frame_h = (u64::from(mw) * u64::from(h) / u64::from(w)).clamp(1, u64::from(mh)) as u32;
|
||||
let mut out = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h {
|
||||
let sy = (u64::from(y) * u64::from(frame_h) / u64::from(h)) as u32;
|
||||
for x in 0..w {
|
||||
let sx = (u64::from(x) * u64::from(mw) / u64::from(w)) as u32;
|
||||
let at = ((sy * mw + sx) * 4) as usize;
|
||||
out.extend_from_slice(&map[at..at + 4]);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// What `PAGED_READ` returns at texel (x, y) of the atlas.
|
||||
fn read(map: &PagedMap, (width, height): (u32, u32), (x, y): (u32, u32)) -> [u8; 4] {
|
||||
let texel = |at: u32| {
|
||||
let at = (at * 4) as usize;
|
||||
[0, 1, 2, 3].map(|c| map.pixels[at + c])
|
||||
};
|
||||
let at = |x: u32, y: u32| texel(y * map.width + x);
|
||||
let h = texel(0);
|
||||
assert_eq!(
|
||||
(
|
||||
u32::from(h[0]) | u32::from(h[1]) << 8,
|
||||
u32::from(h[2]) | u32::from(h[3]) << 8
|
||||
),
|
||||
(width, height)
|
||||
);
|
||||
let pages = (width.div_ceil(PAGE), height.div_ceil(PAGE));
|
||||
let e = texel(2 + (y / PAGE) * pages.0 + x / PAGE);
|
||||
let k = u32::from(e[0]) | u32::from(e[1]) << 8 | u32::from(e[2]) << 16;
|
||||
if k == 0 {
|
||||
return texel(1);
|
||||
}
|
||||
let rows = (2 + pages.0 * pages.1)
|
||||
.div_ceil(map.width)
|
||||
.next_multiple_of(16);
|
||||
let per_row = map.width / 16;
|
||||
at(
|
||||
(k - 1) % per_row * 16 + x % 16,
|
||||
rows + (k - 1) / per_row * 16 + y % 16,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_textures_read_back_and_the_rest_reads_the_default() {
|
||||
let size = (4096, 2048);
|
||||
// A 20x20 texture across four pages, and one without a specular map.
|
||||
let normal: Vec<u8> = (0..20 * 20)
|
||||
.flat_map(|i: u32| [i as u8, (i >> 8) as u8, 7, 255])
|
||||
.collect();
|
||||
let sprites = [
|
||||
SpriteMaps {
|
||||
rect: [30, 40, 20, 20],
|
||||
maps: [Some(normal.clone()), Some(vec![9; 20 * 20 * 4])],
|
||||
},
|
||||
SpriteMaps {
|
||||
rect: [1000, 1000, 16, 16],
|
||||
maps: [Some(vec![3; 16 * 16 * 4]), None],
|
||||
},
|
||||
];
|
||||
let [normals, specular] = build(size, &sprites, 16384);
|
||||
let (normals, specular) = (normals.unwrap(), specular.unwrap());
|
||||
for y in 0..20 {
|
||||
for x in 0..20 {
|
||||
let i = ((y * 20 + x) * 4) as usize;
|
||||
assert_eq!(read(&normals, size, (30 + x, 40 + y)), normal[i..i + 4]);
|
||||
assert_eq!(read(&specular, size, (30 + x, 40 + y)), [9; 4]);
|
||||
}
|
||||
}
|
||||
// The texels beside it on its pages, and pages without a map.
|
||||
assert_eq!(read(&normals, size, (29, 40)), crate::sprites::FLAT_NORMAL);
|
||||
assert_eq!(read(&normals, size, (2000, 5)), crate::sprites::FLAT_NORMAL);
|
||||
assert_eq!(
|
||||
read(&specular, size, (1005, 1005)),
|
||||
crate::sprites::NO_SPECULAR
|
||||
);
|
||||
assert_eq!(read(&normals, size, (1005, 1005)), [3; 4]);
|
||||
// Five pages: a table of 2 + 256 x 128 entries, 33 rows at 1024 wide (48, whole pages),
|
||||
// then a row of pages.
|
||||
assert_eq!((normals.width, normals.height), (1024, 64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_mapped_makes_no_map() {
|
||||
let sprites = [SpriteMaps {
|
||||
rect: [0, 0, 16, 16],
|
||||
maps: [None, None],
|
||||
}];
|
||||
assert_eq!(build((1024, 1024), &sprites, 16384), [None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_map_fits_its_texture() {
|
||||
// A 2x map is sampled down; a strip of frames keeps its first.
|
||||
let map: Vec<u8> = (0..4 * 8).flat_map(|i: u8| [i, 0, 0, 255]).collect();
|
||||
let fitted = fit(&map, (4, 8), (2, 2)).unwrap();
|
||||
assert_eq!(
|
||||
fitted.chunks(4).map(|p| p[0]).collect::<Vec<_>>(),
|
||||
[0, 2, 8, 10]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -840,6 +840,7 @@ pub enum Opt {
|
||||
PadRepeatInterval,
|
||||
PadInvertX,
|
||||
PadInvertY,
|
||||
PadCraftOnServers,
|
||||
PadToggleSneak,
|
||||
PadMoveDeadzone,
|
||||
PadLookDeadzone,
|
||||
|
||||
@@ -199,26 +199,73 @@ fn sprite_at(tiled: vec2<f32>, layer: u32, du: vec2<f32>, dv: vec2<f32>) -> vec4
|
||||
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
|
||||
}
|
||||
|
||||
// The terrain stages' values, as the code reads them. What crosses from the vertex stage to the
|
||||
// fragment stage is `TerrainWire`: the same values in fewer interpolant locations.
|
||||
struct TerrainOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) @interpolate(flat) layer: u32,
|
||||
@location(2) color: vec4<f32>,
|
||||
@location(3) spherical: f32,
|
||||
@location(4) cylindrical: f32,
|
||||
clip: vec4<f32>,
|
||||
uv: vec2<f32>,
|
||||
layer: u32,
|
||||
color: vec4<f32>,
|
||||
spherical: f32,
|
||||
cylindrical: f32,
|
||||
// How the face shines in HDR output (mesher.rs's `EMISSION_BITS`): 0 not
|
||||
// at all, 1 to 4 the tier with its bright parts, 5 to 8 the tier all over.
|
||||
@location(5) @interpolate(flat) emission: u32,
|
||||
emission: u32,
|
||||
// HDR lighting: block and sky light, as levels 0 to 15.
|
||||
@location(11) scene: vec2<f32>,
|
||||
@location(6) @interpolate(flat) fade: u32,
|
||||
scene: vec2<f32>,
|
||||
fade: u32,
|
||||
// A distant tile's tile-relative position, for its fade (`faded`).
|
||||
@location(7) local: vec3<f32>,
|
||||
local: vec3<f32>,
|
||||
// terrain.vsh's chunkVisibility: the section's fade-in, from 16 blocks out.
|
||||
@location(8) visibility: f32,
|
||||
visibility: f32,
|
||||
// The camera-relative height, for the water mirror's cut at the plane.
|
||||
@location(9) height: f32,
|
||||
@location(10) @interpolate(flat) emissive_layer: u32,
|
||||
height: f32,
|
||||
emissive_layer: u32,
|
||||
}
|
||||
// `TerrainOut` in 5 interpolant locations instead of 12. A GPU's vertex stage pays for each
|
||||
// location it exports (AMD's parameter cache gives one a whole slot, however few of its four
|
||||
// components are used): on an RX 580 the 12 cost 3.2 ns per quad and these 5 cost 1.6. Every
|
||||
// component is still interpolated on its own (and the integers stay flat), so each pixel gets
|
||||
// the very values the separate locations gave it.
|
||||
struct TerrainWire {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
// The colour.
|
||||
@location(0) a: vec4<f32>,
|
||||
// `local` and `visibility`.
|
||||
@location(1) b: vec4<f32>,
|
||||
// `uv` and `scene`.
|
||||
@location(2) c: vec4<f32>,
|
||||
// `spherical`, `cylindrical` and `height`.
|
||||
@location(3) d: vec4<f32>,
|
||||
// `layer`, `emission`, `fade` and `emissive_layer`.
|
||||
@location(4) @interpolate(flat) e: vec4<u32>,
|
||||
}
|
||||
fn to_wire(o: TerrainOut) -> TerrainWire {
|
||||
var w: TerrainWire;
|
||||
w.clip = o.clip;
|
||||
w.a = o.color;
|
||||
w.b = vec4(o.local, o.visibility);
|
||||
w.c = vec4(o.uv, o.scene);
|
||||
w.d = vec4(o.spherical, o.cylindrical, o.height, 0.0);
|
||||
w.e = vec4(o.layer, o.emission, o.fade, o.emissive_layer);
|
||||
return w;
|
||||
}
|
||||
fn from_wire(w: TerrainWire) -> TerrainOut {
|
||||
var o: TerrainOut;
|
||||
o.clip = w.clip;
|
||||
o.color = w.a;
|
||||
o.local = w.b.xyz;
|
||||
o.visibility = w.b.w;
|
||||
o.uv = w.c.xy;
|
||||
o.scene = w.c.zw;
|
||||
o.spherical = w.d.x;
|
||||
o.cylindrical = w.d.y;
|
||||
o.height = w.d.z;
|
||||
o.layer = w.e.x;
|
||||
o.emission = w.e.y;
|
||||
o.fade = w.e.z;
|
||||
o.emissive_layer = w.e.w;
|
||||
return o;
|
||||
}
|
||||
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
|
||||
// `fade_code`, `fade_out_code`). It fades in, or out, chunk by chunk: each
|
||||
@@ -333,9 +380,9 @@ fn fade_code(w: f32) -> u32 {
|
||||
@vertex fn vs_terrain(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
@location(5) origin: vec4<f32>) -> TerrainWire {
|
||||
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w));
|
||||
}
|
||||
// Flat section draws (gfx/flat.rs): one draw per arena page over the frame's
|
||||
// index list. A section's vertices fill whole 256-vertex blocks of its page:
|
||||
@@ -346,10 +393,10 @@ fn fade_code(w: f32) -> u32 {
|
||||
@builtin(vertex_index) vertex: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> TerrainOut {
|
||||
) -> TerrainWire {
|
||||
let origin = slot_origins[block_slots[vertex >> 8u]];
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w));
|
||||
}
|
||||
// Solid terrain draws in two passes, each over its own part of every mesh's quads
|
||||
// (`mesher::Part`): first the quads with no cutout that are not fading, through
|
||||
@@ -370,38 +417,38 @@ fn left_out() -> TerrainOut {
|
||||
@vertex fn vs_terrain_opaque(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
if !opaque_quad(layer, origin.w) { return left_out(); }
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
@location(5) origin: vec4<f32>) -> TerrainWire {
|
||||
if !opaque_quad(layer, origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w));
|
||||
}
|
||||
@vertex fn vs_terrain_cutout(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
if opaque_quad(layer, origin.w) { return left_out(); }
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
@location(5) origin: vec4<f32>) -> TerrainWire {
|
||||
if opaque_quad(layer, origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w));
|
||||
}
|
||||
@vertex fn vs_terrain_flat_opaque(
|
||||
@builtin(vertex_index) vertex: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> TerrainOut {
|
||||
) -> TerrainWire {
|
||||
let origin = slot_origins[block_slots[vertex >> 8u]];
|
||||
if !opaque_quad(layer, origin.w) { return left_out(); }
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
if !opaque_quad(layer, origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w));
|
||||
}
|
||||
@vertex fn vs_terrain_flat_cutout(
|
||||
@builtin(vertex_index) vertex: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> TerrainOut {
|
||||
) -> TerrainWire {
|
||||
let origin = slot_origins[block_slots[vertex >> 8u]];
|
||||
if opaque_quad(layer, origin.w) { return left_out(); }
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
if opaque_quad(layer, origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w));
|
||||
}
|
||||
// Solid section draws that read their vertices from the arena page (gfx.rs's `pulled`
|
||||
// pipelines; `mesher/packed.rs`): a draw's vertex indices below PACKED_BASE name vertices
|
||||
@@ -511,37 +558,37 @@ fn corner_vertex(c: ArenaCorner, origin: vec4<f32>) -> TerrainOut {
|
||||
unorm8x4(c.r.z), light, origin.xyz, origin.w);
|
||||
}
|
||||
@vertex fn vs_section(@builtin(vertex_index) vertex: u32,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
return corner_vertex(section_corner(vertex).c, origin);
|
||||
@location(5) origin: vec4<f32>) -> TerrainWire {
|
||||
return to_wire(corner_vertex(section_corner(vertex).c, origin));
|
||||
}
|
||||
@vertex fn vs_section_opaque(@builtin(vertex_index) vertex: u32,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
@location(5) origin: vec4<f32>) -> TerrainWire {
|
||||
let s = section_corner(vertex);
|
||||
if !opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
|
||||
return corner_vertex(s.c, origin);
|
||||
if !opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(corner_vertex(s.c, origin));
|
||||
}
|
||||
@vertex fn vs_section_cutout(@builtin(vertex_index) vertex: u32,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
@location(5) origin: vec4<f32>) -> TerrainWire {
|
||||
let s = section_corner(vertex);
|
||||
if opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
|
||||
return corner_vertex(s.c, origin);
|
||||
if opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(corner_vertex(s.c, origin));
|
||||
}
|
||||
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainOut {
|
||||
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainWire {
|
||||
let s = section_corner(vertex);
|
||||
let origin = slot_origins[block_slots[s.slot >> 8u]];
|
||||
return corner_vertex(s.c, origin);
|
||||
return to_wire(corner_vertex(s.c, origin));
|
||||
}
|
||||
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainOut {
|
||||
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainWire {
|
||||
let s = section_corner(vertex);
|
||||
let origin = slot_origins[block_slots[s.slot >> 8u]];
|
||||
if !opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
|
||||
return corner_vertex(s.c, origin);
|
||||
if !opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(corner_vertex(s.c, origin));
|
||||
}
|
||||
@vertex fn vs_section_flat_cutout(@builtin(vertex_index) vertex: u32) -> TerrainOut {
|
||||
@vertex fn vs_section_flat_cutout(@builtin(vertex_index) vertex: u32) -> TerrainWire {
|
||||
let s = section_corner(vertex);
|
||||
let origin = slot_origins[block_slots[s.slot >> 8u]];
|
||||
if opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
|
||||
return corner_vertex(s.c, origin);
|
||||
if opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(corner_vertex(s.c, origin));
|
||||
}
|
||||
// HDR lighting: how far a surface's light carries it above paper white, 0 to 1. Only the
|
||||
// strongest light does, so dark stays as dark as in SDR; sky light by how the face meets the sun.
|
||||
@@ -620,11 +667,13 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
let lift = smoothstep(globals.hdr_windows[0][t], globals.hdr_windows[1][t], y);
|
||||
return 0.175 * f32(3u - t) + 0.165 * (1.0 - lift);
|
||||
}
|
||||
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_solid(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
return solid_color(in);
|
||||
}
|
||||
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
|
||||
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_opaque(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
|
||||
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
|
||||
}
|
||||
@@ -635,7 +684,8 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
}
|
||||
// The water mirror (gfx.rs's reflection pass): what lies below the water plane
|
||||
// (`fog_ends.w`, camera-relative) would show above it in the mirror, so it is cut away.
|
||||
@fragment fn fs_solid_mirror(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_solid_mirror(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
if in.height < globals.fog_ends.w { discard; }
|
||||
return solid_color(in);
|
||||
}
|
||||
@@ -703,7 +753,8 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
|
||||
// HDR lighting on what's mostly opaque among translucent faces (water), drawn again after
|
||||
// them with only alpha written: `fs_translucent` blends its alpha as coverage, so a lake's
|
||||
// value would be diluted. Clear glass leaves what's behind it, light sources included, alone.
|
||||
@fragment fn fs_translucent_mark(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_translucent_mark(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
hdr_normal = normalize(cross(dy, dx));
|
||||
@@ -716,7 +767,8 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
|
||||
// Water, glass and other see-through faces draw whole as they fade in, the
|
||||
// layer drawn first in front (gfx.rs's water tiers): dithered, the faded-out
|
||||
// pixels showed the layer behind through the one in front, twice the water.
|
||||
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_translucent(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
if color.a < 0.01 { discard; }
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"horizon.options.look_sensitivity": "Look Sensitivity",
|
||||
"horizon.options.invert_x": "Invert Look X",
|
||||
"horizon.options.invert_y": "Invert Look Y",
|
||||
"horizon.options.craft_on_servers": "Quick Craft on Servers",
|
||||
"horizon.controller.direct_craft": "Quick Craft",
|
||||
"horizon.options.sneak_button": "Sneak Button",
|
||||
"horizon.options.move_deadzone": "Move Deadzone",
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
@@ -975,6 +977,7 @@
|
||||
"A DualSense's light shows your health, from green through amber to red.": "A DualSense's light shows your health, from green through amber to red.",
|
||||
"A DualSense's light changes with the dimension you're in.": "A DualSense's light changes with the dimension you're in.",
|
||||
"A DualSense keeps its own light.": "A DualSense keeps its own light.",
|
||||
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.",
|
||||
"Show the buttons for what you can do, while you play with a controller.": "Show the buttons for what you can do, while you play with a controller.",
|
||||
"How fast the stick moves the crosshair in inventories.": "How fast the stick moves the crosshair in inventories.",
|
||||
"How fast the right stick scrolls a list.": "How fast the right stick scrolls a list.",
|
||||
|
||||
@@ -1443,7 +1443,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
// What a copper golem did to chests, before the next mob ticks.
|
||||
for e in &events[from.min(events.len())..] {
|
||||
match e {
|
||||
MobEvent::ChestOpen { chest, open } => {
|
||||
MobEvent::ChestOpen { chest, open, .. } => {
|
||||
let o = world
|
||||
.chest_openers
|
||||
.entry((chest[0], chest[1], chest[2]))
|
||||
|
||||
@@ -90,6 +90,17 @@ impl CommandStorage {
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
/// What an in-place edit of `get`'s compound leaves behind when no
|
||||
/// `set` follows: a command that changed the stored compound and then
|
||||
/// failed (`DataCommands.manipulateData` throws before `setData`). Only
|
||||
/// an id that is stored has a compound to edit; the storage is not
|
||||
/// marked for saving.
|
||||
pub fn set_unsaved(&mut self, id: &str, contents: Tag) {
|
||||
let (ns, path) = split_id(id);
|
||||
if let Some(stored) = self.namespaces.get_mut(ns).and_then(|m| m.get_mut(path)) {
|
||||
*stored = contents;
|
||||
}
|
||||
}
|
||||
/// Every stored id (`keys`).
|
||||
pub fn keys(&self) -> Vec<String> {
|
||||
self.namespaces
|
||||
|
||||
@@ -664,6 +664,12 @@ pub fn mob_to_nbt(
|
||||
"Invulnerable".into(),
|
||||
Tag::Byte(i8::from(m.invulnerable_flag)),
|
||||
);
|
||||
// Entity.saveWithoutId: the hurt immunity left, only while there is some.
|
||||
if m.invulnerable > 0 {
|
||||
map.insert("invulnerable_time".into(), Tag::Int(m.invulnerable));
|
||||
} else {
|
||||
map.remove(&horizon_world::nbt::JavaString::from("invulnerable_time"));
|
||||
}
|
||||
map.insert(
|
||||
"LeftHanded".into(),
|
||||
Tag::Byte(i8::from(m.extra.left_handed)),
|
||||
@@ -1938,7 +1944,12 @@ pub fn mob_from_nbt(
|
||||
};
|
||||
let effect = crate::brewing::namespaced(def.id);
|
||||
let duration = number(e.get("duration")).unwrap_or(0) as i32;
|
||||
let amplifier = number(e.get("amplifier")).unwrap_or(0) as i32;
|
||||
// `MobEffectInstance.Details`: Codec.BYTE uses boxed Number.byteValue,
|
||||
// then UNSIGNED_BYTE interprets the resulting byte as 0..255.
|
||||
let amplifier = e
|
||||
.get("amplifier")
|
||||
.and_then(horizon_world::datafix::dynamic::as_number)
|
||||
.map_or(0, |n| i32::from(n.byte_value() as u8));
|
||||
m.extra.effects.retain(|x| x.0 != effect);
|
||||
m.extra.effects.push((effect, duration, amplifier));
|
||||
// Details.CODEC: `show_particles` defaults to true.
|
||||
@@ -1955,6 +1966,8 @@ pub fn mob_from_nbt(
|
||||
}
|
||||
m.silent = flag(tag, "Silent");
|
||||
m.invulnerable_flag = flag(tag, "Invulnerable");
|
||||
// Entity.load: `getIntOr("invulnerable_time", 0)`.
|
||||
m.invulnerable = number(tag.get("invulnerable_time")).unwrap_or(0) as i32;
|
||||
if m.species() == crate::mobs::Species::Bat {
|
||||
// Bat.readAdditionalSaveData: BatFlags bit 0, resting.
|
||||
m.extra.flag = number(tag.get("BatFlags")).unwrap_or(0) & 1 != 0;
|
||||
@@ -2639,6 +2652,28 @@ fn item_uuid_from_tag(tag: Option<&Tag>) -> Option<[i32; 4]> {
|
||||
values.get(..4)?.try_into().ok()
|
||||
}
|
||||
|
||||
/// The `Entity` fields a saved non-mob entity carries (`Base::tag`), for a
|
||||
/// caller that writes its own kind's fields around them.
|
||||
pub fn entity_base_tag(
|
||||
id: &str,
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
rotation: [f32; 2],
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
) -> Tag {
|
||||
Base {
|
||||
id,
|
||||
uuid,
|
||||
position,
|
||||
motion,
|
||||
rotation,
|
||||
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
.tag(raw)
|
||||
}
|
||||
|
||||
/// `ItemEntity.readAdditionalSaveData`: Health uses NumericTag's short conversion;
|
||||
/// Owner and Thrower use the numeric UUID codec.
|
||||
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
|
||||
@@ -9392,6 +9427,109 @@ mod carrier_inventory_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mob_effect_amplifier_codec {
|
||||
use super::*;
|
||||
|
||||
fn types() -> HashMap<String, Arc<MobType>> {
|
||||
let kind = crate::mobs::sensor_scan_tests::kind();
|
||||
HashMap::from([(kind.id.clone(), kind)])
|
||||
}
|
||||
|
||||
fn input(amplifier: Option<Tag>) -> Tag {
|
||||
let mut effect = Tag::compound([
|
||||
("id", string("minecraft:night_vision")),
|
||||
("duration", Tag::Int(200)),
|
||||
("ambient", Tag::Byte(1)),
|
||||
("show_particles", Tag::Byte(0)),
|
||||
("show_icon", Tag::Byte(0)),
|
||||
]);
|
||||
if let Some(value) = amplifier {
|
||||
put(&mut effect, "amplifier", value);
|
||||
}
|
||||
Tag::compound([
|
||||
("id", string("minecraft:cow")),
|
||||
("Pos", doubles(&[6.5, 200.0, 6.5])),
|
||||
("Health", Tag::Float(10.0f32.to_bits())),
|
||||
("NoAI", Tag::Byte(1)),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
("Tags", Tag::List(vec![string("amplifier-control")])),
|
||||
("active_effects", Tag::List(vec![effect])),
|
||||
])
|
||||
}
|
||||
|
||||
fn saved_effect(tag: &Tag) -> &Tag {
|
||||
let Some(Tag::List(effects)) = tag.get("active_effects") else {
|
||||
panic!("actual effect missing: {tag:?}");
|
||||
};
|
||||
assert_eq!(effects.len(), 1);
|
||||
&effects[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_numeric_amplifiers_roundtrip_without_raw_input() {
|
||||
let cases = [
|
||||
(None, 0),
|
||||
(Some(Tag::Byte(0)), 0),
|
||||
(Some(Tag::Int(1)), 1),
|
||||
(Some(Tag::Byte(127)), 127),
|
||||
(Some(Tag::Byte(-128)), 128),
|
||||
(Some(Tag::Byte(-1)), 255),
|
||||
(Some(Tag::Int(256)), 0),
|
||||
(Some(Tag::Int(-1)), 255),
|
||||
(Some(Tag::Long(9007199254740993)), 1),
|
||||
(Some(Tag::Long(-4294967297)), 255),
|
||||
(Some(Tag::Float(1.75f32.to_bits())), 1),
|
||||
(Some(Tag::Float((-0.5f32).to_bits())), 0),
|
||||
(Some(Tag::Double((-1.75f64).to_bits())), 255),
|
||||
(Some(Tag::Double(256.75f64.to_bits())), 0),
|
||||
(Some(Tag::Double(1e20f64.to_bits())), 255),
|
||||
(Some(Tag::Double((-1e20f64).to_bits())), 0),
|
||||
];
|
||||
let types = types();
|
||||
for (value, expected) in cases {
|
||||
let raw = input(value.clone());
|
||||
let mob = mob_from_nbt(&raw, &types, 7, None).unwrap();
|
||||
assert_eq!(
|
||||
mob.extra.effects,
|
||||
[("minecraft:night_vision", 200, expected)],
|
||||
"{value:?}"
|
||||
);
|
||||
for source in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&mob, [158, 0, 0, 1], source, None, |_| None);
|
||||
let effect = saved_effect(&saved);
|
||||
let expected_tag = (expected != 0).then_some(Tag::Byte(expected as u8 as i8));
|
||||
assert_eq!(effect.get("amplifier"), expected_tag.as_ref(), "{value:?}");
|
||||
assert_eq!(effect.get("duration"), Some(&Tag::Int(200)));
|
||||
assert_eq!(effect.get("ambient"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(effect.get("show_particles"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(effect.get("show_icon"), Some(&Tag::Byte(0)));
|
||||
let back = mob_from_nbt(&saved, &types, 8, None).unwrap();
|
||||
assert_eq!(back.extra.effects, mob.extra.effects);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modeled_amplifier_mutation_overrides_stale_raw_and_omits_zero() {
|
||||
let raw = input(Some(Tag::Byte(12)));
|
||||
let mut mob = mob_from_nbt(&raw, &types(), 7, None).unwrap();
|
||||
for amplifier in [0, 128, 255, 1] {
|
||||
mob.extra.effects[0].2 = amplifier;
|
||||
for source in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&mob, [158, 0, 0, 2], source, None, |_| None);
|
||||
let expected = (amplifier != 0).then_some(Tag::Byte(amplifier as u8 as i8));
|
||||
assert_eq!(saved_effect(&saved).get("amplifier"), expected.as_ref());
|
||||
assert_eq!(saved.get("Health"), Some(&Tag::Float(10.0f32.to_bits())));
|
||||
assert_eq!(saved.get("NoAI"), Some(&Tag::Byte(1)));
|
||||
if source.is_some() {
|
||||
assert_eq!(saved.get("Tags"), raw.get("Tags"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod camel_inherited_saved_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -336,6 +336,7 @@ mod ordered_tick;
|
||||
mod packs;
|
||||
mod pad;
|
||||
mod pad_actions;
|
||||
mod pad_craft;
|
||||
mod pad_menu;
|
||||
mod paintings;
|
||||
mod pandas;
|
||||
@@ -2176,6 +2177,19 @@ impl FallingBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A block entity's `mark_motion` id: its position, packed into the 32 bits beside the tag
|
||||
/// (x and z wrap at 2048 blocks and y at 1024; two block entities that far apart in one frame's
|
||||
/// view are told apart by their vertex counts, as `PackEntities` matches only equal runs).
|
||||
fn motion_block_entity(pos: [i32; 3]) -> u64 {
|
||||
motion_at(Game::MOTION_BLOCK_ENTITY, pos)
|
||||
}
|
||||
|
||||
/// `tag` with a block position packed into its low 32 bits (`motion_block_entity`).
|
||||
fn motion_at(tag: u64, pos: [i32; 3]) -> u64 {
|
||||
let [x, y, z] = pos.map(|c| c as u32);
|
||||
tag | u64::from((x & 0x7FF) | (z & 0x7FF) << 11 | (y & 0x3FF) << 22)
|
||||
}
|
||||
|
||||
/// `FallingBlockRenderer.shouldRender` drops a falling block whose own state is
|
||||
/// the state of the cell it is in (`level.getBlockState(entity.blockPosition())`):
|
||||
/// a `NoGravity` block, or one that has just landed, sits inside an identical
|
||||
@@ -7682,6 +7696,7 @@ impl Game<'_> {
|
||||
if running {
|
||||
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
|
||||
horizon_client::spans::time("tick_item_frames", || self.tick_item_frames());
|
||||
self.tick_explicit_knots();
|
||||
self.ordered_before(ordered_tick::Batch::ArmorStands)?;
|
||||
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands())?;
|
||||
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
|
||||
@@ -8318,6 +8333,7 @@ impl Game<'_> {
|
||||
| BlockEntity::EndGateway(_)
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Bell
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_)
|
||||
| BlockEntity::Brushable(_)
|
||||
@@ -8381,6 +8397,7 @@ impl Game<'_> {
|
||||
| BlockEntity::EndGateway(_)
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Bell
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_)
|
||||
| BlockEntity::Brushable(_)
|
||||
@@ -8515,6 +8532,7 @@ impl Game<'_> {
|
||||
| BlockEntity::EndGateway(_)
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Bell
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_)
|
||||
| BlockEntity::Brushable(_)
|
||||
@@ -9530,6 +9548,17 @@ impl Game<'_> {
|
||||
None => camera_rotation(self.camera_type, self.yaw, self.pitch),
|
||||
}
|
||||
}
|
||||
/// `mark_motion` ids of entities that aren't mobs or dropped items (boats, minecarts, armor
|
||||
/// stands, item frames, paintings, primed TNT, falling blocks): their entity id under this tag.
|
||||
pub(super) const MOTION_OBJECT: u64 = 3 << 32;
|
||||
/// An item frame's held item, apart from the frame (one id may mark one run).
|
||||
pub(super) const MOTION_FRAME_ITEM: u64 = 4 << 32;
|
||||
/// A block entity, by its position (`motion_block_entity`).
|
||||
pub(super) const MOTION_BLOCK_ENTITY: u64 = 5 << 32;
|
||||
/// A block a piston moves, by the cell it moves to (`motion_at`).
|
||||
pub(super) const MOTION_MOVING_BLOCK: u64 = 6 << 32;
|
||||
/// The player's own model (third person, and a shader pack's shadow pass).
|
||||
pub(super) const MOTION_OWN_PLAYER: u64 = 7 << 32;
|
||||
/// The entity whose geometry starts at vertex `start` (0: none), for frame
|
||||
/// generation to follow its own motion. Each run lasts until the next mark.
|
||||
pub(super) fn mark_motion(&self, start: usize, id: u64) {
|
||||
@@ -9716,6 +9745,7 @@ impl Game<'_> {
|
||||
// In first person only a shader pack's shadow pass draws the player.
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
let player_start = out.len();
|
||||
self.mark_motion(player_start, Self::MOTION_OWN_PLAYER);
|
||||
let player_alpha = if self.studio_replay.is_some() {
|
||||
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
@@ -9729,6 +9759,7 @@ impl Game<'_> {
|
||||
.borrow_mut()
|
||||
.extend(out.drain(player_start..));
|
||||
}
|
||||
self.mark_motion(out.len(), 0);
|
||||
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
|
||||
self.cosmetics_fx_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:evoker_fangs");
|
||||
@@ -9962,7 +9993,10 @@ impl Game<'_> {
|
||||
r.block_entity_revision(),
|
||||
Some(§ion_revision),
|
||||
&mut out,
|
||||
|_, state, start| self.mark_block_entity(start, state),
|
||||
|pos, state, start| {
|
||||
self.mark_block_entity(start, state);
|
||||
self.mark_motion(start, motion_block_entity(pos));
|
||||
},
|
||||
|pos, _, start, out| {
|
||||
if shadow_only_at.contains(&pos) {
|
||||
self.hide_spans_from(start, out.len());
|
||||
@@ -10155,6 +10189,7 @@ impl Game<'_> {
|
||||
let light = self.world.light(x, y + 1, z);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let start = out.len();
|
||||
self.mark_motion(start, Self::MOTION_OBJECT | u64::from(f.id));
|
||||
self.block_model_vertices(
|
||||
f.state,
|
||||
horizon_core::random::position_seed(f.start_pos),
|
||||
@@ -10168,23 +10203,30 @@ impl Game<'_> {
|
||||
vertex.color = horizon_client::hud_layout::multiply(vertex.color, tint);
|
||||
}
|
||||
}
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.mark_entity(out.len(), "");
|
||||
self.moving_piston_geometry(camera, alpha, &mut out);
|
||||
// LevelRenderer.submitTransientBlocks.
|
||||
self.transient_block_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:tnt");
|
||||
self.tnt_geometry(camera, alpha, &mut out);
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.mark_entity(out.len(), "minecraft:painting");
|
||||
self.painting_geometry(camera, &mut out);
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.mark_entity(out.len(), "minecraft:item_frame");
|
||||
self.frame_geometry(camera, &mut out);
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.mark_entity(out.len(), "minecraft:armor_stand");
|
||||
self.armor_stand_geometry(camera, alpha, &mut out);
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.cushion_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:oak_boat");
|
||||
self.boat_geometry(camera, alpha, &mut out);
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.mark_entity(out.len(), "minecraft:minecart");
|
||||
self.cart_geometry(camera, alpha, &mut out);
|
||||
self.mark_motion(out.len(), 0);
|
||||
// Beams: `gbuffers_beaconbeam`, not a block entity's program (5.4 has no stage for them).
|
||||
self.mark_stage(out.len(), "NONE");
|
||||
self.beacon_geometry(camera, alpha, &mut out);
|
||||
@@ -18224,6 +18266,8 @@ fn write_level_for_role(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod storage_alias_tests;
|
||||
#[cfg(test)]
|
||||
mod sulfur_bucket_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1089,6 +1089,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, Self::MOTION_OBJECT | u64::from(a.id));
|
||||
// EntityRenderer: drawn between last tick's position and this one's.
|
||||
let at = a.render_position(alpha);
|
||||
let p = super::entity_culling::at(at) - camera;
|
||||
|
||||
@@ -1380,6 +1380,9 @@ impl Game<'_> {
|
||||
// of coasting on the velocity of the ride that just ended.
|
||||
self.boats[i].velocity = Vec3::ZERO;
|
||||
self.boats[i].delta_rotation = 0.0;
|
||||
if self.dismount_in_portal(self.boats[i].position) {
|
||||
return;
|
||||
}
|
||||
let target = self.boat_dismount_target(i, 0.6, self.yaw);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
@@ -1619,7 +1622,14 @@ impl Game<'_> {
|
||||
// Player.attack's full damage against the boat's entity type.
|
||||
let (kind, id) = (self.boats[i].entity_type(), self.boats[i].id as i32);
|
||||
let (damage, scale, _, magic) = self.melee_damage(&kind, false);
|
||||
let chest = self.boats[i].chest;
|
||||
let hurt = self.hurt_boat_result(i, damage, self.creative);
|
||||
// AbstractChestBoat.destroy -> ContainerEntity.chestVehicleDestroyed:
|
||||
// with entity_drops on, a player whose hit breaks a chest boat (not a
|
||||
// creative discard) angers the piglins that see it.
|
||||
if chest && hurt == Some(true) && !self.creative && self.ui.rules.bool("entity_drops") {
|
||||
self.anger_piglins(true);
|
||||
}
|
||||
self.vehicle_attack_sounds(area, magic.then_some(id));
|
||||
if damage > 0.0
|
||||
&& let Some(destroyed) = hurt
|
||||
@@ -1729,6 +1739,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, Self::MOTION_OBJECT | u64::from(b.id));
|
||||
let models = if b.chest {
|
||||
&self.boat_models.chest_models
|
||||
} else {
|
||||
|
||||
@@ -91,11 +91,14 @@ impl Prompt {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The server list entry of the joined address, if it has one.
|
||||
/// The server list entry of the joined address, if it has one, shown or
|
||||
/// hidden (`ServerList.get`).
|
||||
fn accepted_code_of_conduct(&self) -> Option<i32> {
|
||||
let address = self.server_packs.address.as_deref()?;
|
||||
servers::load(&multiplayer::servers_file(&saves()))
|
||||
let (shown, hidden) = servers::load_all(&multiplayer::servers_file(&saves()));
|
||||
shown
|
||||
.into_iter()
|
||||
.chain(hidden)
|
||||
.find(|s| s.ip == address)
|
||||
.map(|s| s.accepted_code_of_conduct)
|
||||
}
|
||||
@@ -106,13 +109,20 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let mut list = servers::load(&file);
|
||||
let (mut list, mut hidden) = servers::load_all(&file);
|
||||
let mut found = false;
|
||||
for entry in list.iter_mut().filter(|s| &s.ip == address) {
|
||||
entry.accepted_code_of_conduct = accepted;
|
||||
found = true;
|
||||
}
|
||||
if found && let Err(e) = servers::save(&file, &list) {
|
||||
// Not among the shown entries: the hidden ones (a Direct Connection's).
|
||||
if !found {
|
||||
for entry in hidden.iter_mut().filter(|s| &s.ip == address) {
|
||||
entry.accepted_code_of_conduct = accepted;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if found && let Err(e) = servers::save_all(&file, &list, &hidden) {
|
||||
log::warn!("Horizon: could not save {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ pub(in crate::game) struct OtherEntity {
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub item_transformation: Option<super::native_entities::ItemDisplayTransformation>,
|
||||
/// Only explicitly loaded leash knots own this BlockAttachedEntity check counter.
|
||||
pub knot_check: Option<u8>,
|
||||
}
|
||||
pub(super) fn err(key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
|
||||
CommandError::tr(key, args)
|
||||
@@ -3735,6 +3737,46 @@ impl Game<'_> {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
// `MinMaxBounds.Ints.matches`: DamagePredicate's damage-only
|
||||
// Int bounds shape; effective DAMAGE presence is required.
|
||||
// Other fields keep the entire existing predicate path.
|
||||
("minecraft:damage", Some(ref v))
|
||||
if matches!(v, horizon_world::nbt::Tag::Compound(c) if c.len() == 1)
|
||||
&& match v.get("damage") {
|
||||
Some(horizon_world::nbt::Tag::Int(_)) => true,
|
||||
Some(horizon_world::nbt::Tag::Compound(bounds)) => {
|
||||
bounds.iter().all(|(key, value)| {
|
||||
matches!(key.to_string_lossy().as_str(), "min" | "max")
|
||||
&& matches!(value, horizon_world::nbt::Tag::Int(_))
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
} =>
|
||||
{
|
||||
let removed = stack
|
||||
.data()
|
||||
.is_some_and(|data| data.other("!minecraft:damage").is_some());
|
||||
let has_damage = !removed
|
||||
&& (present.is_some()
|
||||
|| self.items().get(&stack.item).is_some_and(|item| {
|
||||
item.components.iter().any(|c| c == "minecraft:damage")
|
||||
}));
|
||||
let n = i64::from(stack.damage);
|
||||
has_damage
|
||||
&& match v.get("damage").unwrap() {
|
||||
horizon_world::nbt::Tag::Int(exact) => n == i64::from(*exact),
|
||||
bounds => {
|
||||
let get = |key| match bounds.get(key) {
|
||||
Some(horizon_world::nbt::Tag::Int(value)) => {
|
||||
Some(i64::from(*value))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
get("min").is_none_or(|min| n >= min)
|
||||
&& get("max").is_none_or(|max| n <= max)
|
||||
}
|
||||
}
|
||||
}
|
||||
(_, Some(v)) => present.is_some_and(|have| {
|
||||
horizon_client::nbt_path::compare_nbt(&v, Some(have))
|
||||
}),
|
||||
@@ -5128,6 +5170,169 @@ mod forced_respawn_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod item_damage_range_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and scratch homes/cwd"]
|
||||
fn actual_chest_damage_range_uses_live_damage_and_effective_presence() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("setblock 4 199 4 stone"));
|
||||
assert!(game.run_command(r#"setblock 4 200 4 chest{Items:[{Slot:0b,id:"minecraft:diamond_sword",count:1,components:{"minecraft:custom_data":{marker:195}}},{Slot:1b,id:"minecraft:diamond_sword",count:1,components:{"minecraft:damage":3,"minecraft:custom_data":{marker:195}}},{Slot:2b,id:"minecraft:diamond_sword",count:1,components:{"minecraft:damage":4,"minecraft:custom_data":{marker:195}}},{Slot:3b,id:"minecraft:diamond_sword",count:1,components:{"minecraft:damage":5,"minecraft:custom_data":{marker:195}}},{Slot:4b,id:"minecraft:stone",count:1,components:{"minecraft:custom_data":{marker:195}}},{Slot:5b,id:"minecraft:diamond_sword",count:1,components:{"!minecraft:damage":{},"minecraft:custom_data":{marker:195}}}]}"#));
|
||||
assert!(game.run_command("scoreboard objectives add h195 dummy"));
|
||||
assert!(game.world.loaded(4, 4));
|
||||
assert!(game.run_command("execute if block 4 199 4 stone"));
|
||||
let clock = game.world.game_time;
|
||||
let stacks = match game.world.block_entities.get(&(4, 200, 4)).unwrap() {
|
||||
horizon_client::containers::BlockEntity::Chest(slots) => slots.clone(),
|
||||
_ => panic!("actual loaded chest missing"),
|
||||
};
|
||||
for (slot, damage) in [0, 3, 4, 5, 0, 0].into_iter().enumerate() {
|
||||
let stack = stacks[slot].as_ref().unwrap();
|
||||
assert_eq!(stack.damage, damage);
|
||||
assert_eq!(stack.count, 1);
|
||||
assert_eq!(
|
||||
&*stack.item,
|
||||
if slot == 4 {
|
||||
"minecraft:stone"
|
||||
} else {
|
||||
"minecraft:diamond_sword"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
stack.custom_data().unwrap().get(&"marker".into()),
|
||||
Some(&horizon_world::nbt::Tag::Int(195))
|
||||
);
|
||||
assert!(game.run_command(&format!(
|
||||
"execute if items block 4 200 4 container.{slot} {}",
|
||||
stack.item
|
||||
)));
|
||||
}
|
||||
assert!(stacks[6].is_none());
|
||||
assert!(
|
||||
game.items()
|
||||
.get("minecraft:diamond_sword")
|
||||
.unwrap()
|
||||
.components
|
||||
.iter()
|
||||
.any(|c| c == "minecraft:damage")
|
||||
);
|
||||
assert!(
|
||||
!game
|
||||
.items()
|
||||
.get("minecraft:stone")
|
||||
.unwrap()
|
||||
.components
|
||||
.iter()
|
||||
.any(|c| c == "minecraft:damage")
|
||||
);
|
||||
let removed =
|
||||
horizon_client::item_data::encode_components(stacks[5].as_ref().unwrap()).unwrap();
|
||||
assert!(
|
||||
removed.get("!minecraft:damage").is_some(),
|
||||
"actual removal patch must survive load before evaluating it"
|
||||
);
|
||||
assert!(game.run_command(
|
||||
"execute unless data block 4 200 4 Items[{Slot:0b}].components.\"minecraft:damage\""
|
||||
));
|
||||
assert!(game.run_command(
|
||||
"execute if data block 4 200 4 Items[{Slot:2b,components:{\"minecraft:damage\":4}}]"
|
||||
));
|
||||
assert!(game.run_command(
|
||||
"execute if data block 4 200 4 Items[{Slot:5b}].components.\"!minecraft:damage\""
|
||||
));
|
||||
let mut cases = Vec::new();
|
||||
for (slot, damage) in [(1, 3), (2, 4), (3, 5)] {
|
||||
for (shape, expected) in [
|
||||
("4", damage == 4),
|
||||
("{min:4}", damage >= 4),
|
||||
("{max:4}", damage <= 4),
|
||||
("{min:3,max:4}", (3..=4).contains(&damage)),
|
||||
] {
|
||||
cases.push((
|
||||
slot,
|
||||
format!("minecraft:damage~{{damage:{shape}}}"),
|
||||
expected,
|
||||
));
|
||||
}
|
||||
}
|
||||
cases.extend([
|
||||
(0, "damage~{damage:0}".into(), true),
|
||||
(0, "damage~{damage:{min:1}}".into(), false),
|
||||
(4, "damage~{damage:0}".into(), false),
|
||||
(5, "damage~{damage:0}".into(), false),
|
||||
(2, "!damage~{damage:4}".into(), false),
|
||||
(1, "!damage~{damage:4}".into(), true),
|
||||
(1, "damage=3".into(), true),
|
||||
(1, "damage=4".into(), false),
|
||||
(1, "count~1".into(), true),
|
||||
(1, "custom_data~{marker:195}".into(), true),
|
||||
]);
|
||||
let mut mismatches = Vec::new();
|
||||
for (slot, term, expected) in cases {
|
||||
let stack = stacks[slot].as_ref().unwrap();
|
||||
let predicate = ItemInput {
|
||||
id: stack.item.to_string(),
|
||||
tag: false,
|
||||
any: false,
|
||||
components: Some(format!("[{term}]")),
|
||||
};
|
||||
let actual = game.stack_matches(&predicate, stack);
|
||||
if actual != expected {
|
||||
mismatches.push(format!(
|
||||
"direct slot{slot} {term}: {actual}, expected{expected}"
|
||||
));
|
||||
}
|
||||
assert!(game.run_command("scoreboard players set #r h195 91"));
|
||||
assert!(game.run_command("scoreboard players set #s h195 17"));
|
||||
let command = format!(
|
||||
"execute store result score #r h195 store success score #s h195 if items block 4 200 4 container.{slot} {}[{term}]",
|
||||
stack.item
|
||||
);
|
||||
game.run_command(&command);
|
||||
let r = game.score.board.score("#r", "h195").map(|s| s.value);
|
||||
let s = game.score.board.score("#s", "h195").map(|s| s.value);
|
||||
if (r, s) != (Some(i32::from(expected)), Some(i32::from(expected))) {
|
||||
mismatches.push(format!("command {term}: {r:?}/{s:?}, expected{expected}"));
|
||||
}
|
||||
assert!(game.run_command("scoreboard players set #branch h195 23"));
|
||||
game.run_command(&format!("execute unless items block 4 200 4 container.{slot} {}[{term}] run scoreboard players set #branch h195 7",stack.item));
|
||||
let branch = game.score.board.score("#branch", "h195").map(|s| s.value);
|
||||
if branch != Some(if expected { 23 } else { 7 }) {
|
||||
mismatches.push(format!("unless {term}: {branch:?}"));
|
||||
}
|
||||
}
|
||||
assert!(!game.run_command(
|
||||
"execute if items block 4 200 4 container.4 diamond_sword[damage~{damage:4}]"
|
||||
));
|
||||
assert!(!game.run_command(
|
||||
"execute if items block 4 200 4 container.6 diamond_sword[damage~{damage:4}]"
|
||||
));
|
||||
let actual = match game.world.block_entities.get(&(4, 200, 4)).unwrap() {
|
||||
horizon_client::containers::BlockEntity::Chest(slots) => slots,
|
||||
_ => panic!("source chest changed"),
|
||||
};
|
||||
assert_eq!(
|
||||
actual, &stacks,
|
||||
"queries must leave the authoritative loaded stacks unchanged"
|
||||
);
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
assert!(
|
||||
mismatches.is_empty(),
|
||||
"damage-only command predicate mismatches: {mismatches:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vehicle_kill_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -263,6 +263,7 @@ impl Game<'_> {
|
||||
id,
|
||||
kind: "minecraft:area_effect_cloud".into(),
|
||||
item_transformation: None,
|
||||
knot_check: None,
|
||||
position: horizon_core::geometry::Vec3::new(x, y, z),
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
@@ -374,9 +375,9 @@ impl Game<'_> {
|
||||
st.liked_player_ttl = was.liked_player_ttl;
|
||||
}
|
||||
}
|
||||
// Entity.load mutates the existing living entity; its damage
|
||||
// cooldown and previous-hit amount are not read from saved NBT.
|
||||
slot.invulnerable = old.invulnerable;
|
||||
// Entity.load mutates the existing living entity; its
|
||||
// previous-hit amount is not read from saved NBT (its hurt
|
||||
// immunity is, as `invulnerable_time`).
|
||||
slot.last_hurt = old.last_hurt;
|
||||
// Entity.load updates saved data on the existing entity;
|
||||
// lastTradedPlayer is transient and survives a live NoAI edit.
|
||||
@@ -1111,6 +1112,13 @@ impl Game<'_> {
|
||||
let mut data = self.data_of(&target)?;
|
||||
let changed = data_cmd::modify(op, &mut data, &target_path, &sources)?;
|
||||
if changed == 0 {
|
||||
// StorageDataAccessor.getData is the stored compound itself: an
|
||||
// edit that changed it and still counted nothing (a wildcard set
|
||||
// that clears an array, then rejects the value) stays. An entity's
|
||||
// or a block's data is a copy.
|
||||
if let DataRef::Storage(id) = &target {
|
||||
self.score.storage.set_unsaved(id, data);
|
||||
}
|
||||
return Err(err("commands.data.merge.failed", &[]));
|
||||
}
|
||||
self.set_data_of(&target, data)?;
|
||||
@@ -1894,6 +1902,131 @@ mod modern_literal_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bell_command_identity_tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{chunk::Chunk, motion::PlayerMotion};
|
||||
use horizon_core::{geometry::Vec3, position::ChunkPos};
|
||||
use std::sync::Arc;
|
||||
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
|
||||
.join(format!("h174-{:?}", std::thread::current().id())),
|
||||
);
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
|
||||
game.creative = true;
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("tick freeze"));
|
||||
assert!(game.run_command("fill 0 198 0 15 199 15 stone"));
|
||||
test(&mut game);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn placed_bell_is_a_real_stored_entity_with_command_metadata() {
|
||||
with_game(|game| {
|
||||
let clock = game.world.game_time;
|
||||
assert!(game.run_command("setblock 8 200 4 chest[facing=north] strict"));
|
||||
assert!(game.world.block_entities.contains_key(&(8, 200, 4)));
|
||||
assert!(game.run_command("setblock 10 200 4 stone strict"));
|
||||
assert!(game.block_entity_nbt([10, 200, 4]).is_none());
|
||||
assert!(game.run_command(
|
||||
"setblock 4 200 4 bell[facing=north,attachment=floor,powered=false] strict"
|
||||
));
|
||||
assert!(game.run_command(
|
||||
"execute if block 4 200 4 bell[facing=north,attachment=floor,powered=false]"
|
||||
));
|
||||
assert!(game.world.chunks.contains_key(&(0, 0)));
|
||||
let entity = game
|
||||
.world
|
||||
.block_entities
|
||||
.get(&(4, 200, 4))
|
||||
.expect("actual placed bell stored in collection");
|
||||
assert_eq!(entity.id(), "minecraft:bell");
|
||||
assert!(entity.fits("minecraft:bell"));
|
||||
assert!(!entity.fits("minecraft:chest"));
|
||||
for _ in 0..2 {
|
||||
let tag = game.block_entity_nbt([4, 200, 4]).unwrap();
|
||||
for (key, value) in [
|
||||
("id", Tag::String("minecraft:bell".into())),
|
||||
("x", Tag::Int(4)),
|
||||
("y", Tag::Int(200)),
|
||||
("z", Tag::Int(4)),
|
||||
] {
|
||||
assert_eq!(tag.get(key), Some(&value));
|
||||
}
|
||||
assert!(game.run_command(
|
||||
"execute if data block 4 200 4 {id:\"minecraft:bell\",x:4,y:200,z:4}"
|
||||
));
|
||||
}
|
||||
// No raw input: factory/model and the real common writer supply metadata.
|
||||
let fresh = BlockEntity::for_block("minecraft:bell").unwrap();
|
||||
let tag = save::block_entity_data((-3, 204, 11), &fresh, "minecraft:bell", None);
|
||||
assert_eq!(tag.get("x"), Some(&Tag::Int(-3)));
|
||||
assert_eq!(tag.get("y"), Some(&Tag::Int(204)));
|
||||
assert_eq!(tag.get("z"), Some(&Tag::Int(11)));
|
||||
let (pos, back) = save::decode_block_entity(&tag).unwrap();
|
||||
assert_eq!(pos, (-3, 204, 11));
|
||||
assert_eq!(back.id(), "minecraft:bell");
|
||||
assert!(game.run_command(
|
||||
"setblock 4 200 4 bell[facing=east,attachment=floor,powered=false] strict"
|
||||
));
|
||||
assert_eq!(
|
||||
game.world.block_entities.get(&(4, 200, 4)).unwrap().id(),
|
||||
"minecraft:bell"
|
||||
);
|
||||
assert!(game.run_command("setblock 4 200 4 air strict"));
|
||||
assert!(!game.world.block_entities.contains_key(&(4, 200, 4)));
|
||||
assert!(game.world.block_entities.contains_key(&(8, 200, 4)));
|
||||
assert!(game.run_command(
|
||||
"setblock 4 200 4 bell[facing=south,attachment=floor,powered=false] strict"
|
||||
));
|
||||
assert_eq!(
|
||||
game.world.block_entities.get(&(4, 200, 4)).unwrap().id(),
|
||||
"minecraft:bell"
|
||||
);
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod falling_live_adoption_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, commands::Target, load_resources};
|
||||
|
||||
@@ -146,9 +146,20 @@ impl Game<'_> {
|
||||
(Target::Mob(id), e) => match self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
Some(m) => {
|
||||
let before = m.extra.effects.len();
|
||||
let boosted = |m: &horizon_client::mobs::Mob| {
|
||||
m.extra
|
||||
.effects
|
||||
.iter()
|
||||
.any(|x| x.0 == "minecraft:health_boost")
|
||||
};
|
||||
let had_boost = boosted(m);
|
||||
m.extra
|
||||
.effects
|
||||
.retain(|x| e.is_some_and(|e| x.0 != namespaced(e.id)));
|
||||
// LivingEntity.onEffectsRemoved -> refreshDirtyAttributes.
|
||||
if had_boost && !boosted(m) {
|
||||
horizon_client::mobs::finalize::health_boost_changed(m);
|
||||
}
|
||||
let left = &m.extra.effects;
|
||||
m.extra
|
||||
.hidden_effects
|
||||
|
||||
@@ -2222,3 +2222,218 @@ mod mob_equipment_stack_tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod builtin_component_delivery_tests {
|
||||
use crate::game::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig},
|
||||
};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
item::{Slot, component},
|
||||
packets::{configuration::ClientInformation, play},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h283_actual_command_inventory_bridge_encodes_four_builtin_components() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(game.world.loaded(8, 8));
|
||||
game.lan_host(
|
||||
crate::game::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "component-wire-test".into(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 283,
|
||||
name: "ComponentGuest".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[8.5, 200.0, 8.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.selected = 1;
|
||||
guest.permission = 2;
|
||||
let mut host = game.lan.take().unwrap();
|
||||
host.hub.guests.push(guest);
|
||||
let clock = game.world.game_time;
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let registries: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(root.join("registries.json")).unwrap()).unwrap();
|
||||
let id = |registry: &str, name: &str| {
|
||||
registries[registry]["entries"][name]["protocol_id"]
|
||||
.as_i64()
|
||||
.unwrap() as i32
|
||||
};
|
||||
let command = |game: &mut Game<'_>, host: &mut LanHost, input: &str| {
|
||||
assert!(
|
||||
game.as_guest(host, 2, |g| g.run_command(input)).unwrap(),
|
||||
"{input}"
|
||||
);
|
||||
};
|
||||
let capture = |host: &mut LanHost| {
|
||||
while rx.try_recv().is_ok() {}
|
||||
host.hub.send_inventory(0);
|
||||
let mut packets = Vec::new();
|
||||
for out in rx.try_iter() {
|
||||
match out {
|
||||
Out::Packet(body) => packets.push(body),
|
||||
Out::Packets(bodies) => packets.extend(bodies),
|
||||
Out::Disconnect(_) => panic!("unexpected disconnect"),
|
||||
}
|
||||
}
|
||||
let mut contents = Vec::new();
|
||||
for body in packets {
|
||||
let mut r = Reader::new(&body);
|
||||
let packet = r.varint().unwrap();
|
||||
if packet == horizon_protocol::ids::play::clientbound::CONTAINER_SET_CONTENT {
|
||||
let content = play::ContainerSetContent::read(&mut r).unwrap();
|
||||
assert_eq!(r.remaining(), 0, "complete enclosing content payload");
|
||||
assert_eq!(content.container, 0);
|
||||
assert_eq!(content.items.len(), 46);
|
||||
assert!(content.carried.is_none());
|
||||
contents.push(content);
|
||||
}
|
||||
}
|
||||
assert_eq!(contents.len(), 1, "one actual inventory content packet");
|
||||
contents.pop().unwrap().items[9].clone().unwrap()
|
||||
};
|
||||
command(
|
||||
&mut game,
|
||||
&mut host,
|
||||
"item replace entity @s inventory.0 with stone[minecraft:custom_name=\"H283 control\"] 3",
|
||||
);
|
||||
let control = capture(&mut host);
|
||||
assert_eq!(control.item, id("minecraft:item", "minecraft:stone"));
|
||||
assert_eq!(control.count, 3);
|
||||
assert!(control.component(component::CUSTOM_NAME).is_some());
|
||||
command(
|
||||
&mut game,
|
||||
&mut host,
|
||||
"item replace entity @s inventory.0 with gold_ingot",
|
||||
);
|
||||
let plain = capture(&mut host);
|
||||
assert!(
|
||||
plain
|
||||
.component(id(
|
||||
"minecraft:data_component_type",
|
||||
"minecraft:provides_trim_material"
|
||||
))
|
||||
.is_none()
|
||||
);
|
||||
assert!(plain.removed.is_empty());
|
||||
let mut failures = Vec::new();
|
||||
for (name, item, value, expected) in [
|
||||
(
|
||||
"minecraft:provides_trim_material",
|
||||
"minecraft:diamond",
|
||||
"\"minecraft:gold\"",
|
||||
Tag::String("minecraft:gold".into()),
|
||||
),
|
||||
(
|
||||
"minecraft:provides_banner_patterns",
|
||||
"minecraft:paper",
|
||||
"\"#minecraft:pattern_item/globe\"",
|
||||
Tag::String("#minecraft:pattern_item/globe".into()),
|
||||
),
|
||||
(
|
||||
"minecraft:can_place_on",
|
||||
"minecraft:stone",
|
||||
"{blocks:\"minecraft:dirt\"}",
|
||||
Tag::compound([("blocks", Tag::String("minecraft:dirt".into()))]),
|
||||
),
|
||||
(
|
||||
"minecraft:can_break",
|
||||
"minecraft:diamond_pickaxe",
|
||||
"{blocks:\"minecraft:stone\",state:{}}",
|
||||
Tag::compound([
|
||||
("blocks", Tag::String("minecraft:stone".into())),
|
||||
("state", Tag::compound(std::iter::empty::<(&str, Tag)>())),
|
||||
]),
|
||||
),
|
||||
] {
|
||||
command(
|
||||
&mut game,
|
||||
&mut host,
|
||||
&format!("item replace entity @s inventory.0 with {item}[{name}={value}]"),
|
||||
);
|
||||
let source: ItemStack = host.hub.guest(2).unwrap().inventory[9].clone().unwrap();
|
||||
assert_eq!(source.item.as_ref(), item);
|
||||
assert_eq!(source.count, 1);
|
||||
assert_eq!(
|
||||
source.data().unwrap().other(name),
|
||||
Some(&expected),
|
||||
"actual registered command adoption"
|
||||
);
|
||||
assert!(source.wire_patch().is_none(), "no cached wire fallback");
|
||||
let received = capture(&mut host);
|
||||
assert_eq!(received.item, id("minecraft:item", item));
|
||||
assert_eq!(received.count, 1);
|
||||
assert!(received.removed.is_empty());
|
||||
let kind = id("minecraft:data_component_type", name);
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&Some(received.clone()), &mut w);
|
||||
let mut r = Reader::new(&w.data);
|
||||
let reread = Slot::read(&mut r).unwrap().unwrap();
|
||||
assert_eq!(r.remaining(), 0, "complete nested slot payload");
|
||||
assert_eq!(reread.component(kind), received.component(kind));
|
||||
if received.component(kind).is_none() {
|
||||
failures.push((name, received));
|
||||
}
|
||||
assert_eq!(host.hub.guest(2).unwrap().uuid, 283);
|
||||
assert_eq!(host.hub.guest(2).unwrap().health, 20.0);
|
||||
assert_eq!(host.hub.guest(2).unwrap().position, [8.5, 200.0, 8.5]);
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"actual command -> network_stack -> full protocol packet missing builtin components: {failures:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,14 @@ use horizon_world::{
|
||||
const RECHECK_TICKS: i64 = 5;
|
||||
|
||||
impl Game<'_> {
|
||||
/// A copper golem opened or closed a chest half: the counter changes and
|
||||
/// A copper golem opened or closed a chest half: the counter changes, the
|
||||
/// first opener posts CONTAINER_OPEN and the last closer CONTAINER_CLOSE
|
||||
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`), and
|
||||
/// the clients see the block event (`openerCountChanged`).
|
||||
pub(super) fn golem_chest_open(&mut self, chest: [i32; 3], open: bool) {
|
||||
pub(super) fn golem_chest_open(&mut self, chest: [i32; 3], open: bool, mob: u32) {
|
||||
let key = (chest[0], chest[1], chest[2]);
|
||||
let entry = self.world.chest_openers.entry(key).or_insert((0, 0.0));
|
||||
let before = entry.0;
|
||||
if open {
|
||||
entry.0 += 1;
|
||||
entry.1 = entry.1.max(3.0);
|
||||
@@ -39,6 +42,12 @@ impl Game<'_> {
|
||||
if count <= 0 {
|
||||
self.world.chest_openers.remove(&key);
|
||||
}
|
||||
let who = Some(horizon_client::mobs::Who::Mob(mob));
|
||||
if open && before == 0 {
|
||||
self.post_block_game_event("minecraft:container_open", chest, who, None);
|
||||
} else if !open && count == 0 {
|
||||
self.post_block_game_event("minecraft:container_close", chest, who, None);
|
||||
}
|
||||
self.block_entity_event(chest, 1, count.max(0));
|
||||
// The guests get the same `ClientboundBlockEventPacket` (the lid).
|
||||
self.lan_block_event(chest, 1, count.max(0));
|
||||
@@ -99,9 +108,11 @@ impl Game<'_> {
|
||||
if count == before {
|
||||
continue;
|
||||
}
|
||||
// `recheckOpeners`: the game event has no source.
|
||||
if count == 0 {
|
||||
self.world.chest_openers.remove(&key);
|
||||
self.golem_chest_sound(p, false);
|
||||
self.post_block_game_event("minecraft:container_close", p, None, None);
|
||||
} else if let Some(o) = self.world.chest_openers.get_mut(&key) {
|
||||
o.0 = count;
|
||||
}
|
||||
|
||||
@@ -2419,13 +2419,13 @@ impl Game<'_> {
|
||||
self.post_game_event(event, at, ctx);
|
||||
}
|
||||
MobEvent::GameEvent {
|
||||
event: "minecraft:block_place",
|
||||
event: event @ ("minecraft:block_place" | "minecraft:block_destroy"),
|
||||
at,
|
||||
source,
|
||||
state,
|
||||
} => {
|
||||
let ctx = self.who_context(Some(Who::Mob(source)), Some(state));
|
||||
self.post_game_event("minecraft:block_place", at, ctx);
|
||||
self.post_game_event(event, at, ctx);
|
||||
}
|
||||
// The sculk layer tracks steps and bee/bat flaps itself
|
||||
// (`entity_motion_events`, `tick_mob_sculk_events`). Birds
|
||||
@@ -2762,9 +2762,10 @@ impl Game<'_> {
|
||||
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
MobEvent::Tamed { .. } | MobEvent::Buck { .. } | MobEvent::StabDismount { .. } => {
|
||||
self.mount_event(&e)
|
||||
}
|
||||
MobEvent::Tamed { .. }
|
||||
| MobEvent::Buck { .. }
|
||||
| MobEvent::StabDismount { .. }
|
||||
| MobEvent::EjectPassengers { .. } => self.mount_event(&e),
|
||||
MobEvent::IronGolemHit {
|
||||
target,
|
||||
amount,
|
||||
@@ -2825,7 +2826,7 @@ impl Game<'_> {
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
}
|
||||
MobEvent::ChestOpen { chest, open } => self.golem_chest_open(chest, open),
|
||||
MobEvent::ChestOpen { chest, open, mob } => self.golem_chest_open(chest, open, mob),
|
||||
MobEvent::ChestSlot { chest, slot, stack } => {
|
||||
self.golem_chest_slot(chest, slot, stack)
|
||||
}
|
||||
@@ -5074,7 +5075,9 @@ impl Game<'_> {
|
||||
let result = self.projectile_hit_inner(p, hit);
|
||||
// LivingEntity.actuallyHurt credits the shooter (a ServerPlayer) with the absorbed damage.
|
||||
self.settle_dealt_absorbed(p.owner == Some(Who::Player));
|
||||
result
|
||||
result?;
|
||||
// The hit's hurt teleports an enderman or a shulker now (`hurtServer`).
|
||||
self.settle_hurt_teleports()
|
||||
}
|
||||
fn projectile_hit_inner(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
|
||||
let _attacker = self.knockback_scope_for(p.owner);
|
||||
@@ -5876,12 +5879,22 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
&& m.damage_projectile(4.0, source, aim, [p.velocity.x, p.velocity.z])
|
||||
{
|
||||
m.add_effect("minecraft:levitation", 200, 0);
|
||||
// Shulker.hurtServer: a shulker hit by a bullet.
|
||||
horizon_client::mobs::shulker::hit_by_bullet(m);
|
||||
let hurt = self.mobs.iter_mut().find(|m| m.id == id).is_some_and(|m| {
|
||||
let hurt =
|
||||
m.damage_projectile(4.0, source, aim, [p.velocity.x, p.velocity.z]);
|
||||
if hurt {
|
||||
// Shulker.hurtServer: a shulker hit by a bullet.
|
||||
horizon_client::mobs::shulker::hit_by_bullet(m);
|
||||
}
|
||||
hurt
|
||||
});
|
||||
if hurt {
|
||||
// The hurt's teleport (`teleportSomewhere`,
|
||||
// `hitByShulkerBullet`) comes before the levitation.
|
||||
self.settle_hurt_teleports()?;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.add_effect("minecraft:levitation", 200, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6217,7 +6230,7 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(sheared) = self.shear_pig_saddle(index, &item) {
|
||||
if let Some(sheared) = self.shear_saddle(index, &item) {
|
||||
if sheared {
|
||||
self.swing_arm();
|
||||
}
|
||||
@@ -7111,6 +7124,8 @@ impl Game<'_> {
|
||||
on_ground,
|
||||
);
|
||||
self.add_projectile(proj);
|
||||
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
|
||||
self.award_stat("used", item, 1);
|
||||
// ItemStack.use's applyAfterUseComponentSideEffects: the pearl's
|
||||
// second is the `use_cooldown` component, as for any thrown item.
|
||||
self.apply_use_cooldown(stack);
|
||||
|
||||
@@ -534,6 +534,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.dismountVehicle` from a live vehicle at `vehicle` whose
|
||||
/// block (`blockPosition`) is in `#minecraft:portals`: the rider leaves by
|
||||
/// the same free-position branch as from a removed vehicle, not beside it.
|
||||
/// True when it did.
|
||||
pub(super) fn dismount_in_portal(&mut self, vehicle: Vec3) -> bool {
|
||||
let pos = [
|
||||
vehicle.x.floor() as i32,
|
||||
vehicle.y.floor() as i32,
|
||||
vehicle.z.floor() as i32,
|
||||
];
|
||||
if !self.block_in_tag("minecraft:portals", &self.block_name(pos)) {
|
||||
return false;
|
||||
}
|
||||
self.dismount_removed_vehicle(vehicle.y);
|
||||
true
|
||||
}
|
||||
|
||||
/// `Entity.positionRider` for the mobs (and other players) sitting on a
|
||||
/// cushion: its passenger attachment `(0, 0.25, 0)`, less the rider's own
|
||||
/// vehicle attachment. A server sends no position for a passenger, so
|
||||
|
||||
@@ -1063,6 +1063,7 @@ impl Game<'_> {
|
||||
// Nothing is kept around the spawn while nobody is on.
|
||||
self.streaming.radius = -1;
|
||||
self.settle_spawn()?;
|
||||
self.prepare_levels()?;
|
||||
let gate_lists = lists.clone();
|
||||
let gate: LoginGate = Arc::new(move |address, uuid, name| {
|
||||
// `ServerActivityMonitor.reportLoginActivity`: a login attempt, however it ends. The management
|
||||
|
||||
@@ -859,6 +859,10 @@ impl Game<'_> {
|
||||
self.world.block_entities.insert(
|
||||
p,
|
||||
BlockEntity::Jukebox(horizon_client::containers::Jukebox {
|
||||
song_clock_present: stack
|
||||
.as_ref()
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
.is_some_and(|i| i.jukebox_song.is_some()),
|
||||
items: [stack],
|
||||
ticks: 0,
|
||||
playing: false,
|
||||
|
||||
@@ -3828,6 +3828,8 @@ impl Game<'_> {
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
sync_position: false,
|
||||
stepped: true,
|
||||
idle: false,
|
||||
bare: false,
|
||||
leash: None,
|
||||
|
||||
@@ -12,19 +12,24 @@ use horizon_world::{
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Enderman.hurtServer` teleports inside the hurt (`teleport()`,
|
||||
/// `repeatedlyTryToTeleport()`). `Mob::damage` has no world, so it leaves
|
||||
/// the tries in `counter2`; a hurt the game layer deals runs them here,
|
||||
/// right after it, not on the enderman's next AI step.
|
||||
/// `repeatedlyTryToTeleport()`), and so does `Shulker.hurtServer`
|
||||
/// (`teleportSomewhere()`, `hitByShulkerBullet()`). `Mob::damage` has no
|
||||
/// world, so it leaves them pending (an enderman's tries in `counter2`, a
|
||||
/// shulker's flags); a hurt the game layer deals runs them here, right
|
||||
/// after it, not on the mob's next AI step.
|
||||
pub(super) fn settle_hurt_teleports(&mut self) -> Result<()> {
|
||||
let pending: Vec<u32> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::Enderman && m.extra.counter2 > 0)
|
||||
.filter(|m| {
|
||||
(m.species() == Species::Enderman && m.extra.counter2 > 0)
|
||||
|| horizon_client::mobs::shulker::teleport_pending(m)
|
||||
})
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
for id in pending {
|
||||
// What `LivingEntity.hurtServer` raised comes first, where the
|
||||
// enderman was hurt and from its random's next draws: the hurt
|
||||
// mob was hurt and from its random's next draws: the hurt
|
||||
// sound, `InfestedMobEffect.onMobHurt`'s silverfish.
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
continue;
|
||||
@@ -33,21 +38,46 @@ impl Game<'_> {
|
||||
if !hurt.is_empty() {
|
||||
self.handle_mob_events(hurt)?;
|
||||
}
|
||||
// The world is read while the enderman moves: take it out, then
|
||||
// put it back where it was (the mob order stays).
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let shulker = self.mobs[i].species() == Species::Shulker;
|
||||
// A shulker's teleport reads the mobs around it (`noCollision`,
|
||||
// the crowding count of `hitByShulkerBullet`), itself included as
|
||||
// in its own tick; an enderman's reads only blocks.
|
||||
let uuid = self.level.player_uuid;
|
||||
let others: Vec<_> = if shulker {
|
||||
self.mobs
|
||||
.iter()
|
||||
.map(|m| super::creatures::seen(m, uuid))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// The world is read while the mob moves: take it out, then put it
|
||||
// back where it was (the mob order stays).
|
||||
let mut mob = self.mobs.swap_remove(i);
|
||||
let mut events = Vec::new();
|
||||
// `hitByShulkerBullet` draws its crowding check from the level's random.
|
||||
let mut level_random = self.world.random.clone();
|
||||
{
|
||||
let world = self.mob_world_base(&[]);
|
||||
horizon_client::mobs::enderman::run_pending_teleports(
|
||||
&mut mob,
|
||||
&world,
|
||||
&mut events,
|
||||
);
|
||||
let world = self.mob_world_base(&others);
|
||||
if shulker {
|
||||
horizon_client::mobs::shulker::run_pending_teleports(
|
||||
&mut mob,
|
||||
&world,
|
||||
&mut level_random,
|
||||
&mut events,
|
||||
);
|
||||
} else {
|
||||
horizon_client::mobs::enderman::run_pending_teleports(
|
||||
&mut mob,
|
||||
&world,
|
||||
&mut events,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.world.random = level_random;
|
||||
self.mobs.push(mob);
|
||||
let last = self.mobs.len() - 1;
|
||||
self.mobs.swap(i, last);
|
||||
@@ -55,7 +85,8 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `EndermanLeaveBlockGoal.tick`: normalize, check, place, then clear.
|
||||
/// `EndermanLeaveBlockGoal.tick`: normalize, check, place, post
|
||||
/// BLOCK_PLACE with the enderman and the placed state, then clear.
|
||||
pub(super) fn enderman_place_block(
|
||||
&mut self,
|
||||
mob: u32,
|
||||
@@ -95,18 +126,25 @@ impl Game<'_> {
|
||||
|| !support.full_collision
|
||||
|| occupants.iter().any(|bounds| bounds.intersects(area))
|
||||
{
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
// AIR is a legitimate normalized state. setBlock may report no
|
||||
// change for it; the eligible operation still empties the hand.
|
||||
b.set_block(level, ticks, at, state, flags::UPDATE_ALL);
|
||||
true
|
||||
Some(state)
|
||||
});
|
||||
if placed
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| {
|
||||
m.id == mob && m.species() == Species::Enderman && m.extra.carried == Some(carried)
|
||||
})
|
||||
{
|
||||
let Some(state) = placed else {
|
||||
return self.process_level_events();
|
||||
};
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_place",
|
||||
pos,
|
||||
Some(horizon_client::mobs::Who::Mob(mob)),
|
||||
Some(state),
|
||||
);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| {
|
||||
m.id == mob && m.species() == Species::Enderman && m.extra.carried == Some(carried)
|
||||
}) {
|
||||
m.extra.carried = None;
|
||||
}
|
||||
self.process_level_events()
|
||||
|
||||
@@ -169,3 +169,119 @@ fn an_infested_endermans_silverfish_come_out_where_it_was_hurt() {
|
||||
assert!(p.distance(at) < 3.0, "silverfish at {p:?}, hurt at {at:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn shulker(game: &Game, id: u32, at: Vec3, seed: i64) -> Mob {
|
||||
let kind = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:shulker")
|
||||
.unwrap()
|
||||
.clone();
|
||||
let mut mob = Mob::new(id, kind, at, 0.0);
|
||||
mob.random = LegacyRandom::new(seed);
|
||||
mob.persistent = true;
|
||||
mob
|
||||
}
|
||||
|
||||
/// `ShulkerBullet.onHitEntity` -> `Shulker.hurtServer`: an open shulker a
|
||||
/// bullet hurts runs `hitByShulkerBullet` (its teleport) inside the hurt, so
|
||||
/// it has moved when the hit returns, and the levitation still lands on it.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_shulker_bullets_hit_teleports_an_open_shulker_before_it_returns() {
|
||||
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind};
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let mut game = floored_game(&root);
|
||||
let at = Vec3::new(8.5, 4.0, 8.5);
|
||||
let mut moved = 0;
|
||||
// On a flat floor one try in 17 finds a block to cling to: some seeds teleport.
|
||||
for seed in 0..40 {
|
||||
let id = 700 + seed as u32;
|
||||
let mut mob = shulker(&game, id, at, seed);
|
||||
// Open: hitByShulkerBullet is for an open shell only.
|
||||
mob.extra.counter = 100;
|
||||
game.mobs.push(mob);
|
||||
let bullet = Projectile::new(
|
||||
ProjectileKind::ShulkerBullet {
|
||||
target: None,
|
||||
dir: None,
|
||||
steps: 0,
|
||||
delta: [0.0, 0.0, 0.0],
|
||||
seed: 0,
|
||||
},
|
||||
Vec3::new(at.x, at.y + 0.5, at.z - 1.0),
|
||||
None,
|
||||
);
|
||||
game.projectile_hit(
|
||||
&bullet,
|
||||
Hit::Entity {
|
||||
who: horizon_client::mobs::Who::Mob(id),
|
||||
at: Vec3::new(at.x, at.y + 0.5, at.z),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let m = game.mobs.iter().find(|m| m.id == id).unwrap();
|
||||
assert!(
|
||||
!m.extra.flag && !m.extra.flag2,
|
||||
"seed {seed}: a teleport left for the AI step"
|
||||
);
|
||||
assert!(
|
||||
m.has_effect("minecraft:levitation"),
|
||||
"seed {seed}: no levitation"
|
||||
);
|
||||
if m.position.distance(at) > 0.5 {
|
||||
moved += 1;
|
||||
}
|
||||
game.mobs.clear();
|
||||
}
|
||||
assert!(moved > 0, "no shulker teleported");
|
||||
}
|
||||
|
||||
/// `Shulker.teleportSomewhere` -> `Entity.unRide`: a mob riding the shulker
|
||||
/// gets off where the shulker stood (`ejectPassengers`) instead of being
|
||||
/// carried to where it teleports.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_teleporting_shulker_leaves_its_rider_behind() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let mut game = floored_game(&root);
|
||||
let at = Vec3::new(8.5, 4.0, 8.5);
|
||||
let mut teleported = 0;
|
||||
for seed in 0..40 {
|
||||
let (id, rider) = (800 + 2 * seed as u32, 801 + 2 * seed as u32);
|
||||
let mut mob = shulker(&game, id, at, seed);
|
||||
// What `after_hurt` leaves after a low-health roll.
|
||||
mob.extra.flag = true;
|
||||
game.mobs.push(mob);
|
||||
let mut zombie = enderman(&game, rider, at, 0);
|
||||
zombie.kind = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:zombie")
|
||||
.unwrap()
|
||||
.clone();
|
||||
game.mobs.push(zombie);
|
||||
horizon_client::mobs::riders::link(&mut game.mobs, rider, id);
|
||||
game.settle_hurt_teleports().unwrap();
|
||||
let s = game.mobs.iter().find(|m| m.id == id).unwrap();
|
||||
let z = game.mobs.iter().find(|m| m.id == rider).unwrap();
|
||||
if s.position.distance(at) > 0.5 {
|
||||
teleported += 1;
|
||||
assert_eq!(z.extra.mount, None, "seed {seed}: still riding");
|
||||
assert_eq!(s.extra.rider, None, "seed {seed}: still carrying");
|
||||
assert!(
|
||||
z.position.distance(at) < 2.0,
|
||||
"seed {seed}: rider at {:?}, shulker left {at:?}",
|
||||
z.position
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
z.extra.mount,
|
||||
Some(id),
|
||||
"seed {seed}: thrown off without a teleport"
|
||||
);
|
||||
}
|
||||
game.mobs.clear();
|
||||
}
|
||||
assert!(teleported > 0, "no shulker teleported");
|
||||
}
|
||||
@@ -1252,11 +1252,8 @@ impl Entity for ExperienceOrb {
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
fn move_by_piston(&mut self, movement: Vec3) {
|
||||
self.position = self.position.add_vector(movement);
|
||||
if movement.y != 0.0 {
|
||||
self.on_ground = movement.y < 0.0;
|
||||
}
|
||||
fn set_piston_on_ground(&mut self, on_ground: bool) {
|
||||
self.on_ground = on_ground;
|
||||
}
|
||||
fn piston_slime_bounce(&mut self, movement: Direction) {
|
||||
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
|
||||
|
||||
@@ -85,6 +85,10 @@ impl Game<'_> {
|
||||
pub(super) fn server_address(&self) -> Option<&str> {
|
||||
self.extras.reconnect.address()
|
||||
}
|
||||
/// The server this game connected to last was found on the local network.
|
||||
pub(super) fn server_is_lan(&self) -> bool {
|
||||
self.extras.reconnect.lan()
|
||||
}
|
||||
/// Auto-reconnect: the server let the player in.
|
||||
pub(super) fn extras_joined(&mut self) {
|
||||
self.extras.reconnect.joined(std::time::Instant::now());
|
||||
|
||||
@@ -307,6 +307,10 @@ impl Game<'_> {
|
||||
let p = self.item_frames[i].pos;
|
||||
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
|
||||
self.frame_output_changed(p);
|
||||
// ItemFrame.interact: BLOCK_CHANGE by the player, after setItem or
|
||||
// setRotation.
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
self.post_game_event("minecraft:block_change", at, ctx);
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
let frame = self.item_frames.iter().find(|f| f.id == entity_id);
|
||||
let item = frame
|
||||
@@ -369,6 +373,10 @@ impl Game<'_> {
|
||||
self.entity_killed_event(at, true);
|
||||
}
|
||||
self.drop_frame_items(&f, with_frame, true);
|
||||
// BLOCK_CHANGE by the attacker after the drops: ItemFrame.hurtServer
|
||||
// for the item, ItemFrame.dropItem(level, causedBy) for the frame.
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
self.post_game_event("minecraft:block_change", at, ctx);
|
||||
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
@@ -478,6 +486,9 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&f, true, false);
|
||||
// ItemFrame.dropItem(level, causedBy): BLOCK_CHANGE after the drops.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:block_change", center(&f), ctx);
|
||||
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
@@ -506,6 +517,9 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&f, true, false);
|
||||
// ItemFrame.dropItem(level, null): BLOCK_CHANGE after the drops.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:block_change", center(&f), ctx);
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
@@ -550,6 +564,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, Self::MOTION_OBJECT | u64::from(f.id));
|
||||
let s = f.step();
|
||||
// Pose: to the block centre, then X/Y rotation by facing.
|
||||
let (x_rot, y_rot) = if s[1] == 0 {
|
||||
@@ -688,6 +703,7 @@ impl Game<'_> {
|
||||
for (stack, id, m, light, cull, kind) in items {
|
||||
let entity_start = out.len();
|
||||
self.mark_entity(entity_start, kind);
|
||||
self.mark_motion(entity_start, Self::MOTION_FRAME_ITEM | u64::from(id));
|
||||
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
|
||||
let state = self.resolve_item(
|
||||
&stack,
|
||||
@@ -707,6 +723,8 @@ impl Game<'_> {
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
}
|
||||
// Maps: keyed by their map id, which several frames can show, so they stay unmarked.
|
||||
self.mark_motion(out.len(), 0);
|
||||
// Nearest first: past the atlas's map slots, the far ones go without.
|
||||
maps.sort_by(|a, b| {
|
||||
let d = |m: &glam::Mat4| {
|
||||
|
||||
@@ -108,8 +108,10 @@ impl Game<'_> {
|
||||
/// `ItemStack.canBreakBlockInAdventureMode` / `canPlaceOnBlockInAdventureMode`
|
||||
/// for the main-hand stack: some predicate of its `can_break` or
|
||||
/// `can_place_on` component (`AdventureModePredicate`: one block predicate
|
||||
/// or a list) matches the block at `pos`. Predicates with `nbt` or
|
||||
/// `components` need the block entity's data and never match.
|
||||
/// or a list) matches the block at `pos`, as `BlockPredicate.matches(BlockInWorld)`
|
||||
/// tests it: the state, then `nbt` against the block entity's
|
||||
/// `saveWithFullMetadata` (no block entity fails). `components` is not
|
||||
/// looked at on this path.
|
||||
fn adventure_predicate(&self, component: &str, pos: [i32; 3]) -> bool {
|
||||
use horizon_world::nbt::Tag;
|
||||
let Some(tag) = self
|
||||
@@ -128,9 +130,6 @@ impl Game<'_> {
|
||||
.state(state)
|
||||
.map(|s| &s.properties);
|
||||
let matches = |p: &Tag| {
|
||||
if p.get("nbt").is_some() || p.get("components").is_some() {
|
||||
return false;
|
||||
}
|
||||
let block_ok = match p.get("blocks") {
|
||||
None => true,
|
||||
Some(Tag::String(id)) => self.block_in_holder_set(&id.to_string_lossy(), name),
|
||||
@@ -169,7 +168,24 @@ impl Game<'_> {
|
||||
}),
|
||||
Some(_) => false,
|
||||
};
|
||||
block_ok && state_ok
|
||||
// NbtPredicate (TagParser.LENIENT_CODEC: SNBT or a compound),
|
||||
// NbtUtils.compareNbt(pattern, data, true).
|
||||
let nbt_ok = || match p.get("nbt") {
|
||||
None => true,
|
||||
Some(Tag::String(snbt)) => horizon_world::nbt::parse_snbt(&snbt.to_string_lossy())
|
||||
.is_ok_and(|(pattern, _)| {
|
||||
horizon_client::nbt_path::compare_nbt(
|
||||
&pattern,
|
||||
self.block_entity_nbt(pos).as_ref(),
|
||||
)
|
||||
}),
|
||||
Some(pattern @ Tag::Compound(_)) => horizon_client::nbt_path::compare_nbt(
|
||||
pattern,
|
||||
self.block_entity_nbt(pos).as_ref(),
|
||||
),
|
||||
Some(_) => false,
|
||||
};
|
||||
block_ok && state_ok && nbt_ok()
|
||||
};
|
||||
match tag {
|
||||
Tag::List(list) => list.iter().any(matches),
|
||||
|
||||
@@ -870,10 +870,11 @@ impl Game<'_> {
|
||||
pending: None,
|
||||
load: LoadPhase::default(),
|
||||
});
|
||||
// MinecraftServer.prepareLevels: no forced chunks to load, so the
|
||||
// stage is over at once; the player's chunks come after.
|
||||
// MinecraftServer.prepareLevels: the saved tickets' chunks load; the
|
||||
// player's chunks come after.
|
||||
let view = &flags.load;
|
||||
view.start(Stage::LoadInitialChunks, 0);
|
||||
startup::time("server.prepare_levels", || self.prepare_levels())?;
|
||||
view.finish(Stage::LoadInitialChunks);
|
||||
let _ = ready.send(Ok((pipe, self.level.name.clone())));
|
||||
startup::time("server.settle_spawn", || self.settle_spawn())
|
||||
|
||||
@@ -99,6 +99,7 @@ impl Game<'_> {
|
||||
pub(super) fn inventory_press(&mut self, right: bool) {
|
||||
use horizon_client::container_screen::Target;
|
||||
use horizon_client::menu::Input;
|
||||
self.direct_craft_stop();
|
||||
if self.mod_browser_press(right) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ impl Game<'_> {
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
j.playing = true;
|
||||
j.song_clock_present = true;
|
||||
j.ticks = 0;
|
||||
}
|
||||
self.set_has_record(pos, true);
|
||||
@@ -99,6 +100,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
j.playing = false;
|
||||
j.song_clock_present = false;
|
||||
j.ticks = 0;
|
||||
}
|
||||
self.stop_song(pos);
|
||||
@@ -119,6 +121,7 @@ impl Game<'_> {
|
||||
let was_playing = j.playing;
|
||||
j.items[0] = stack;
|
||||
j.playing = song.is_some();
|
||||
j.song_clock_present = song.is_some();
|
||||
j.ticks = 0;
|
||||
self.set_has_record(pos, has_record);
|
||||
match song {
|
||||
@@ -173,6 +176,7 @@ impl Game<'_> {
|
||||
items: [Some(record)],
|
||||
ticks: 0,
|
||||
playing: true,
|
||||
song_clock_present: true,
|
||||
}),
|
||||
);
|
||||
self.set_has_record(pos, true);
|
||||
@@ -274,6 +278,7 @@ impl Game<'_> {
|
||||
let record = match self.world.block_entities.get_mut(&key) {
|
||||
Some(BlockEntity::Jukebox(j)) => {
|
||||
j.playing = false;
|
||||
j.song_clock_present = false;
|
||||
j.ticks = 0;
|
||||
j.items[0].take()
|
||||
}
|
||||
@@ -315,6 +320,7 @@ impl Game<'_> {
|
||||
if s.finished(ticks) {
|
||||
if let Some(BlockEntity::Jukebox(j)) = self.world.block_entities.get_mut(&key) {
|
||||
j.playing = false;
|
||||
j.song_clock_present = false;
|
||||
j.ticks = 0;
|
||||
}
|
||||
self.stop_song(pos);
|
||||
@@ -533,3 +539,86 @@ mod jukebox_play_tests {
|
||||
assert!(lines.is_empty(), "{lines:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_song_clock_tests {
|
||||
use crate::game::native_machines::tests::server_game;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn actual_start_tick_stop_and_loaded_record_use_the_modeled_clock() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
assert!(game.run_command("forceload add 0 0 15 15"));
|
||||
assert!(game.world.loaded(4, 4));
|
||||
assert!(game.run_command("setblock 4 199 4 stone"));
|
||||
assert!(game.run_command("setblock 4 200 4 jukebox"));
|
||||
let clock = game.world.game_time;
|
||||
let mut failures = Vec::new();
|
||||
let mut check = |label: &str, tag: &horizon_world::nbt::Tag, want: Option<i64>| {
|
||||
let got = tag.get("ticks_since_song_started").cloned();
|
||||
let want = want.map(horizon_world::nbt::Tag::Long);
|
||||
if got != want {
|
||||
failures.push(format!("{label}: {got:?}, expected {want:?}"));
|
||||
}
|
||||
};
|
||||
let pos = [4, 200, 4];
|
||||
check(
|
||||
"actual empty Game writer",
|
||||
&game.block_entity_nbt(pos).unwrap(),
|
||||
None,
|
||||
);
|
||||
assert!(game.run_command("item replace block 4 200 4 container.0 with music_disc_cat[minecraft:custom_data={h032:1}]"));
|
||||
let j = game.jukebox(pos).unwrap();
|
||||
assert!(j.playing);
|
||||
assert_eq!(j.ticks, 0);
|
||||
assert_eq!(j.record().unwrap().count, 1);
|
||||
check(
|
||||
"actual start keeps zero Long",
|
||||
&game.block_entity_nbt(pos).unwrap(),
|
||||
Some(0),
|
||||
);
|
||||
game.tick_jukebox((4, 200, 4));
|
||||
assert_eq!(game.jukebox(pos).unwrap().ticks, 1);
|
||||
check(
|
||||
"actual tick keeps sole counter",
|
||||
&game.block_entity_nbt(pos).unwrap(),
|
||||
Some(1),
|
||||
);
|
||||
let before = game
|
||||
.block_entity_nbt(pos)
|
||||
.unwrap()
|
||||
.get("RecordItem")
|
||||
.cloned();
|
||||
assert!(before.is_some());
|
||||
game.jukebox_set_item(pos, None);
|
||||
let j = game.jukebox(pos).unwrap();
|
||||
assert!(!j.playing);
|
||||
assert_eq!(j.ticks, 0);
|
||||
assert!(j.record().is_none());
|
||||
let saved = game.block_entity_nbt(pos).unwrap();
|
||||
assert!(saved.get("RecordItem").is_none());
|
||||
check("actual stop removes clock", &saved, None);
|
||||
assert!(game.run_command("setblock 8 199 4 stone"));
|
||||
assert!(game.run_command("setblock 8 200 4 jukebox[has_record=true]{RecordItem:{id:\"minecraft:music_disc_cat\",count:1,components:{\"minecraft:custom_data\":{h032:1}}}}"));
|
||||
let j = game.jukebox([8, 200, 4]).unwrap();
|
||||
assert!(j.playing, "preserve legacy record-only adoption");
|
||||
assert_eq!(j.ticks, 0);
|
||||
let saved = game.block_entity_nbt([8, 200, 4]).unwrap();
|
||||
assert_eq!(
|
||||
saved.get("RecordItem"),
|
||||
before.as_ref(),
|
||||
"actual record/components preserved"
|
||||
);
|
||||
check("actual loaded record without clock", &saved, None);
|
||||
assert_eq!(
|
||||
game.world.game_time, clock,
|
||||
"no scene ticks in this caller test; registered runtime progress is separately paired"
|
||||
);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"actual Game saved song-clock mismatches: {failures:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1971,6 +1971,8 @@ impl Game<'_> {
|
||||
e.on_ground = place.on_ground;
|
||||
e.velocity = place.velocity;
|
||||
e.needs_sync = m.needs_sync;
|
||||
e.sync_position = m.sync_position;
|
||||
e.stepped = true;
|
||||
(e.idle, e.bare) = (idle, bare);
|
||||
e.leash = leash;
|
||||
if bare {
|
||||
@@ -2118,6 +2120,19 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// PlayerList.placeNewPlayer: the name the user cache knew,
|
||||
// before it learns this one, for a renamed player's join line.
|
||||
let mut renamed = None;
|
||||
if self.options.dedicated.is_some()
|
||||
&& let Some((uuid, name)) =
|
||||
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
|
||||
&& let Some(old) = super::dedicated::cached_user_by_id(uuid)
|
||||
{
|
||||
if !old.name.eq_ignore_ascii_case(&name) {
|
||||
renamed = Some(old.name.clone());
|
||||
}
|
||||
host.hub.former_names.insert(uuid, old.name);
|
||||
}
|
||||
self.lan_guest_joined(&mut host, conn, entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
&& let Some(g) = host.hub.guest(conn)
|
||||
@@ -2149,6 +2164,15 @@ impl Game<'_> {
|
||||
"INFO",
|
||||
&horizon_client::lan::arrivals::held_line(&g.name),
|
||||
);
|
||||
} else if let Some(old) = renamed {
|
||||
let line = Text::translate(
|
||||
"multiplayer.player.joined.renamed",
|
||||
&[Text::literal(g.name.clone()), Text::literal(old)],
|
||||
);
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
&format!("System chat: {}", line.plain()),
|
||||
);
|
||||
} else {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
@@ -2663,8 +2687,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// ServerEntity.sendChanges clears needsSync once it has looked.
|
||||
// SteppedInterpolationTracker.updateTracking clears syncPosition.
|
||||
for m in &mut self.mobs {
|
||||
m.needs_sync = false;
|
||||
m.sync_position = false;
|
||||
}
|
||||
for p in &mut self.creatures.projectiles {
|
||||
p.needs_sync = false;
|
||||
|
||||
@@ -51,6 +51,8 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
sync_position: false,
|
||||
stepped: false,
|
||||
idle: false,
|
||||
bare: false,
|
||||
leash: None,
|
||||
|
||||
@@ -649,7 +649,10 @@ impl Game<'_> {
|
||||
{
|
||||
let _ = p.ignite_for_ticks((sweep.ignite * 20.0) as i32);
|
||||
}
|
||||
// The swept player's thorns, dealt to the attacker below.
|
||||
return g.thorns_damage();
|
||||
}
|
||||
Vec::new()
|
||||
};
|
||||
for (victim, id, team) in victims {
|
||||
if sweep.skip == Some(id)
|
||||
@@ -660,10 +663,33 @@ impl Game<'_> {
|
||||
}
|
||||
let hit = self.pvp_hit_of(host, sweep.attacker, victim, "sweep");
|
||||
let Victim::Guest(conn) = victim;
|
||||
self.guest_struck_by(host, conn, |g| {
|
||||
let _hit = pvp_scope(hit);
|
||||
land(g)
|
||||
});
|
||||
let thorns = self
|
||||
.guest_struck_by(host, conn, |g| {
|
||||
let _hit = pvp_scope(hit);
|
||||
land(g)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// doPostAttackEffects' victim side: its armour's thorns hurt the
|
||||
// attacker, as on a direct hit (pvp_land).
|
||||
let thorns_cause = self.player_attacker(host, victim.conn());
|
||||
for damage in thorns {
|
||||
match sweep.attacker {
|
||||
None => {
|
||||
if let Some((id, name)) = þs_cause {
|
||||
self.note_named_attacker("minecraft:thorns", *id, name.clone());
|
||||
}
|
||||
self.hurt_player_by("minecraft:thorns", damage);
|
||||
}
|
||||
Some(conn) => {
|
||||
self.guest_hit_by(host, conn, |g| {
|
||||
if let Some((id, name)) = þs_cause {
|
||||
g.note_named_attacker("minecraft:thorns", *id, name.clone());
|
||||
}
|
||||
g.hurt_player_by("minecraft:thorns", damage)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,39 @@ fn write_mob_leash_tag(
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// BlockAttachedEntity.tick / LeashFenceKnotEntity.survives for explicitly loaded knots.
|
||||
pub(super) fn tick_explicit_knots(&mut self) {
|
||||
let ticking = self.entity_ticking_area();
|
||||
let world = &self.world;
|
||||
let facts = self.res.facts();
|
||||
let fences = self.res.items().block_tags.get("minecraft:fences");
|
||||
let mut untied = Vec::new();
|
||||
self.others.retain_mut(|knot| {
|
||||
let Some(check) = knot.knot_check.as_mut() else {
|
||||
return true;
|
||||
};
|
||||
let at = horizon_core::position::BlockPos::containing(knot.position);
|
||||
if !ticking(at.x, at.z) || !world.chunks.contains_key(&(at.x >> 4, at.z >> 4)) {
|
||||
return true;
|
||||
}
|
||||
// Vanilla compares the old postincrement value: first check is tick 101.
|
||||
if *check >= 100 {
|
||||
*check = 0;
|
||||
let state = facts.get(world.block(at.x, at.y, at.z));
|
||||
if !fences.is_some_and(|f| f.contains(&*state.name)) {
|
||||
untied.push(knot.position.components());
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
*check += 1;
|
||||
}
|
||||
true
|
||||
});
|
||||
// LeashFenceKnotEntity.dropItem plays LEAD_UNTIED; it drops no lead item.
|
||||
for at in untied {
|
||||
self.lead_sound("untied", at);
|
||||
}
|
||||
}
|
||||
pub(super) fn lead_sound(&mut self, sound: &str, at: [f64; 3]) {
|
||||
self.audio
|
||||
.play(&format!("minecraft:item.lead.{sound}"), Some(at), 1.0, 1.0);
|
||||
|
||||
@@ -298,6 +298,65 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `MinecraftServer.prepareLevels`: before the first tick, the chunks the
|
||||
/// open levels' restored tickets hold (forced chunks, a native mod's) are
|
||||
/// loaded (`ChunkLoadCounter`). The first tick then finds them ticking
|
||||
/// and unpacks their saved block and fluid ticks at the game time they
|
||||
/// were saved at (`startTickingChunk`), not at whichever tick they finish
|
||||
/// streaming in.
|
||||
pub(super) fn prepare_levels(&mut self) -> Result<()> {
|
||||
if self.options.execution_role.is_client() {
|
||||
return Ok(());
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(600);
|
||||
self.wait_for_ticket_chunks(deadline);
|
||||
let back = self.dim;
|
||||
for dim in [Dim::Overworld, Dim::Nether, Dim::End] {
|
||||
if dim != back && self.levels.parked.contains_key(&dim) {
|
||||
self.enter_level(None, dim)?;
|
||||
self.wait_for_ticket_chunks(deadline);
|
||||
}
|
||||
}
|
||||
if self.dim != back {
|
||||
self.enter_level(None, back)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stream the level in place until each chunk its tickets make block
|
||||
/// ticking is a ticking chunk (lit, with its neighbours loaded).
|
||||
fn wait_for_ticket_chunks(&mut self, deadline: Instant) {
|
||||
// A `PORTAL` ticket reaches block ticking two chunks out, a forced one one.
|
||||
const REACH: i32 = 2;
|
||||
loop {
|
||||
self.update_chunk_tickets();
|
||||
let levels = self.tickets.levels();
|
||||
let streaming = &self.streaming;
|
||||
let ready = streaming
|
||||
.forced
|
||||
.iter()
|
||||
.chain(&streaming.mod_forced)
|
||||
.copied()
|
||||
.chain(self.tickets.portal_chunks())
|
||||
.all(|(cx, cz)| {
|
||||
(cz - REACH..=cz + REACH).all(|z| {
|
||||
(cx - REACH..=cx + REACH).all(|x| {
|
||||
!levels.loaded_ticking(x, z) || super::ticking_chunk(&self.world, x, z)
|
||||
})
|
||||
})
|
||||
});
|
||||
if ready {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
log::warn!("Horizon: the chunks of the saved tickets did not load");
|
||||
return;
|
||||
}
|
||||
self.update_world();
|
||||
self.streaming.wait(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange the level in place with `p`.
|
||||
fn swap_level(&mut self, p: &mut Parked) {
|
||||
use std::mem::swap;
|
||||
|
||||
@@ -89,7 +89,9 @@ pub(super) fn build_renderer(
|
||||
pack.item_renderer
|
||||
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
|
||||
renderer.set_lines_shader(atlas.lines_shader.as_deref());
|
||||
renderer.set_sky_shaders(atlas.sky_shader.as_deref(), atlas.stars_shader.as_deref());
|
||||
renderer.set_custom_sky(&pack.custom_sky);
|
||||
renderer.set_shared_material_maps(&atlas.material_maps);
|
||||
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
|
||||
renderer
|
||||
.hud
|
||||
|
||||
@@ -1997,6 +1997,9 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.riding = None;
|
||||
if self.dismount_in_portal(self.carts[i].position) {
|
||||
return;
|
||||
}
|
||||
let c = &self.carts[i];
|
||||
let (bx, by, bz) = (
|
||||
c.position.x.floor() as i32,
|
||||
@@ -2688,6 +2691,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, Self::MOTION_OBJECT | u64::from(c.id));
|
||||
// The per-entity jitter (offsetSeed) that keeps stacked carts from z-fighting.
|
||||
let seed = i64::from(c.id).wrapping_mul(493_286_711);
|
||||
let seed = seed
|
||||
|
||||
@@ -138,12 +138,28 @@ impl Game<'_> {
|
||||
let id = self.riding?;
|
||||
self.mobs.iter().position(|m| m.id == id)
|
||||
}
|
||||
/// `Entity.interact` / `Mob.canShearEquipment` / `Mob.shearItem`:
|
||||
/// the pig's ordinary saddle comes off before Pig.mobInteract can ride it.
|
||||
/// An occupied pig consumes no equipment interaction and cannot be mounted.
|
||||
pub(super) fn shear_pig_saddle(&mut self, index: usize, item: &str) -> Option<bool> {
|
||||
/// `Entity.interact` / `Mob.canShearEquipment` / `Mob.attemptToShearEquipment`
|
||||
/// / `Mob.shearItem`: the saddle of a `#can_equip_saddle` mob comes off
|
||||
/// before its mobInteract can ride it, with the saddle's shearing sound.
|
||||
/// An occupied mount is not sheared: its mobInteract runs instead.
|
||||
pub(super) fn shear_saddle(&mut self, index: usize, item: &str) -> Option<bool> {
|
||||
let m = &self.mobs[index];
|
||||
if item != "minecraft:shears" || m.species() != Species::Pig || m.is_baby() || !m.alive() {
|
||||
// EntityTypeTags.CAN_EQUIP_SADDLE.
|
||||
let saddleable = matches!(
|
||||
m.species(),
|
||||
Species::Horse
|
||||
| Species::SkeletonHorse
|
||||
| Species::ZombieHorse
|
||||
| Species::Donkey
|
||||
| Species::Mule
|
||||
| Species::Pig
|
||||
| Species::Strider
|
||||
| Species::Camel
|
||||
| Species::CamelHusk
|
||||
| Species::Nautilus
|
||||
| Species::ZombieNautilus
|
||||
);
|
||||
if item != "minecraft:shears" || !saddleable || m.is_baby() || !m.alive() {
|
||||
return None;
|
||||
}
|
||||
let id = m.id;
|
||||
@@ -155,15 +171,28 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.any(|r| r.alive() && !r.removed && r.extra.mount == Some(id))
|
||||
{
|
||||
return Some(false);
|
||||
// !canShearEquipment (isVehicle): the click goes on to mobInteract. A
|
||||
// ridden pig or strider has nothing more to give, but a camel takes a
|
||||
// second rider and a horse or nautilus opens its inventory.
|
||||
return matches!(m.species(), Species::Pig | Species::Strider).then_some(false);
|
||||
}
|
||||
if self.keys.held(Action::Sneak) {
|
||||
return None;
|
||||
}
|
||||
// attemptToShearEquipment takes the body slot before the saddle: horse
|
||||
// and nautilus armor come off first (not modelled here).
|
||||
if m.extra
|
||||
.body
|
||||
.as_deref()
|
||||
.is_some_and(|b| b.ends_with("_horse_armor") || b.ends_with("_nautilus_armor"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let saddle = m.saddle_item()?;
|
||||
if &*saddle.item != "minecraft:saddle" {
|
||||
return None;
|
||||
}
|
||||
let silent = m.silent;
|
||||
let at = m.position;
|
||||
let offset = horizon_client::mobs::riders::passenger_attachment(m)[1];
|
||||
self.damage_held_tool();
|
||||
@@ -174,6 +203,15 @@ impl Game<'_> {
|
||||
self.spawn_at_location(at, offset, saddle.clone());
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity("minecraft:player_sheared_equipment", &saddle, &target);
|
||||
// attemptToShearEquipment: playSound(equippable.shearingSound()).
|
||||
if !silent {
|
||||
self.audio.play(
|
||||
"minecraft:item.saddle.unequip",
|
||||
Some([at.x, at.y, at.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
/// `Pig` / `Strider.mobInteract`: saddle an adult, or ride a saddled one.
|
||||
@@ -668,6 +706,9 @@ impl Game<'_> {
|
||||
height: m.height(),
|
||||
yaw: m.yaw,
|
||||
};
|
||||
if self.dismount_in_portal(mount.position) {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
@@ -759,6 +800,46 @@ impl Game<'_> {
|
||||
// RunAroundLikeCrazyGoal.tick: entity event 6, smoke.
|
||||
self.mob_event_particles(mob, 6);
|
||||
}
|
||||
// `Entity.ejectPassengers` before the mob moved off `from`: each
|
||||
// rider's `dismountVehicle` reads the vehicle where it stood.
|
||||
MobEvent::EjectPassengers { mob, from } => {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
|
||||
return;
|
||||
};
|
||||
let moved = (self.mobs[i].position, self.mobs[i].previous);
|
||||
self.mobs[i].position = from;
|
||||
self.mobs[i].previous = from;
|
||||
if self.riding == Some(mob) {
|
||||
self.stop_riding();
|
||||
}
|
||||
let guests: Vec<u32> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.vehicle == Some(mob))
|
||||
.map(|g| g.conn)
|
||||
.collect()
|
||||
});
|
||||
for conn in guests {
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
self.as_guest(&mut host, conn, |g| g.stop_riding());
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
let riders: Vec<u32> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(mob))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
for rider in riders {
|
||||
self.dismount_mob(rider);
|
||||
}
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
(m.position, m.previous) = moved;
|
||||
m.extra.rider = None;
|
||||
}
|
||||
}
|
||||
// `LivingEntity.stabAttack`: `target.stopRiding()`.
|
||||
MobEvent::StabDismount { target } => match target {
|
||||
Who::Player => self.stop_riding(),
|
||||
|
||||
@@ -32,6 +32,9 @@ impl Game<'_> {
|
||||
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
|
||||
continue;
|
||||
};
|
||||
// Its own motion run, by the cell it moves to (fixed while it moves), for a shader
|
||||
// pack's `at_velocity`; a finished one keeps that key until the chunk mesh takes over.
|
||||
self.mark_motion(out.len(), super::motion_at(Self::MOTION_MOVING_BLOCK, p));
|
||||
let step = piston.movement_direction().normal();
|
||||
let at = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
|
||||
// `createMovingBlock`'s block position (seed, tint and light): the
|
||||
@@ -54,6 +57,7 @@ impl Game<'_> {
|
||||
self.moving_block_vertices(base, at, corner - camera, out);
|
||||
}
|
||||
}
|
||||
self.mark_motion(out.len(), 0);
|
||||
}
|
||||
/// The section holding the block at `p` (`SectionPos.blockToSection`).
|
||||
pub(super) fn section_of(&self, p: [i32; 3]) -> Option<(i32, i32, i32)> {
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(super) struct Pads {
|
||||
virtual_input: bool,
|
||||
/// Buttons a `pad ... tap` releases at the next poll.
|
||||
taps: Vec<Button>,
|
||||
edges: Edges,
|
||||
pub(super) edges: Edges,
|
||||
activity: gamepad::Activity,
|
||||
context: Option<Context>,
|
||||
blocked_sticks: [bool; 2],
|
||||
@@ -215,6 +215,7 @@ impl Pads {
|
||||
if self.feed_source != source {
|
||||
self.feed_source = source;
|
||||
self.menu.armed = None;
|
||||
self.menu.stop_craft();
|
||||
}
|
||||
self.feed = Some(state);
|
||||
}
|
||||
@@ -231,6 +232,7 @@ impl Pads {
|
||||
/// A replay ended: let go of everything it held.
|
||||
pub(super) fn clear_injected(&mut self) {
|
||||
self.menu.armed = None;
|
||||
self.menu.stop_craft();
|
||||
self.injected = PadState::default();
|
||||
}
|
||||
|
||||
@@ -397,8 +399,11 @@ impl Game<'_> {
|
||||
|
||||
/// End held actions and captures before another context receives input.
|
||||
fn pad_cancel_inputs(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
// Before the releases below: a cancelled press must not click.
|
||||
// Before the releases below: a cancelled press must not click, nor a cancelled trigger
|
||||
// change the recipe book's category as it comes up.
|
||||
self.pad.menu.armed = None;
|
||||
self.pad.menu.trigger = None;
|
||||
self.direct_craft_stop();
|
||||
let held_sticks = [
|
||||
gamepad::radial(self.pad.state.left, self.settings.controller.move_deadzone)
|
||||
!= [0.0; 2],
|
||||
@@ -798,6 +803,7 @@ impl Game<'_> {
|
||||
}
|
||||
"release" => {
|
||||
self.pad.menu.armed = None;
|
||||
self.direct_craft_stop();
|
||||
self.pad.injected = PadState::default();
|
||||
self.pad.taps.clear();
|
||||
Ok(json!({ "ok": true }))
|
||||
|
||||
@@ -122,7 +122,7 @@ impl Game<'_> {
|
||||
Under::Nothing
|
||||
} else if self.recipe_under_pointer().is_some() {
|
||||
Under::Recipe {
|
||||
direct_craft: false,
|
||||
direct_craft: self.direct_craft_usable(),
|
||||
alternatives: self.recipe_has_alternatives_under_pointer(),
|
||||
}
|
||||
} else if self.recipe_book_under_pointer() {
|
||||
|
||||
@@ -264,6 +264,7 @@ impl Pads {
|
||||
// Another pad, or none: its A going away isn't a release.
|
||||
if self.devices.active != active {
|
||||
self.menu.armed = None;
|
||||
self.menu.stop_craft();
|
||||
}
|
||||
let Some(pad) = self
|
||||
.devices
|
||||
|
||||
@@ -0,0 +1,912 @@
|
||||
//! Crafting a recipe straight into the inventory with a controller (Horizon's own;
|
||||
//! docs/controllers.md): LT + A crafts once, and with both still held past the repeat delay it
|
||||
//! goes on until either is let go, the items run out or the inventory is full.
|
||||
//!
|
||||
//! Each craft is what a player does by hand with the recipe book, one request at a time: the
|
||||
//! recipe placed (`ServerboundPlaceRecipePacket`), its result quick-moved
|
||||
//! (`ServerboundContainerClickPacket`), and whatever stays in the grid, remainders included,
|
||||
//! quick-moved back. The next request waits for the server's answer to the last one, nothing is
|
||||
//! predicted, so the items are always the server's.
|
||||
//!
|
||||
//! The book's numbers are worked out here, and a server with recipes of its own can number them
|
||||
//! differently, so a placement may make something else. A result that isn't the recipe's stops the
|
||||
//! craft with nothing moved: the grid and the result stay as the click on the recipe left them,
|
||||
//! for the player to take or put back by hand.
|
||||
//!
|
||||
//! No answer in time ends the craft at once and nothing more goes out: a quick move can't return
|
||||
//! what a late answer has yet to place, and a server that isn't answering would leave it waiting
|
||||
//! for ever. So what the server placed stays in the grid. The player sees it there, and the way
|
||||
//! out is what it always is: closing the screen returns the grid, and the next LT + A returns it
|
||||
//! before it places anything.
|
||||
//!
|
||||
//! Vanilla's quick move throws what doesn't fit on the ground (`CraftingMenu.quickMoveStack`'s
|
||||
//! `player.drop`), so a result only moves with room for all of it as the client last heard the
|
||||
//! inventory. A full inventory stops the craft with the ingredients back where they came from, or
|
||||
//! in the grid if they can't go back. The server's inventory can be ahead of that: a pickup the
|
||||
//! client hasn't heard of yet can fill the last room, and then the server's quick move drops the
|
||||
//! rest of the result, as a vanilla shift-click on the result does.
|
||||
//!
|
||||
//! Requests to any server but the window's own (single player) keep their distance: placements
|
||||
//! `PLACE_GAP` apart, under Paper's limit on them, and on someone else's server every request
|
||||
//! `REQUEST_GAP` apart as well.
|
||||
//!
|
||||
//! However a craft ends while the server can still answer (a button let go, another press but
|
||||
//! LT + A on its recipe, which asks for one more, the pad gone, focus lost), it ends one way: no new craft starts, the step in flight finishes, and
|
||||
//! what's in the grid goes back ([`DirectCraft::stop`]).
|
||||
use super::Game;
|
||||
use horizon_client::{gamepad, items::ItemStack};
|
||||
use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// How long a request waits for the server's answer before the craft ends.
|
||||
const ANSWER_WAIT: Duration = Duration::from_secs(2);
|
||||
/// The gap between any two requests to someone else's server: one a game tick (50 ms) at most,
|
||||
/// so a held craft never sends faster than the game ticks.
|
||||
const REQUEST_GAP: Duration = Duration::from_millis(50);
|
||||
/// The gap between two placements on any server but the window's own. Paper's default
|
||||
/// `packet-limiter` drops a `ServerboundPlaceRecipePacket` beyond 5 a second averaged over 4
|
||||
/// seconds (`GlobalConfiguration.PacketLimiter`'s override for it: interval 4.0, max-packet-rate
|
||||
/// 5.0, action DROP); a dropped one would go unanswered and end the craft. One every 250 ms is 4 a
|
||||
/// second, under it.
|
||||
const PLACE_GAP: Duration = Duration::from_millis(250);
|
||||
/// The most crafts one craft makes: a full inventory of stacks, one item a craft.
|
||||
const MOST_CRAFTS: u32 = 36 * 64;
|
||||
|
||||
/// What the last request is waiting to see.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Answer {
|
||||
/// The recipe placed: a result shows.
|
||||
Placed,
|
||||
/// The result moved: the slot is empty and the inventory holds this many of the item.
|
||||
Moved { item: Arc<str>, total: u32 },
|
||||
/// This many items were in this grid cell: fewer are now.
|
||||
Returned { cell: usize, count: u32 },
|
||||
/// The grid is empty: the old result goes.
|
||||
NoResult,
|
||||
}
|
||||
|
||||
/// The next thing to do.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Step {
|
||||
Wait,
|
||||
Stop,
|
||||
Place,
|
||||
MoveResult,
|
||||
Return(usize),
|
||||
}
|
||||
|
||||
/// The open menu as the client last heard it from the server.
|
||||
struct Seen<'a> {
|
||||
grid: &'a [Option<ItemStack>],
|
||||
result: Option<&'a ItemStack>,
|
||||
/// What the recipe makes (`Entry::results`).
|
||||
recipe_results: &'a [ItemStack],
|
||||
inventory: &'a [Option<ItemStack>],
|
||||
craftable: bool,
|
||||
max_stack: &'a dyn Fn(&ItemStack) -> u32,
|
||||
}
|
||||
|
||||
impl Seen<'_> {
|
||||
/// `result` is what the recipe makes. Item and count only: a recipe can pass its input's
|
||||
/// components on to its result (`TransmuteRecipe`), so those can differ from the book's.
|
||||
fn makes(&self, result: &ItemStack) -> bool {
|
||||
self.recipe_results
|
||||
.iter()
|
||||
.any(|made| made.item == result.item && made.count == result.count)
|
||||
}
|
||||
fn count_of(&self, item: &str) -> u32 {
|
||||
self.inventory
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|s| &*s.item == item)
|
||||
.map(|s| s.count)
|
||||
.sum()
|
||||
}
|
||||
/// How many of `stack` fit in the inventory (`moveItemStackTo`).
|
||||
fn room_for(&self, stack: &ItemStack) -> u32 {
|
||||
let max = (self.max_stack)(stack);
|
||||
self.inventory
|
||||
.iter()
|
||||
.map(|slot| match slot {
|
||||
None => max,
|
||||
Some(s) if s.same_item_same_components(stack) => max.saturating_sub(s.count),
|
||||
Some(_) => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// A crafting book's recipe by what stays the same when the book is rebuilt: its recipe id, and
|
||||
/// its display id among the entries that share the id. Its index in the book can move.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct RecipeKey {
|
||||
pub(super) id: String,
|
||||
pub(super) display: i32,
|
||||
}
|
||||
|
||||
/// A craft under way.
|
||||
pub(super) struct DirectCraft {
|
||||
recipe: RecipeKey,
|
||||
/// The window it started in: another one ends it.
|
||||
menu: i32,
|
||||
/// The grid's size it started with: another one ends it.
|
||||
cells: usize,
|
||||
/// The crafts the presses asked for, and the crafts made.
|
||||
asked: u32,
|
||||
done: u32,
|
||||
/// Both buttons are still down from the press: past `repeat_at` it keeps crafting.
|
||||
holding: bool,
|
||||
repeat_at: Instant,
|
||||
/// The grid holds the recipe this craft placed, so its result is the craft's to take.
|
||||
placed: bool,
|
||||
answer: Option<(Answer, Instant)>,
|
||||
next_request: Instant,
|
||||
/// The gap kept after every request: `REQUEST_GAP` on someone else's server, else none.
|
||||
request_gap: Duration,
|
||||
next_place: Instant,
|
||||
/// The gap kept after every placement: `PLACE_GAP` on any server but the window's own.
|
||||
place_gap: Duration,
|
||||
}
|
||||
|
||||
impl DirectCraft {
|
||||
fn new(recipe: RecipeKey, menu: i32, cells: usize, repeat_at: Instant, now: Instant) -> Self {
|
||||
Self {
|
||||
recipe,
|
||||
menu,
|
||||
cells,
|
||||
asked: 1,
|
||||
done: 0,
|
||||
holding: true,
|
||||
repeat_at,
|
||||
placed: false,
|
||||
answer: None,
|
||||
next_request: now,
|
||||
request_gap: Duration::ZERO,
|
||||
next_place: now,
|
||||
place_gap: Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// A button of the press came up: what was asked for still gets made, and no more.
|
||||
fn let_go(&mut self) {
|
||||
self.holding = false;
|
||||
}
|
||||
|
||||
/// The end of a craft while the server can still answer: no new craft starts, the one in
|
||||
/// flight finishes, and `step` returns the grid before it says `Stop`.
|
||||
pub(super) fn stop(&mut self) {
|
||||
self.holding = false;
|
||||
let in_flight = self.placed || matches!(self.answer, Some((Answer::Placed, _)));
|
||||
self.asked = self.done + u32::from(in_flight);
|
||||
}
|
||||
|
||||
/// The server said the placement it was asked for can't be made: nothing's in flight.
|
||||
fn refused(&mut self) {
|
||||
if matches!(self.answer, Some((Answer::Placed, _))) {
|
||||
self.answer = None;
|
||||
}
|
||||
self.stop();
|
||||
}
|
||||
|
||||
/// LT + A on this recipe again before the last has finished: one more, on the same footing as
|
||||
/// the first, and still one request at a time.
|
||||
fn again(&mut self, repeat_at: Instant) {
|
||||
self.asked = self.asked.saturating_add(1);
|
||||
self.holding = true;
|
||||
self.repeat_at = repeat_at;
|
||||
}
|
||||
|
||||
/// The next step, and the gaps it leaves before the next request and placement.
|
||||
fn step(&mut self, seen: &Seen, now: Instant) -> Step {
|
||||
let step = self.next_step(seen, now);
|
||||
if step != Step::Wait {
|
||||
self.next_request = now + self.request_gap;
|
||||
}
|
||||
if step == Step::Place {
|
||||
self.next_place = now + self.place_gap;
|
||||
}
|
||||
step
|
||||
}
|
||||
|
||||
fn next_step(&mut self, seen: &Seen, now: Instant) -> Step {
|
||||
if let Some((answer, deadline)) = &self.answer {
|
||||
let answered = match answer {
|
||||
Answer::Placed => seen.result.is_some(),
|
||||
Answer::Moved { item, total } => {
|
||||
seen.result.is_none() && seen.count_of(item) >= *total
|
||||
}
|
||||
Answer::Returned { cell, count } => {
|
||||
let left = seen.grid.get(*cell).and_then(Option::as_ref);
|
||||
left.map_or(0, |s| s.count) < *count
|
||||
}
|
||||
Answer::NoResult => seen.result.is_none(),
|
||||
};
|
||||
if !answered {
|
||||
return if now >= *deadline {
|
||||
Step::Stop
|
||||
} else {
|
||||
Step::Wait
|
||||
};
|
||||
}
|
||||
match answer {
|
||||
Answer::Placed => self.placed = true,
|
||||
Answer::Moved { .. } => {
|
||||
self.placed = false;
|
||||
self.done += 1;
|
||||
}
|
||||
Answer::Returned { .. } | Answer::NoResult => {}
|
||||
}
|
||||
self.answer = None;
|
||||
}
|
||||
if now < self.next_request {
|
||||
return Step::Wait;
|
||||
}
|
||||
let deadline = now + ANSWER_WAIT;
|
||||
if self.placed {
|
||||
let Some(result) = seen.result else {
|
||||
return Step::Stop;
|
||||
};
|
||||
if !seen.makes(result) {
|
||||
return Step::Stop;
|
||||
}
|
||||
if seen.room_for(result) >= result.count {
|
||||
let total = seen.count_of(&result.item) + result.count;
|
||||
let item = result.item.clone();
|
||||
self.answer = Some((Answer::Moved { item, total }, deadline));
|
||||
return Step::MoveResult;
|
||||
}
|
||||
// No room: what was placed goes back, and that's the end of it.
|
||||
self.placed = false;
|
||||
self.stop();
|
||||
}
|
||||
// What's in the grid leaves first (a quick move drops nothing), so the place below can't
|
||||
// send it to `clearGrid`, which a creative player's menu may throw away.
|
||||
if let Some((cell, stack)) = seen
|
||||
.grid
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, s)| s.as_ref().map(|s| (i, s)))
|
||||
{
|
||||
if seen.room_for(stack) == 0 {
|
||||
return Step::Stop;
|
||||
}
|
||||
let count = stack.count;
|
||||
self.answer = Some((Answer::Returned { cell, count }, deadline));
|
||||
return Step::Return(cell);
|
||||
}
|
||||
if seen.result.is_some() {
|
||||
self.answer = Some((Answer::NoResult, deadline));
|
||||
return Step::Wait;
|
||||
}
|
||||
let asked = self.done < self.asked;
|
||||
if (!asked && !self.holding) || self.done >= MOST_CRAFTS || !seen.craftable {
|
||||
return Step::Stop;
|
||||
}
|
||||
if (!asked && now < self.repeat_at) || now < self.next_place {
|
||||
return Step::Wait;
|
||||
}
|
||||
self.answer = Some((Answer::Placed, deadline));
|
||||
Step::Place
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A server direct crafting needs no say of the player's on: one found through LAN discovery
|
||||
/// (the server list's announced worlds, `server_is_lan`), kept through a reconnect, or a split
|
||||
/// guest's link by memory to the host's world on this window (`"local"`). One reached by
|
||||
/// address isn't, even on this computer or the local network: an address can be a proxy, a
|
||||
/// tunnel or a friend's relay as well.
|
||||
fn direct_craft_free_server(&self) -> bool {
|
||||
self.server_is_lan()
|
||||
|| self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.session.address == "local")
|
||||
}
|
||||
|
||||
/// Someone else's server: neither single player's own nor a free one
|
||||
/// (`direct_craft_free_server`). Every request keeps its distance there, and crafting waits for
|
||||
/// the player's say (`craft_on_servers`).
|
||||
fn direct_craft_elsewhere(&self) -> bool {
|
||||
self.integrated.is_none() && !self.direct_craft_free_server()
|
||||
}
|
||||
|
||||
/// Whether direct crafting is allowed where the player is: only through a server, as every
|
||||
/// world is played (its quick moves are the server's), and on someone else's only if the
|
||||
/// player allowed it (`craft_on_servers`).
|
||||
fn direct_craft_allowed(&self) -> bool {
|
||||
self.remote.is_some()
|
||||
&& (!self.direct_craft_elsewhere() || self.settings.controller.craft_on_servers)
|
||||
}
|
||||
|
||||
/// The pad can direct-craft here, with nothing carried: the inventory's guide says so on a
|
||||
/// recipe. Whether the recipe can be made isn't checked, as that counts the inventory.
|
||||
pub(super) fn direct_craft_usable(&self) -> bool {
|
||||
self.pad.in_use
|
||||
&& self.carried.is_none()
|
||||
&& self.mod_recipe_screen()
|
||||
&& self.direct_craft_allowed()
|
||||
}
|
||||
|
||||
/// This press is another LT + A on the running craft's recipe: it asks for one more craft
|
||||
/// rather than ending the craft.
|
||||
pub(super) fn direct_craft_takes_press(&self, button: gamepad::Button) -> bool {
|
||||
button == gamepad::Button::South
|
||||
&& self.pad.edges.is_held(gamepad::Button::LeftTrigger)
|
||||
&& self.screen == super::Screen::Inventory
|
||||
&& self.title.is_none()
|
||||
&& !self.prompt_open()
|
||||
&& self.pad.menu.craft.as_ref().is_some_and(|craft| {
|
||||
self.recipe_under_pointer()
|
||||
.and_then(|entry| self.recipe_stable_key(entry))
|
||||
.is_some_and(|key| key == craft.recipe)
|
||||
})
|
||||
}
|
||||
|
||||
/// LT + A on a recipe: whether it started a craft, or asked the running one for another.
|
||||
/// Refused with an item carried, on a book other than crafting, when the recipe can't be made,
|
||||
/// where it isn't allowed, or while another recipe's craft is still finishing (one request at
|
||||
/// a time); a refused press is plain A's.
|
||||
pub(super) fn direct_craft_start(&mut self, entry: usize) -> bool {
|
||||
let now = Instant::now();
|
||||
let repeat_delay = self.settings.controller.repeat_delay;
|
||||
let repeat_at = now + Duration::from_secs_f32(repeat_delay);
|
||||
let Some(recipe) = self.recipe_stable_key(entry) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(craft) = &mut self.pad.menu.craft {
|
||||
if craft.recipe != recipe {
|
||||
return false;
|
||||
}
|
||||
craft.again(repeat_at);
|
||||
return true;
|
||||
}
|
||||
if self.carried.is_some()
|
||||
|| !self.mod_recipe_screen()
|
||||
|| !self.direct_craft_allowed()
|
||||
|| !self.recipe_craftable(entry)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let menu = self.remote_menu().0;
|
||||
let cells = self.craft_size * self.craft_size;
|
||||
let mut craft = DirectCraft::new(recipe, menu, cells, repeat_at, now);
|
||||
if self.direct_craft_elsewhere() {
|
||||
craft.request_gap = REQUEST_GAP;
|
||||
}
|
||||
if self.integrated.is_none() {
|
||||
craft.place_gap = PLACE_GAP;
|
||||
}
|
||||
self.pad.menu.craft = Some(craft);
|
||||
true
|
||||
}
|
||||
|
||||
/// Anything that ends a running craft while the server can still answer; a click or key
|
||||
/// behind it goes on to the screen.
|
||||
pub(super) fn direct_craft_stop(&mut self) {
|
||||
self.pad.menu.stop_craft();
|
||||
}
|
||||
|
||||
/// The server said a placement can't be made.
|
||||
pub(super) fn direct_craft_refused(&mut self) {
|
||||
if let Some(craft) = &mut self.pad.menu.craft {
|
||||
craft.refused();
|
||||
}
|
||||
}
|
||||
|
||||
/// Once per poll: the running craft's next step, when the last one has been answered.
|
||||
pub(super) fn direct_craft_poll(&mut self) {
|
||||
let Some(mut craft) = self.pad.menu.craft.take() else {
|
||||
return;
|
||||
};
|
||||
let cells = self.craft_size * self.craft_size;
|
||||
// The screen went, or isn't the one the craft's grid was on.
|
||||
if !self.mod_recipe_screen() || self.remote_menu().0 != craft.menu || cells != craft.cells {
|
||||
return;
|
||||
}
|
||||
// Something came into the hand: a quick move still takes the grid back.
|
||||
if self.carried.is_some() {
|
||||
craft.stop();
|
||||
}
|
||||
if !self.pad.edges.is_held(gamepad::Button::South)
|
||||
|| !self.pad.edges.is_held(gamepad::Button::LeftTrigger)
|
||||
{
|
||||
craft.let_go();
|
||||
}
|
||||
// The book was rebuilt without the recipe: what's in the grid still goes back, and a
|
||||
// result can't be told for the recipe's, so it stays.
|
||||
let entry = self.recipe_entry_index(&craft.recipe);
|
||||
if entry.is_none() {
|
||||
craft.stop();
|
||||
}
|
||||
let craftable = entry.is_some_and(|entry| self.recipe_craftable(entry));
|
||||
let result = self.slot_view(200);
|
||||
let now = Instant::now();
|
||||
let step = {
|
||||
// Room counted too high would drop part of a result.
|
||||
let max_stack = |s: &ItemStack| self.stack_max_size(s);
|
||||
craft.step(
|
||||
&Seen {
|
||||
grid: &self.craft_grid[..cells],
|
||||
result: result.as_ref(),
|
||||
recipe_results: entry.map_or(&[], |entry| self.recipe_results(entry)),
|
||||
inventory: &self.inventory[..36],
|
||||
craftable,
|
||||
max_stack: &max_stack,
|
||||
},
|
||||
now,
|
||||
)
|
||||
};
|
||||
match (step, entry) {
|
||||
(Step::Wait, _) => {}
|
||||
(Step::Place, Some(entry)) => self.recipe_place_once(entry),
|
||||
(Step::Stop, _) | (Step::Place, None) => return,
|
||||
// Only ever started with a server (`direct_craft_allowed`): its quick moves are the
|
||||
// server's, unpredicted.
|
||||
(Step::MoveResult, _) => {
|
||||
self.remote_quick_move_unpredicted(200);
|
||||
}
|
||||
(Step::Return(cell), _) => {
|
||||
self.remote_quick_move_unpredicted(100 + cell);
|
||||
}
|
||||
}
|
||||
self.pad.menu.craft = Some(craft);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stack(item: &str, count: u32) -> Option<ItemStack> {
|
||||
Some(ItemStack::new(item, count))
|
||||
}
|
||||
|
||||
/// A server's menu, as `Seen` shows it to the craft.
|
||||
struct Menu {
|
||||
grid: Vec<Option<ItemStack>>,
|
||||
result: Option<ItemStack>,
|
||||
recipe_results: Vec<ItemStack>,
|
||||
inventory: Vec<Option<ItemStack>>,
|
||||
craftable: bool,
|
||||
}
|
||||
|
||||
impl Menu {
|
||||
fn new(inventory: Vec<Option<ItemStack>>) -> Self {
|
||||
Self {
|
||||
grid: vec![None; 4],
|
||||
result: None,
|
||||
// Every result the tests' crafts make.
|
||||
recipe_results: vec![
|
||||
ItemStack::new("minecraft:oak_planks", 4),
|
||||
ItemStack::new("minecraft:stick", 4),
|
||||
ItemStack::new("minecraft:cake", 1),
|
||||
],
|
||||
inventory,
|
||||
craftable: true,
|
||||
}
|
||||
}
|
||||
fn step(&self, craft: &mut DirectCraft, now: Instant) -> Step {
|
||||
let max_stack = |s: &ItemStack| {
|
||||
if &*s.item == "minecraft:bucket" {
|
||||
16
|
||||
} else {
|
||||
64
|
||||
}
|
||||
};
|
||||
craft.step(
|
||||
&Seen {
|
||||
grid: &self.grid,
|
||||
result: self.result.as_ref(),
|
||||
recipe_results: &self.recipe_results,
|
||||
inventory: &self.inventory,
|
||||
craftable: self.craftable,
|
||||
max_stack: &max_stack,
|
||||
},
|
||||
now,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A press that came up at once: it asks for one craft.
|
||||
fn tap(now: Instant) -> DirectCraft {
|
||||
let mut craft = held(now, now);
|
||||
craft.let_go();
|
||||
craft
|
||||
}
|
||||
|
||||
/// A press still down, going on from `repeat_at`.
|
||||
fn held(repeat_at: Instant, now: Instant) -> DirectCraft {
|
||||
let recipe = RecipeKey {
|
||||
id: "minecraft:stick".into(),
|
||||
display: 0,
|
||||
};
|
||||
DirectCraft::new(recipe, 0, 4, repeat_at, now)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_goes_out_until_the_server_answers() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![stack("minecraft:oak_log", 1), None]);
|
||||
let mut craft = tap(now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Wait);
|
||||
menu.inventory[0] = None;
|
||||
menu.grid[0] = stack("minecraft:oak_log", 1);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Wait);
|
||||
menu.result = stack("minecraft:oak_planks", 4);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Wait);
|
||||
// The result went, but the slots holding it haven't come in yet.
|
||||
menu.grid[0] = None;
|
||||
menu.result = None;
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Wait);
|
||||
menu.inventory[0] = stack("minecraft:oak_planks", 4);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_answer_ends_the_craft_without_asking_again() {
|
||||
let now = Instant::now();
|
||||
let menu = Menu::new(vec![stack("minecraft:oak_log", 1)]);
|
||||
let mut craft = held(now, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
assert_eq!(menu.step(&mut craft, now + ANSWER_WAIT / 2), Step::Wait);
|
||||
assert_eq!(menu.step(&mut craft, now + ANSWER_WAIT), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_craft_that_times_out_with_a_full_grid_sends_nothing_and_the_next_press_clears_it() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = held(now, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
// The ingredients came, the result never did: no return is asked of a silent server.
|
||||
menu.grid[0] = stack("minecraft:oak_log", 1);
|
||||
assert_eq!(menu.step(&mut craft, now + ANSWER_WAIT), Step::Stop);
|
||||
// The way out: another LT + A takes the grid back before it places anything.
|
||||
let later = now + ANSWER_WAIT;
|
||||
let mut next = tap(later);
|
||||
assert_eq!(menu.step(&mut next, later), Step::Return(0));
|
||||
menu.grid[0] = None;
|
||||
menu.inventory[0] = stack("minecraft:oak_log", 1);
|
||||
assert_eq!(menu.step(&mut next, later), Step::Place);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_that_is_not_the_recipes_stops_the_craft_with_nothing_moved() {
|
||||
let now = Instant::now();
|
||||
// Another item, then the recipe's item in another count: neither is what was asked for.
|
||||
for other in [
|
||||
ItemStack::new("minecraft:diamond", 1),
|
||||
ItemStack::new("minecraft:oak_planks", 2),
|
||||
] {
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = held(now, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
menu.grid[0] = stack("minecraft:oak_log", 1);
|
||||
menu.result = Some(other);
|
||||
// A stop, neither `MoveResult` nor a `Return`: the grid is the player's to see to.
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_with_the_placement_in_flight_still_moves_nothing_unexpected() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = held(now, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
craft.stop();
|
||||
menu.grid[0] = stack("minecraft:oak_log", 1);
|
||||
menu.result = stack("minecraft:diamond", 1);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_of_a_recipes_results_is_its_own() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
// A recipe of many results, as a tipped arrow's: each potion is the same recipe.
|
||||
menu.recipe_results = vec![
|
||||
ItemStack::new("minecraft:tipped_arrow", 8),
|
||||
ItemStack::new("minecraft:spectral_arrow", 8),
|
||||
];
|
||||
let mut craft = tap(now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
menu.result = stack("minecraft:spectral_arrow", 8);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hold_goes_on_until_the_items_run_out() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = held(now, now);
|
||||
for done in 1..=3 {
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
menu.result = stack("minecraft:stick", 4);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
|
||||
menu.result = None;
|
||||
menu.inventory[0] = stack("minecraft:stick", 4 * done);
|
||||
}
|
||||
menu.craftable = false;
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_without_room_for_all_of_it_never_moves() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![
|
||||
stack("minecraft:oak_planks", 62),
|
||||
stack("minecraft:oak_log", 2),
|
||||
]);
|
||||
let mut craft = held(now, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
menu.inventory[1] = stack("minecraft:oak_log", 1);
|
||||
menu.grid[0] = stack("minecraft:oak_log", 1);
|
||||
menu.result = stack("minecraft:oak_planks", 4);
|
||||
// Room for two of the four: the log goes back, and the craft ends there.
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Return(0));
|
||||
menu.grid[0] = None;
|
||||
menu.result = None;
|
||||
menu.inventory[1] = stack("minecraft:oak_log", 2);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_inventory_leaves_the_remainder_in_the_grid() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![stack("minecraft:dirt", 64)]);
|
||||
let mut craft = held(now, now);
|
||||
menu.grid[2] = stack("minecraft:bucket", 1);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remainder_goes_back_before_the_next_placement() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![None, None]);
|
||||
let mut craft = held(now, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
menu.result = stack("minecraft:cake", 1);
|
||||
menu.grid[1] = stack("minecraft:milk_bucket", 1);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
|
||||
menu.result = None;
|
||||
menu.grid[1] = stack("minecraft:bucket", 1);
|
||||
menu.inventory[0] = stack("minecraft:cake", 1);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Return(1));
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Wait);
|
||||
menu.grid[1] = None;
|
||||
menu.inventory[1] = stack("minecraft:bucket", 1);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_player_left_in_the_grid_goes_back_rather_than_being_crafted() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![None]);
|
||||
menu.grid[0] = stack("minecraft:oak_log", 1);
|
||||
menu.result = stack("minecraft:oak_planks", 4);
|
||||
let mut craft = tap(now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Return(0));
|
||||
menu.grid[0] = None;
|
||||
menu.inventory[0] = stack("minecraft:oak_log", 1);
|
||||
// The old result hasn't gone yet: no placement over it.
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Wait);
|
||||
menu.result = None;
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
}
|
||||
|
||||
/// One craft from its placement to its result in the inventory, `total` of them by then.
|
||||
fn make_one(menu: &mut Menu, craft: &mut DirectCraft, total: u32, now: Instant) {
|
||||
assert_eq!(menu.step(craft, now), Step::Place);
|
||||
menu.result = stack("minecraft:stick", 4);
|
||||
assert_eq!(menu.step(craft, now), Step::MoveResult);
|
||||
menu.result = None;
|
||||
menu.inventory[0] = stack("minecraft:stick", total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tap_makes_one_and_a_hold_waits_for_the_delay() {
|
||||
let now = Instant::now();
|
||||
let later = now + Duration::from_millis(350);
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = tap(now);
|
||||
make_one(&mut menu, &mut craft, 4, now);
|
||||
assert_eq!(menu.step(&mut craft, later), Step::Stop);
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = held(later, now);
|
||||
make_one(&mut menu, &mut craft, 4, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Wait);
|
||||
assert_eq!(menu.step(&mut craft, later), Step::Place);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letting_go_before_the_delay_ends_the_wait_at_once() {
|
||||
let now = Instant::now();
|
||||
let later = now + Duration::from_secs(60);
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = held(later, now);
|
||||
make_one(&mut menu, &mut craft, 4, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Wait);
|
||||
craft.let_go();
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letting_go_finishes_the_craft_in_flight_and_empties_the_grid() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![None; 3]);
|
||||
let mut craft = held(now, now);
|
||||
make_one(&mut menu, &mut craft, 4, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
// Let go with the placement unanswered.
|
||||
craft.let_go();
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Wait);
|
||||
menu.result = stack("minecraft:cake", 1);
|
||||
menu.grid[1] = stack("minecraft:milk_bucket", 1);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
|
||||
menu.result = None;
|
||||
menu.grid[1] = stack("minecraft:bucket", 1);
|
||||
menu.inventory[1] = stack("minecraft:cake", 1);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Return(1));
|
||||
menu.grid[1] = None;
|
||||
menu.inventory[2] = stack("minecraft:bucket", 1);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_before_anything_went_out_sends_nothing() {
|
||||
let now = Instant::now();
|
||||
let menu = Menu::new(vec![None]);
|
||||
let mut craft = tap(now);
|
||||
craft.stop();
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_keeps_only_the_craft_in_flight() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = held(now, now);
|
||||
make_one(&mut menu, &mut craft, 4, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
craft.stop();
|
||||
menu.result = stack("minecraft:stick", 4);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
|
||||
craft.stop();
|
||||
menu.result = None;
|
||||
menu.inventory[0] = stack("minecraft:stick", 8);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_placement_ends_it_with_the_grid_returned() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = held(now, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
craft.refused();
|
||||
menu.grid[0] = stack("minecraft:oak_log", 1);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Return(0));
|
||||
menu.grid[0] = None;
|
||||
menu.inventory[0] = stack("minecraft:oak_log", 1);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_press_of_the_recipe_waits_its_turn() {
|
||||
let now = Instant::now();
|
||||
let later = now + Duration::from_secs(60);
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = tap(now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
craft.stop();
|
||||
craft.again(later);
|
||||
craft.let_go();
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Wait);
|
||||
menu.result = stack("minecraft:stick", 4);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
|
||||
menu.result = None;
|
||||
menu.inventory[0] = stack("minecraft:stick", 4);
|
||||
// Asked for, so it needs no delay.
|
||||
make_one(&mut menu, &mut craft, 8, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_taps_while_one_is_in_flight_each_make_a_craft() {
|
||||
let now = Instant::now();
|
||||
let later = now + Duration::from_secs(60);
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = tap(now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
// Two more taps of LT + A on the recipe, both up again before the first craft answers.
|
||||
for _ in 0..2 {
|
||||
craft.again(later);
|
||||
craft.let_go();
|
||||
}
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Wait);
|
||||
menu.result = stack("minecraft:stick", 4);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
|
||||
menu.result = None;
|
||||
menu.inventory[0] = stack("minecraft:stick", 4);
|
||||
make_one(&mut menu, &mut craft, 8, now);
|
||||
make_one(&mut menu, &mut craft, 12, now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placements_keep_their_gap_while_the_other_requests_do_not() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = held(now, now);
|
||||
craft.place_gap = PLACE_GAP;
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
menu.result = stack("minecraft:stick", 4);
|
||||
// The result moves as soon as it shows.
|
||||
assert_eq!(menu.step(&mut craft, now), Step::MoveResult);
|
||||
menu.result = None;
|
||||
menu.inventory[0] = stack("minecraft:stick", 4);
|
||||
// The next placement waits out the gap from the last.
|
||||
assert_eq!(menu.step(&mut craft, now + PLACE_GAP / 2), Step::Wait);
|
||||
assert_eq!(menu.step(&mut craft, now + PLACE_GAP), Step::Place);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_request_keeps_its_gap_when_asked_to() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![None; 2]);
|
||||
let mut craft = held(now, now);
|
||||
craft.request_gap = REQUEST_GAP;
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
menu.result = stack("minecraft:stick", 4);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Wait);
|
||||
assert_eq!(menu.step(&mut craft, now + REQUEST_GAP), Step::MoveResult);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grid_that_shrank_is_no_panic() {
|
||||
let now = Instant::now();
|
||||
let mut menu = Menu::new(vec![None]);
|
||||
menu.grid[3] = stack("minecraft:oak_log", 2);
|
||||
let mut craft = tap(now);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Return(3));
|
||||
menu.grid.truncate(1);
|
||||
assert_eq!(menu.step(&mut craft, now), Step::Place);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_counts_only_stacks_that_can_take_it() {
|
||||
let inventory = vec![
|
||||
stack("minecraft:stick", 60),
|
||||
stack("minecraft:bucket", 16),
|
||||
None,
|
||||
];
|
||||
let max_stack = |s: &ItemStack| {
|
||||
if &*s.item == "minecraft:bucket" {
|
||||
16
|
||||
} else {
|
||||
64
|
||||
}
|
||||
};
|
||||
let seen = Seen {
|
||||
grid: &[],
|
||||
result: None,
|
||||
recipe_results: &[],
|
||||
inventory: &inventory,
|
||||
craftable: true,
|
||||
max_stack: &max_stack,
|
||||
};
|
||||
assert_eq!(seen.room_for(&ItemStack::new("minecraft:stick", 1)), 68);
|
||||
assert_eq!(seen.room_for(&ItemStack::new("minecraft:bucket", 1)), 16);
|
||||
let damaged = ItemStack {
|
||||
damage: 3,
|
||||
..ItemStack::new("minecraft:stick", 1)
|
||||
};
|
||||
assert_eq!(seen.room_for(&damaged), 64);
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,11 @@ pub(super) struct MenuCursor {
|
||||
repeats: u32,
|
||||
/// The on-screen keyboard, open over a text field.
|
||||
pub(super) osk: Option<Keyboard>,
|
||||
/// The recipe being crafted straight into the inventory (`pad_craft`).
|
||||
pub(super) craft: Option<super::pad_craft::DirectCraft>,
|
||||
/// LT held over the crafting book: it changes the category as it comes up, unless A was
|
||||
/// pressed meanwhile (LT + A crafts, `pad_craft`).
|
||||
pub(super) trigger: Option<HeldTrigger>,
|
||||
/// The keyboard was closed on this screen (it doesn't open by itself again).
|
||||
osk_dismissed: bool,
|
||||
/// The keyboard drives the focus (`key_focus`: Tab, the arrows, Enter):
|
||||
@@ -197,7 +202,21 @@ pub(super) struct MenuCursor {
|
||||
prompt_crosshair: Option<(Screen, (f32, f32))>,
|
||||
}
|
||||
|
||||
/// LT held over the crafting book.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct HeldTrigger {
|
||||
/// A was pressed while it was held: it was LT + A, not a category change.
|
||||
pressed_a: bool,
|
||||
}
|
||||
|
||||
impl MenuCursor {
|
||||
/// A running direct craft ends, as cleanly as the server still lets it (`DirectCraft::stop`).
|
||||
pub(super) fn stop_craft(&mut self) {
|
||||
if let Some(craft) = &mut self.craft {
|
||||
craft.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// `Screen.clearFocus`: nothing is focused any more, and no click focus
|
||||
/// is kept for the next Tab (`ComponentPath.applyFocus(false)`).
|
||||
fn clear_focus(&mut self) {
|
||||
@@ -738,6 +757,12 @@ impl Game<'_> {
|
||||
) -> Result<()> {
|
||||
if !down {
|
||||
match button {
|
||||
Button::LeftTrigger if self.pad.menu.trigger.is_some() => {
|
||||
let pressed_a = self.pad.menu.trigger.take().is_some_and(|t| t.pressed_a);
|
||||
if !pressed_a && self.pad_recipe_book_showing() {
|
||||
self.pad_tab(event_loop, false);
|
||||
}
|
||||
}
|
||||
Button::South if self.pad.menu.armed.is_some() => {
|
||||
let armed = self.pad.menu.armed.take();
|
||||
if let Some(at) = armed_click(armed, self.pad.menu.focus) {
|
||||
@@ -757,6 +782,17 @@ impl Game<'_> {
|
||||
}
|
||||
// The pad takes the focus back from the keyboard.
|
||||
self.pad.menu.keyboard = false;
|
||||
// A with LT held over the crafting book is LT + A: LT coming up changes no category.
|
||||
if button == Button::South
|
||||
&& let Some(trigger) = &mut self.pad.menu.trigger
|
||||
{
|
||||
trigger.pressed_a = true;
|
||||
}
|
||||
// Any press ends a running craft but another LT + A on its recipe, which asks for one
|
||||
// more further down: quick taps while one is in flight each make a craft.
|
||||
if !self.direct_craft_takes_press(button) {
|
||||
self.direct_craft_stop();
|
||||
}
|
||||
// A prompt over the screen answers first; no press reaches both.
|
||||
let prompt = self.prompt_open();
|
||||
// Bindings waiting for a button: this one (Start cancels).
|
||||
@@ -852,6 +888,21 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// LT + A on a recipe crafts it into the inventory.
|
||||
if button == Button::South
|
||||
&& !prompt
|
||||
&& self.pad.edges.is_held(Button::LeftTrigger)
|
||||
&& self.screen == Screen::Inventory
|
||||
&& self.title.is_none()
|
||||
{
|
||||
self.pad_take_pointer(event_loop);
|
||||
// Refused, the press is plain A's.
|
||||
if let Some(entry) = self.recipe_under_pointer()
|
||||
&& self.direct_craft_start(entry)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// The book's controls take A alone; X, Y and the throws are for slots and recipes.
|
||||
let book_control = !prompt
|
||||
&& self.screen == Screen::Inventory
|
||||
@@ -938,6 +989,15 @@ impl Game<'_> {
|
||||
Button::LeftBumper | Button::RightBumper if self.pad_recipe_book_showing() => {
|
||||
self.pad_recipe_page(button == Button::RightBumper);
|
||||
}
|
||||
// On the crafting book LT waits for its release, as LT + A crafts there; RT, and
|
||||
// either on a furnace's book, change the category as they go down.
|
||||
Button::LeftTrigger
|
||||
if self.pad_recipe_book_showing()
|
||||
&& self.active_book()
|
||||
== Some(horizon_client::recipe_book::BookType::Crafting) =>
|
||||
{
|
||||
self.pad.menu.trigger = Some(HeldTrigger { pressed_a: false });
|
||||
}
|
||||
Button::LeftTrigger | Button::RightTrigger if self.pad_recipe_book_showing() => {
|
||||
self.pad_tab(event_loop, button == Button::RightTrigger);
|
||||
}
|
||||
@@ -1373,11 +1433,13 @@ impl Game<'_> {
|
||||
menu.focus = None;
|
||||
menu.pending = None;
|
||||
menu.stick_scrolled = None;
|
||||
menu.trigger = None;
|
||||
menu.anchors = None;
|
||||
menu.layout = None;
|
||||
menu.nudge = None;
|
||||
menu.osk = None;
|
||||
menu.osk_dismissed = false;
|
||||
menu.craft = None;
|
||||
menu.focus_name = None;
|
||||
menu.prompt = None;
|
||||
menu.prompt_restart = None;
|
||||
@@ -1386,6 +1448,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.pad.menu.open_polls = self.pad.menu.open_polls.saturating_add(1);
|
||||
self.direct_craft_poll();
|
||||
// Once the focus or the screen moved on from a slider it's let go for good, or coming back
|
||||
// to it would find it still held.
|
||||
if !self.pad_holding_slider() {
|
||||
@@ -1871,6 +1934,8 @@ impl Game<'_> {
|
||||
"holding_slider": self.pad_holding_slider(),
|
||||
"pointer": self.pad.menu.pos.map(|_| self.cursor),
|
||||
"keyboard": self.pad.menu.osk.is_some(),
|
||||
"crafting": self.pad.menu.craft.is_some(),
|
||||
"craft_hint": self.direct_craft_usable(),
|
||||
"targets": self.pad_targets().iter().map(target).collect::<Vec<_>>(),
|
||||
"actions": self.pad_actions_status(),
|
||||
"glyphs": self.pad_glyphs_status(),
|
||||
@@ -1898,6 +1963,7 @@ impl Game<'_> {
|
||||
self.pad.menu.stick = None;
|
||||
self.pad.menu.wheel = [0.0; 2];
|
||||
self.pad.menu.moving = false;
|
||||
self.pad.menu.trigger = None;
|
||||
self.pad.menu.glide = None;
|
||||
self.pad.menu.gesture = None;
|
||||
self.pad.menu.pending = None;
|
||||
|
||||
@@ -444,6 +444,7 @@ impl Game<'_> {
|
||||
1.0 / 32.0,
|
||||
);
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, Self::MOTION_OBJECT | u64::from(p.id));
|
||||
let light = self.world.light(p.pos[0], p.pos[1], p.pos[2]);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let mut quad =
|
||||
|
||||
@@ -1060,12 +1060,19 @@ impl Game<'_> {
|
||||
/// An entity of a type Horizon does not simulate, kept as its compound.
|
||||
fn load_other(&mut self, mut tag: Tag, kind: &str) -> Option<u32> {
|
||||
strip_finalize(&mut tag);
|
||||
let position = entity_nbt::position(&tag)?;
|
||||
let mut position = entity_nbt::position(&tag)?;
|
||||
let item_transformation = if kind == "minecraft:item_display" {
|
||||
super::native_entities::ItemDisplayTransformation::from_tag(tag.get("transformation"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let knot_check = (kind == "minecraft:leash_knot").then_some(0);
|
||||
if knot_check.is_some() {
|
||||
// BlockAttachedEntity.setPos -> LeashFenceKnotEntity.recalculateBoundingBox.
|
||||
let block = BlockPos::containing(position);
|
||||
position =
|
||||
horizon_client::mobs::leash::knot_state([block.x, block.y, block.z]).position;
|
||||
}
|
||||
let rotation = match tag.get("Rotation") {
|
||||
Some(Tag::List(l)) => {
|
||||
let f = |i: usize| match l.get(i) {
|
||||
@@ -1087,6 +1094,7 @@ impl Game<'_> {
|
||||
yaw: rotation[0],
|
||||
pitch: rotation[1],
|
||||
item_transformation,
|
||||
knot_check,
|
||||
});
|
||||
Some(id)
|
||||
}
|
||||
@@ -1129,9 +1137,12 @@ impl Game<'_> {
|
||||
c.entry("response".into()).or_insert(Tag::Byte(0));
|
||||
}
|
||||
}
|
||||
// A marker is saved as `Entity.saveWithoutId` writes it: its common
|
||||
// fields are there even when the loaded compound omitted them.
|
||||
if matches!(o.kind.as_str(), "minecraft:marker" | "marker") {
|
||||
// Marker and LeashFenceKnotEntity still use Entity.saveWithoutId's
|
||||
// common fields when their additional-save methods write nothing.
|
||||
if matches!(
|
||||
o.kind.as_str(),
|
||||
"minecraft:marker" | "marker" | "minecraft:leash_knot"
|
||||
) {
|
||||
entity_nbt::fill_common_defaults(&mut tag);
|
||||
}
|
||||
tag
|
||||
@@ -1629,6 +1640,23 @@ impl Game<'_> {
|
||||
.push(self.fangs_to_nbt(f, uuid));
|
||||
}
|
||||
}
|
||||
// `AreaEffectCloud.addAdditionalSaveData`: a lingering potion's cloud is
|
||||
// an entity of its chunk. A dragon's own breath carries no potion and
|
||||
// is not written.
|
||||
for c in &self.breath_clouds {
|
||||
let at = Vec3::new(c.at[0], c.at[1], c.at[2]);
|
||||
if c.potion.is_none() || !keys.contains(&chunk_of(at)) {
|
||||
continue;
|
||||
}
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&c.id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let tag = super::potions::potion_cloud_to_nbt(c, uuid, self.persist.raw.get(&c.id));
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
for o in &self.others {
|
||||
if keys.contains(&chunk_of(o.position)) {
|
||||
let uuid = self
|
||||
@@ -1727,6 +1755,15 @@ impl Game<'_> {
|
||||
}
|
||||
keep
|
||||
});
|
||||
// The potion clouds written above leave with their chunk; a
|
||||
// dragon's breath (no potion) was not written and stays.
|
||||
self.breath_clouds.retain(|c| {
|
||||
let keep = c.potion.is_none() || !gone(Vec3::new(c.at[0], c.at[1], c.at[2]));
|
||||
if !keep {
|
||||
removed.push(c.id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
// Riders leave with their root vehicle, a boat's with the boat.
|
||||
let leaving: HashSet<u32> = (0..self.mobs.len())
|
||||
.filter(|&i| {
|
||||
@@ -2724,6 +2761,74 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A lingering potion's cloud is an entity of its chunk: unloading the chunk writes it and
|
||||
/// takes it out of the level, so loading the chunk again gives back one cloud, not two.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_lingering_cloud_leaves_with_its_chunk() {
|
||||
use super::super::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!("no menu handoff"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let cloud = Tag::compound([
|
||||
("id", Tag::String("minecraft:area_effect_cloud".into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(4.5f64.to_bits()),
|
||||
Tag::Double(64.0f64.to_bits()),
|
||||
Tag::Double(4.5f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("UUID", Tag::IntArray(vec![5, 6, 7, 8])),
|
||||
("Duration", Tag::Int(600)),
|
||||
(
|
||||
"potion_contents",
|
||||
Tag::compound([("potion", Tag::String("minecraft:poison".into()))]),
|
||||
),
|
||||
]);
|
||||
let clouds = |game: &Game| {
|
||||
game.breath_clouds
|
||||
.iter()
|
||||
.filter(|c| c.potion.is_some())
|
||||
.count()
|
||||
};
|
||||
game.world.loaded_entities.push(((0, 0), vec![cloud]));
|
||||
game.spawn_loaded_entities();
|
||||
assert_eq!(clouds(&game), 1, "the cloud loads");
|
||||
let unloaded = game.entity_chunks(&HashSet::from([(0, 0)]), true);
|
||||
let written: Vec<Tag> = unloaded.into_iter().flat_map(|(_, tags)| tags).collect();
|
||||
assert_eq!(written.len(), 1, "the unload writes the cloud");
|
||||
assert_eq!(clouds(&game), 0, "and takes it out of the level");
|
||||
game.world.loaded_entities.push(((0, 0), written));
|
||||
game.spawn_loaded_entities();
|
||||
assert_eq!(clouds(&game), 1, "loading the chunk again gives one cloud");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_rider_saves_the_horse() {
|
||||
let horse = 7;
|
||||
@@ -3551,6 +3656,288 @@ mod item_display_saved_transform_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod summoned_knot_tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, commands::Target, load_resources};
|
||||
use horizon_client::{chunk::Chunk, motion::PlayerMotion};
|
||||
use horizon_core::{geometry::Vec3, position::ChunkPos, random::LegacyRandom};
|
||||
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
|
||||
.join(format!("h129-{:?}", std::thread::current().id())),
|
||||
);
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("difficulty normal"));
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
for (x, z) in [(0, 1), (1, 0), (1, 1)] {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(x, z))
|
||||
.unwrap();
|
||||
}
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
|
||||
game.creative = true;
|
||||
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
|
||||
assert!(
|
||||
game.run_command("summon pig 13.5 200 2.5 {UUID:[I;129,0,0,99],NoAI:1b,NoGravity:1b}")
|
||||
);
|
||||
game.world.random = LegacyRandom::new(129);
|
||||
test(&mut game);
|
||||
}
|
||||
fn knot_id(game: &Game<'_>, n: i32) -> u32 {
|
||||
game.others
|
||||
.iter()
|
||||
.find(|o| game.persist.uuid_of(o.id) == Some([129, 0, 0, n]))
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
fn summon_knots(game: &mut Game<'_>) -> [u32; 3] {
|
||||
for x in [6, 14] {
|
||||
assert!(game.run_command(&format!("setblock {x} 200 6 oak_fence strict")));
|
||||
}
|
||||
for (n, x) in [(1, 6), (2, 10), (3, 14)] {
|
||||
assert!(game.run_command(&format!("summon leash_knot {x}.2 200.8 6.9 {{UUID:[I;129,0,0,{n}],CustomName:\"knot\",Tags:[\"h129\"]}}")));
|
||||
}
|
||||
[1, 2, 3].map(|n| knot_id(game, n))
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "prepared resources; registered explicit knot load and real saved projection"]
|
||||
fn summoned_knots_adopt_normalized_position_and_save_common_defaults() {
|
||||
with_game(|game| {
|
||||
let clock = game.world.game_time;
|
||||
let ids = summon_knots(game);
|
||||
for (i, id) in ids.into_iter().enumerate() {
|
||||
let o = game.others.iter().find(|o| o.id == id).unwrap();
|
||||
assert_eq!(o.kind, "minecraft:leash_knot");
|
||||
assert_eq!(game.persist.uuid_of(id), Some([129, 0, 0, i as i32 + 1]));
|
||||
let expected = Vec3::new(6.5 + 4.0 * i as f64, 200.375, 6.5);
|
||||
assert_eq!(
|
||||
o.position, expected,
|
||||
"actual loaded model, not a getter-only answer"
|
||||
);
|
||||
let tag = game.entity_nbt(Target::Other(id)).unwrap();
|
||||
assert_eq!(
|
||||
tag.get("Pos"),
|
||||
Some(&Tag::List(
|
||||
expected
|
||||
.components()
|
||||
.map(|v| Tag::Double(v.to_bits()))
|
||||
.to_vec()
|
||||
))
|
||||
);
|
||||
for (key, value) in [
|
||||
("Air", Tag::Short(300)),
|
||||
("Invulnerable", Tag::Byte(0)),
|
||||
("OnGround", Tag::Byte(0)),
|
||||
("PortalCooldown", Tag::Int(0)),
|
||||
("fall_distance", Tag::Double(0.0f64.to_bits())),
|
||||
] {
|
||||
assert_eq!(tag.get(key), Some(&value), "{key}");
|
||||
}
|
||||
assert_eq!(
|
||||
tag.get("Tags"),
|
||||
Some(&Tag::List(vec![Tag::String("h129".into())]))
|
||||
);
|
||||
// The same real model, with no raw source, must still save defaults.
|
||||
let raw = game.persist.raw.remove(&id).unwrap();
|
||||
let fresh = game.entity_nbt(Target::Other(id)).unwrap();
|
||||
assert_eq!(fresh.get("Air"), Some(&Tag::Short(300)));
|
||||
assert_eq!(fresh.get("Pos"), tag.get("Pos"));
|
||||
game.persist.raw.insert(id, raw);
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "prepared resources; actual Game world ticks and support removal"]
|
||||
fn summoned_knots_check_actual_fence_support_on_tick_101() {
|
||||
with_game(|game| {
|
||||
let clock = game.world.game_time;
|
||||
let ids = summon_knots(game);
|
||||
assert!(
|
||||
game.run_command(
|
||||
"summon marker 8.2 200.8 10.9 {UUID:[I;129,0,0,4],data:{audit:7}}"
|
||||
)
|
||||
);
|
||||
let marker = game
|
||||
.others
|
||||
.iter()
|
||||
.find(|o| o.kind == "minecraft:marker")
|
||||
.unwrap()
|
||||
.id;
|
||||
let marker_before = game.entity_nbt(Target::Other(marker)).unwrap();
|
||||
for tick in 1..=101 {
|
||||
game.tick().unwrap();
|
||||
assert_eq!(game.world.game_time, clock + tick);
|
||||
if tick == 1 {
|
||||
assert!(game.run_command("setblock 14 200 6 air strict"));
|
||||
}
|
||||
if tick <= 100 {
|
||||
assert!(
|
||||
ids.iter().all(|id| game.others.iter().any(|o| o.id == *id)),
|
||||
"alive through tick{tick}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
game.others.iter().any(|o| o.id == ids[0]),
|
||||
"actual fence survives"
|
||||
);
|
||||
assert!(
|
||||
!game.others.iter().any(|o| o.id == ids[1]),
|
||||
"unsupported actual entity gone at101"
|
||||
);
|
||||
assert!(
|
||||
!game.others.iter().any(|o| o.id == ids[2]),
|
||||
"removed support actual entity gone at101"
|
||||
);
|
||||
assert_eq!(
|
||||
game.entity_nbt(Target::Other(marker)).unwrap(),
|
||||
marker_before
|
||||
);
|
||||
assert_eq!(
|
||||
game.mobs
|
||||
.iter()
|
||||
.find(|m| game.persist.uuid_of(m.id) == Some([129, 0, 0, 99]))
|
||||
.unwrap()
|
||||
.health,
|
||||
10.0
|
||||
);
|
||||
assert!(game.entity_nbt(Target::Other(ids[1])).is_none());
|
||||
assert!(game.run_command("kill @e[type=leash_knot]"));
|
||||
assert!(!game.others.iter().any(|o| o.kind == "minecraft:leash_knot"));
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "prepared resources; negative fractional registered position"]
|
||||
fn negative_fractional_knot_position_uses_floor_before_centering() {
|
||||
with_game(|game| {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((-1, -1), Arc::new(Chunk::empty(-1, -1)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(-1, -1))
|
||||
.unwrap();
|
||||
assert!(game.run_command("setblock -2 199 -3 stone strict"));
|
||||
assert!(game.run_command("setblock -2 200 -3 oak_fence strict"));
|
||||
assert!(game.run_command("summon leash_knot -1.2 200.8 -2.1 {UUID:[I;129,0,0,5]}"));
|
||||
let id = knot_id(game, 5);
|
||||
assert_eq!(
|
||||
game.others.iter().find(|o| o.id == id).unwrap().position,
|
||||
Vec3::new(-1.5, 200.375, -2.5)
|
||||
);
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "prepared resources; paused and actual ticket-excluded ticks"]
|
||||
fn explicit_knot_check_counts_only_running_entity_ticking_turns() {
|
||||
with_game(|game| {
|
||||
let ids = summon_knots(game);
|
||||
let clock = game.world.game_time;
|
||||
for _ in 0..100 {
|
||||
game.tick().unwrap();
|
||||
}
|
||||
assert!(ids.iter().all(|id| game.others.iter().any(|o| o.id == *id)));
|
||||
assert!(game.run_command("tick freeze"));
|
||||
for _ in 0..3 {
|
||||
game.tick().unwrap();
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock + 100);
|
||||
assert!(ids.iter().all(|id| game.others.iter().any(|o| o.id == *id)));
|
||||
assert!(game.run_command("gamerule spectators_generate_chunks false"));
|
||||
assert!(game.run_command("gamemode spectator @s"));
|
||||
assert!(game.run_command("forceload remove all"));
|
||||
assert!(game.run_command("tick unfreeze"));
|
||||
for _ in 0..3 {
|
||||
game.tick().unwrap();
|
||||
assert!(!game.entity_ticking_area()(10, 6));
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock + 103);
|
||||
assert!(ids.iter().all(|id| game.others.iter().any(|o| o.id == *id)));
|
||||
assert!(game.run_command("gamemode creative @s"));
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
game.tick().unwrap();
|
||||
assert!(game.entity_ticking_area()(10, 6));
|
||||
assert!(game.others.iter().any(|o| o.id == ids[0]));
|
||||
assert!(
|
||||
!game.others.iter().any(|o| o.id == ids[1]),
|
||||
"101 eligible ticks, not elapsed world time"
|
||||
);
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "prepared resources; existing natural lead/knot adapter control"]
|
||||
fn explicit_knot_removal_preserves_a_real_lead_created_knot() {
|
||||
with_game(|game| {
|
||||
use horizon_client::mobs::leash::Holder;
|
||||
assert!(game.run_command("tp @s 12.5 200 2.5"));
|
||||
assert!(game.run_command("item replace entity @s weapon.mainhand with lead"));
|
||||
assert!(game.run_command("setblock 14 200 2 oak_fence strict"));
|
||||
let pig = game
|
||||
.mobs
|
||||
.iter()
|
||||
.position(|m| game.persist.uuid_of(m.id) == Some([129, 0, 0, 99]))
|
||||
.unwrap();
|
||||
assert!(game.interact_leash(pig, "minecraft:lead"));
|
||||
assert!(game.use_knot([14, 200, 2], false));
|
||||
assert_eq!(game.mobs[pig].extra.leash, Some(Holder::Knot([14, 200, 2])));
|
||||
game.lan_assign_ids();
|
||||
let natural = game.knot_ids[&[14, 200, 2]];
|
||||
let ids = summon_knots(game);
|
||||
for _ in 0..101 {
|
||||
game.tick().unwrap();
|
||||
}
|
||||
game.lan_assign_ids();
|
||||
assert_eq!(game.knot_ids[&[14, 200, 2]], natural);
|
||||
let pig = game
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| game.persist.uuid_of(m.id) == Some([129, 0, 0, 99]))
|
||||
.unwrap();
|
||||
assert_eq!(pig.extra.leash, Some(Holder::Knot([14, 200, 2])));
|
||||
assert!(!game.others.iter().any(|o| o.id == ids[1]));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod camel_pose_saved_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -8,6 +8,7 @@ impl Game<'_> {
|
||||
/// `Screen.keyPressed` hears the raw one, and only a raw key settles what
|
||||
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
|
||||
pub(super) fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
|
||||
self.direct_craft_stop();
|
||||
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
|
||||
// every key, before it decides anything.
|
||||
self.creative_ignore_text = false;
|
||||
|
||||
@@ -111,6 +111,152 @@ fn thrown_left_owner(ridden: &[Aabb], position: Vec3, velocity: Vec3) -> bool {
|
||||
pub(super) const LINGERING_WAIT: i32 = 10;
|
||||
/// `AreaEffectCloud.DEFAULT_REAPPLICATION_DELAY`.
|
||||
const REAPPLICATION_DELAY: i32 = 20;
|
||||
/// `AreaEffectCloud.readAdditionalSaveData` for the fields a client cloud keeps:
|
||||
/// its position, lifetime, radius and use settings, and the potion it carries.
|
||||
/// The custom particle and the owner reference are not modelled on a client
|
||||
/// cloud, and the entity id is the caller's (`load_potion_cloud`).
|
||||
pub(super) fn potion_cloud_from_nbt(tag: &Tag) -> Option<(Vec3, Breath)> {
|
||||
use horizon_client::save::{float, number, text};
|
||||
let at = horizon_client::entity_nbt::position(tag)?;
|
||||
let int = |key, default| number(tag.get(key)).map_or(default, |v| v as i32);
|
||||
let real = |key, default| float(tag.get(key)).map_or(default, |v| v as f32);
|
||||
let contents = tag.get("potion_contents");
|
||||
let mut effects = contents
|
||||
.and_then(|c| text(c.get("potion")))
|
||||
.and_then(|id| brewing::potion(&id))
|
||||
.map_or_else(Vec::new, |p| p.effects.to_vec());
|
||||
let (mut hidden, mut ambient) = (Vec::new(), Vec::new());
|
||||
if let Some(Tag::List(custom)) = contents.and_then(|c| c.get("custom_effects")) {
|
||||
for effect in custom {
|
||||
let Some(def) = text(effect.get("id")).and_then(|id| brewing::effect(&id)) else {
|
||||
continue;
|
||||
};
|
||||
let duration = number(effect.get("duration")).unwrap_or(0) as i32;
|
||||
let amp = number(effect.get("amplifier")).unwrap_or(0).clamp(0, 255) as i32;
|
||||
effects.push((def.id, duration, amp));
|
||||
if number(effect.get("show_particles")) == Some(0) {
|
||||
hidden.push(def.id);
|
||||
}
|
||||
if number(effect.get("ambient")).unwrap_or(0) != 0 {
|
||||
ambient.push(def.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let color = contents
|
||||
.and_then(|c| number(c.get("custom_color")))
|
||||
.map_or_else(
|
||||
|| {
|
||||
horizon_client_core::potions::effects_color(
|
||||
effects
|
||||
.iter()
|
||||
.filter(|(id, ..)| !hidden.contains(id))
|
||||
.map(|(id, _, amp)| (*id, *amp)),
|
||||
)
|
||||
},
|
||||
|color| color as u32,
|
||||
);
|
||||
Some((
|
||||
at,
|
||||
Breath {
|
||||
id: 0,
|
||||
at: [at.x, at.y, at.z],
|
||||
radius: real("Radius", 3.0).clamp(0.0, 32.0),
|
||||
age: int("Age", 0),
|
||||
duration: int("Duration", -1),
|
||||
from_dragon: false,
|
||||
victims: Vec::new(),
|
||||
potion: Some(PotionCloud {
|
||||
settings: CloudSettings {
|
||||
wait_time: int("WaitTime", 20),
|
||||
reapplication_delay: int("ReapplicationDelay", 20),
|
||||
radius_on_use: real("RadiusOnUse", 0.0),
|
||||
duration_on_use: int("DurationOnUse", 0),
|
||||
duration_scale: real("potion_duration_scale", 1.0),
|
||||
},
|
||||
effects,
|
||||
hidden,
|
||||
ambient,
|
||||
radius_per_tick: real("RadiusPerTick", 0.0),
|
||||
color,
|
||||
victims: Vec::new(),
|
||||
source: Text::translate("entity.minecraft.area_effect_cloud", &[]),
|
||||
owner: None,
|
||||
guest: None,
|
||||
}),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// `AreaEffectCloud.addAdditionalSaveData`: the lifetime, radius and use
|
||||
/// settings with the effects the cloud applies, as `custom_effects` with the
|
||||
/// colour they were drawn in. The client keeps no potion id, so a cloud that
|
||||
/// came from a named potion is written effect by effect; the custom particle
|
||||
/// and the owner have no counterpart here.
|
||||
pub(super) fn potion_cloud_to_nbt(c: &Breath, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
use horizon_world::nbt::Compound;
|
||||
let Some(potion) = &c.potion else {
|
||||
return Tag::End;
|
||||
};
|
||||
let at = Vec3::new(c.at[0], c.at[1], c.at[2]);
|
||||
let mut tag = horizon_client::entity_nbt::entity_base_tag(
|
||||
"minecraft:area_effect_cloud",
|
||||
at,
|
||||
Vec3::ZERO,
|
||||
[0.0, 0.0],
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
let custom: Vec<Tag> = potion
|
||||
.effects
|
||||
.iter()
|
||||
.map(|(id, duration, amplifier)| {
|
||||
let mut effect = Compound::new();
|
||||
effect.insert("id".into(), Tag::String(brewing::namespaced(id).into()));
|
||||
effect.insert("duration".into(), Tag::Int(*duration));
|
||||
effect.insert("amplifier".into(), Tag::Int(*amplifier));
|
||||
if potion.hidden.contains(id) {
|
||||
effect.insert("show_particles".into(), Tag::Byte(0));
|
||||
}
|
||||
if potion.ambient.contains(id) {
|
||||
effect.insert("ambient".into(), Tag::Byte(1));
|
||||
}
|
||||
Tag::Compound(effect)
|
||||
})
|
||||
.collect();
|
||||
let mut contents = Compound::new();
|
||||
contents.insert("custom_effects".into(), Tag::List(custom));
|
||||
contents.insert("custom_color".into(), Tag::Int(potion.color as i32));
|
||||
let Tag::Compound(map) = &mut tag else {
|
||||
return tag;
|
||||
};
|
||||
map.insert("Age".into(), Tag::Int(c.age));
|
||||
map.insert("Duration".into(), Tag::Int(c.duration));
|
||||
map.insert("Radius".into(), Tag::Float(c.radius.to_bits()));
|
||||
map.insert(
|
||||
"RadiusPerTick".into(),
|
||||
Tag::Float(potion.radius_per_tick.to_bits()),
|
||||
);
|
||||
map.insert("WaitTime".into(), Tag::Int(potion.settings.wait_time));
|
||||
map.insert(
|
||||
"ReapplicationDelay".into(),
|
||||
Tag::Int(potion.settings.reapplication_delay),
|
||||
);
|
||||
map.insert(
|
||||
"RadiusOnUse".into(),
|
||||
Tag::Float(potion.settings.radius_on_use.to_bits()),
|
||||
);
|
||||
map.insert(
|
||||
"DurationOnUse".into(),
|
||||
Tag::Int(potion.settings.duration_on_use),
|
||||
);
|
||||
map.insert(
|
||||
"potion_duration_scale".into(),
|
||||
Tag::Float(potion.settings.duration_scale.to_bits()),
|
||||
);
|
||||
map.insert("potion_contents".into(), Tag::Compound(contents));
|
||||
tag
|
||||
}
|
||||
|
||||
/// An entity a cloud has affected (`AreaEffectCloud.victims`).
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(super) enum Victim {
|
||||
@@ -529,11 +675,12 @@ fn thrown_kind(stack: &ItemStack) -> &'static str {
|
||||
}
|
||||
|
||||
/// `BottleItem.use`: find a living `AreaEffectCloud` owned by an `EnderDragon`
|
||||
/// within the player's box grown by 2, and shrink its radius by 0.5.
|
||||
/// within the player's box grown by 2, and shrink its radius by 0.5. Gives
|
||||
/// the cloud's position when one was found.
|
||||
pub(super) fn collect_dragon_breath(
|
||||
player_box: Aabb,
|
||||
clouds: &mut [super::dragon::Breath],
|
||||
) -> bool {
|
||||
) -> Option<[f64; 3]> {
|
||||
let search_box = player_box.inflate(Vec3::new(2.0, 2.0, 2.0));
|
||||
if let Some(c) = clouds.iter_mut().find(|c| {
|
||||
if !c.from_dragon || c.potion.is_some() || super::dragon::breath_expired(c.age, c.duration)
|
||||
@@ -548,9 +695,9 @@ pub(super) fn collect_dragon_breath(
|
||||
search_box.intersects(cloud_box)
|
||||
}) {
|
||||
c.radius = (c.radius - 0.5).max(0.0);
|
||||
true
|
||||
Some(c.at)
|
||||
} else {
|
||||
false
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,6 +761,8 @@ impl Game<'_> {
|
||||
v,
|
||||
Some(self.player_display_text()),
|
||||
);
|
||||
// ThrowablePotionItem.use / ExperienceBottleItem.use: awardStat(ITEM_USED).
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
self.consume_held();
|
||||
Ok(true)
|
||||
@@ -623,9 +772,11 @@ impl Game<'_> {
|
||||
// AreaEffectCloud owned by an EnderDragon within the player's box
|
||||
// grown by 2. If one is found, it shrinks the cloud's radius by 0.5,
|
||||
// plays item.bottle.fill_dragonbreath at the player, emits
|
||||
// GameEvent.FLUID_PICKUP, and turns one bottle into dragon_breath.
|
||||
// GameEvent.FLUID_PICKUP, fires PLAYER_INTERACTED_WITH_ENTITY on the
|
||||
// cloud, and turns one bottle into dragon_breath.
|
||||
if let Some(player) = self.player.as_ref()
|
||||
&& collect_dragon_breath(player.bounds(), &mut self.breath_clouds)
|
||||
&& let Some(cloud_at) =
|
||||
collect_dragon_breath(player.bounds(), &mut self.breath_clouds)
|
||||
{
|
||||
let player_pos = [player.position.x, player.position.y, player.position.z];
|
||||
self.audio.play(
|
||||
@@ -635,6 +786,18 @@ impl Game<'_> {
|
||||
1.0,
|
||||
);
|
||||
self.post_entity_event("minecraft:fluid_pickup", Who::Player);
|
||||
let cloud = horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:area_effect_cloud".into(),
|
||||
position: cloud_at,
|
||||
..Default::default()
|
||||
};
|
||||
self.trigger_item_entity(
|
||||
"minecraft:player_interacted_with_entity",
|
||||
stack,
|
||||
&cloud,
|
||||
);
|
||||
// BottleItem.turnBottleIntoItem.
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.fill_held(ItemStack::new("minecraft:dragon_breath", 1));
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -645,13 +808,24 @@ impl Game<'_> {
|
||||
if self.res.facts().get(state).fluid != horizon_client::blocks::Fluid::Water {
|
||||
return Ok(false);
|
||||
}
|
||||
let at = [
|
||||
f64::from(hit.pos[0]) + 0.5,
|
||||
f64::from(hit.pos[1]) + 0.5,
|
||||
f64::from(hit.pos[2]) + 0.5,
|
||||
];
|
||||
// The sound at the player, the game event at the water block.
|
||||
let Some(at) = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z])
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.audio
|
||||
.play("minecraft:item.bottle.fill", Some(at), 1.0, 1.0);
|
||||
self.post_block_game_event(
|
||||
"minecraft:fluid_pickup",
|
||||
hit.pos,
|
||||
Some(Who::Player),
|
||||
None,
|
||||
);
|
||||
// BottleItem.turnBottleIntoItem.
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
let water = ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water");
|
||||
self.fill_held(water);
|
||||
Ok(true)
|
||||
@@ -1419,73 +1593,10 @@ impl Game<'_> {
|
||||
/// simulation as lingering potions, with their own timing and potion data.
|
||||
/// The caller retains their UUID and original compound for command queries.
|
||||
pub(super) fn load_potion_cloud(&mut self, tag: &Tag) -> Option<u32> {
|
||||
use horizon_client::save::{float, number, text};
|
||||
let at = horizon_client::entity_nbt::position(tag)?;
|
||||
let int = |key, default| number(tag.get(key)).map_or(default, |v| v as i32);
|
||||
let real = |key, default| float(tag.get(key)).map_or(default, |v| v as f32);
|
||||
let contents = tag.get("potion_contents");
|
||||
let mut effects = contents
|
||||
.and_then(|c| text(c.get("potion")))
|
||||
.and_then(|id| brewing::potion(&id))
|
||||
.map_or_else(Vec::new, |p| p.effects.to_vec());
|
||||
let (mut hidden, mut ambient) = (Vec::new(), Vec::new());
|
||||
if let Some(Tag::List(custom)) = contents.and_then(|c| c.get("custom_effects")) {
|
||||
for effect in custom {
|
||||
let Some(def) = text(effect.get("id")).and_then(|id| brewing::effect(&id)) else {
|
||||
continue;
|
||||
};
|
||||
let duration = number(effect.get("duration")).unwrap_or(0) as i32;
|
||||
let amp = number(effect.get("amplifier")).unwrap_or(0).clamp(0, 255) as i32;
|
||||
effects.push((def.id, duration, amp));
|
||||
if number(effect.get("show_particles")) == Some(0) {
|
||||
hidden.push(def.id);
|
||||
}
|
||||
if number(effect.get("ambient")).unwrap_or(0) != 0 {
|
||||
ambient.push(def.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let color = contents
|
||||
.and_then(|c| number(c.get("custom_color")))
|
||||
.map_or_else(
|
||||
|| {
|
||||
horizon_client_core::potions::effects_color(
|
||||
effects
|
||||
.iter()
|
||||
.filter(|(id, ..)| !hidden.contains(id))
|
||||
.map(|(id, _, amp)| (*id, *amp)),
|
||||
)
|
||||
},
|
||||
|color| color as u32,
|
||||
);
|
||||
let (_, mut cloud) = potion_cloud_from_nbt(tag)?;
|
||||
let id = self.take_entity_id();
|
||||
self.breath_clouds.push(Breath {
|
||||
id,
|
||||
at: [at.x, at.y, at.z],
|
||||
radius: real("Radius", 3.0).clamp(0.0, 32.0),
|
||||
age: int("Age", 0),
|
||||
duration: int("Duration", -1),
|
||||
from_dragon: false,
|
||||
victims: Vec::new(),
|
||||
potion: Some(PotionCloud {
|
||||
settings: CloudSettings {
|
||||
wait_time: int("WaitTime", 20),
|
||||
reapplication_delay: int("ReapplicationDelay", 20),
|
||||
radius_on_use: real("RadiusOnUse", 0.0),
|
||||
duration_on_use: int("DurationOnUse", 0),
|
||||
duration_scale: real("potion_duration_scale", 1.0),
|
||||
},
|
||||
effects,
|
||||
hidden,
|
||||
ambient,
|
||||
radius_per_tick: real("RadiusPerTick", 0.0),
|
||||
color,
|
||||
victims: Vec::new(),
|
||||
source: Text::translate("entity.minecraft.area_effect_cloud", &[]),
|
||||
owner: None,
|
||||
guest: None,
|
||||
}),
|
||||
});
|
||||
cloud.id = id;
|
||||
self.breath_clouds.push(cloud);
|
||||
Some(id)
|
||||
}
|
||||
/// `AreaEffectCloud.tick` for the lingering potions' clouds: the client
|
||||
@@ -2178,6 +2289,84 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_client::screen_effects::nausea_blend_added;
|
||||
|
||||
/// `AreaEffectCloud.addAdditionalSaveData` with `readAdditionalSaveData`: a
|
||||
/// lingering cloud written at a save reads back with its lifetime, radius,
|
||||
/// use settings and the effects it sprays, with the hidden and ambient
|
||||
/// flags it carried.
|
||||
#[test]
|
||||
fn a_lingering_cloud_survives_a_save() {
|
||||
let cloud = Breath {
|
||||
id: 7,
|
||||
at: [1.5, 64.0, -2.5],
|
||||
radius: 3.5,
|
||||
age: 12,
|
||||
duration: 400,
|
||||
from_dragon: false,
|
||||
victims: Vec::new(),
|
||||
potion: Some(PotionCloud {
|
||||
settings: CloudSettings {
|
||||
wait_time: 10,
|
||||
reapplication_delay: 25,
|
||||
radius_on_use: -0.5,
|
||||
duration_on_use: -5,
|
||||
duration_scale: 0.5,
|
||||
},
|
||||
effects: vec![("poison", 200, 1), ("slowness", 100, 0)],
|
||||
hidden: vec!["slowness"],
|
||||
ambient: vec!["poison"],
|
||||
radius_per_tick: -0.008_750,
|
||||
color: 0x0033_FF44,
|
||||
victims: Vec::new(),
|
||||
source: Text::translate("entity.minecraft.area_effect_cloud", &[]),
|
||||
owner: None,
|
||||
guest: None,
|
||||
}),
|
||||
};
|
||||
let tag = potion_cloud_to_nbt(&cloud, [1, 2, 3, 4], None);
|
||||
let (at, back) = potion_cloud_from_nbt(&tag).expect("the cloud reads back");
|
||||
assert_eq!(at, Vec3::new(1.5, 64.0, -2.5), "the position");
|
||||
assert_eq!((back.age, back.duration), (12, 400));
|
||||
assert!((back.radius - 3.5).abs() < 1.0e-6, "{}", back.radius);
|
||||
let potion = back.potion.expect("a potion cloud");
|
||||
assert_eq!(
|
||||
(
|
||||
potion.settings.wait_time,
|
||||
potion.settings.reapplication_delay,
|
||||
potion.settings.duration_on_use
|
||||
),
|
||||
(10, 25, -5)
|
||||
);
|
||||
assert!((potion.settings.radius_on_use + 0.5).abs() < 1.0e-6);
|
||||
assert!((potion.settings.duration_scale - 0.5).abs() < 1.0e-6);
|
||||
assert!((potion.radius_per_tick + 0.008_750).abs() < 1.0e-6);
|
||||
assert_eq!(
|
||||
potion.effects,
|
||||
vec![("poison", 200, 1), ("slowness", 100, 0)]
|
||||
);
|
||||
assert_eq!(potion.hidden, vec!["slowness"]);
|
||||
assert_eq!(potion.ambient, vec!["poison"]);
|
||||
assert_eq!(potion.color, 0x0033_FF44, "the colour it was drawn in");
|
||||
// The vanilla field names the reader of another client would look for.
|
||||
for key in [
|
||||
"Age",
|
||||
"Duration",
|
||||
"Radius",
|
||||
"RadiusPerTick",
|
||||
"WaitTime",
|
||||
"ReapplicationDelay",
|
||||
"RadiusOnUse",
|
||||
"DurationOnUse",
|
||||
"potion_duration_scale",
|
||||
"potion_contents",
|
||||
] {
|
||||
assert!(tag.get(key).is_some(), "{key}");
|
||||
}
|
||||
assert_eq!(
|
||||
horizon_client::entity_nbt::kind(&tag).as_deref(),
|
||||
Some("minecraft:area_effect_cloud")
|
||||
);
|
||||
}
|
||||
|
||||
/// `WitherBoss.canBeAffected` refuses only the wither effect: its `addEffect`
|
||||
/// refuses the rest, but a cloud of strength or healing still counts the boss
|
||||
/// as a victim (`AreaEffectCloud.serverTick` asks `canBeAffected`).
|
||||
@@ -2314,9 +2503,9 @@ mod tests {
|
||||
victims: Vec::new(),
|
||||
potion: None,
|
||||
}];
|
||||
assert!(collect_dragon_breath(player_box, &mut clouds));
|
||||
assert!(collect_dragon_breath(player_box, &mut clouds).is_some());
|
||||
assert_eq!(clouds[0].radius, 4.5);
|
||||
assert!(collect_dragon_breath(player_box, &mut clouds));
|
||||
assert!(collect_dragon_breath(player_box, &mut clouds).is_some());
|
||||
assert_eq!(clouds[0].radius, 4.0);
|
||||
}
|
||||
|
||||
@@ -2336,7 +2525,7 @@ mod tests {
|
||||
victims: Vec::new(),
|
||||
potion: None,
|
||||
}];
|
||||
assert!(!collect_dragon_breath(player_box, &mut non_dragon));
|
||||
assert!(collect_dragon_breath(player_box, &mut non_dragon).is_none());
|
||||
assert_eq!(non_dragon[0].radius, 3.0);
|
||||
|
||||
// Lingering potion cloud (`potion: Some(...)`).
|
||||
@@ -2361,7 +2550,7 @@ mod tests {
|
||||
guest: None,
|
||||
}),
|
||||
}];
|
||||
assert!(!collect_dragon_breath(player_box, &mut lingering));
|
||||
assert!(collect_dragon_breath(player_box, &mut lingering).is_none());
|
||||
assert_eq!(lingering[0].radius, 3.0);
|
||||
|
||||
// Its last tick before the wait-shifted end still holds breath
|
||||
@@ -2376,7 +2565,7 @@ mod tests {
|
||||
victims: Vec::new(),
|
||||
potion: None,
|
||||
}];
|
||||
assert!(collect_dragon_breath(player_box, &mut last));
|
||||
assert!(collect_dragon_breath(player_box, &mut last).is_some());
|
||||
assert_eq!(last[0].radius, 4.5);
|
||||
|
||||
// Expired cloud (`tickCount - waitTime >= duration`).
|
||||
@@ -2390,7 +2579,7 @@ mod tests {
|
||||
victims: Vec::new(),
|
||||
potion: None,
|
||||
}];
|
||||
assert!(!collect_dragon_breath(player_box, &mut expired));
|
||||
assert!(collect_dragon_breath(player_box, &mut expired).is_none());
|
||||
assert_eq!(expired[0].radius, 5.0);
|
||||
|
||||
// Out of horizontal reach: cloud at 10 blocks away with radius 1:
|
||||
@@ -2405,7 +2594,7 @@ mod tests {
|
||||
victims: Vec::new(),
|
||||
potion: None,
|
||||
}];
|
||||
assert!(!collect_dragon_breath(player_box, &mut far));
|
||||
assert!(collect_dragon_breath(player_box, &mut far).is_none());
|
||||
assert_eq!(far[0].radius, 1.0);
|
||||
|
||||
// Out of vertical reach: cloud at Y=64.0 (Y bounds [64.0, 64.5]),
|
||||
@@ -2421,7 +2610,7 @@ mod tests {
|
||||
victims: Vec::new(),
|
||||
potion: None,
|
||||
}];
|
||||
assert!(!collect_dragon_breath(high_player, &mut ground_cloud));
|
||||
assert!(collect_dragon_breath(high_player, &mut ground_cloud).is_none());
|
||||
assert_eq!(ground_cloud[0].radius, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -2558,6 +2747,251 @@ mod xp_bottle_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mob_health_boost_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{chunk::Chunk, motion::PlayerMotion};
|
||||
use horizon_core::{geometry::Vec3, position::ChunkPos};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
|
||||
.join(format!("h205-{:?}", std::thread::current().id())),
|
||||
);
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
|
||||
game.creative = true;
|
||||
test(&mut game);
|
||||
}
|
||||
fn pig(game: &Game<'_>, n: i32) -> usize {
|
||||
let i: Vec<usize> = (0..game.mobs.len())
|
||||
.filter(|&i| game.persist.uuid_of(game.mobs[i].id) == Some([205, 0, 0, n]))
|
||||
.collect();
|
||||
assert_eq!(i.len(), 1, "one live pig with UUID {n}");
|
||||
i[0]
|
||||
}
|
||||
fn health(game: &Game<'_>, n: i32) -> f32 {
|
||||
game.mobs[pig(game, n)].health
|
||||
}
|
||||
fn boost(game: &Game<'_>, n: i32) -> Option<(i32, i32)> {
|
||||
let i = pig(game, n);
|
||||
game.mobs[i]
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.find(|e| e.0 == "minecraft:health_boost")
|
||||
.map(|e| (e.1, e.2))
|
||||
}
|
||||
|
||||
/// Expected values from the official 26.3 server: Health Boost II adds 8
|
||||
/// to max_health without healing; Instant Health heals up to it on the next
|
||||
/// tick; removing the effect (`/effect clear`, or running out) clamps
|
||||
/// Health to the maximum without it (LivingEntity.onAttributeUpdated).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and a scratch HORIZON_HOME"]
|
||||
fn health_boost_heals_to_its_maximum_and_clamps_when_it_goes() {
|
||||
with_game(|game| {
|
||||
// name, UUID word, Health, setup
|
||||
let cases: [(&str, i32, f32, &[&str]); 5] = [
|
||||
(
|
||||
"boost",
|
||||
1,
|
||||
10.0,
|
||||
&["effect give @e[tag=T,limit=1] minecraft:health_boost 100 1"],
|
||||
),
|
||||
(
|
||||
"expiry",
|
||||
2,
|
||||
10.0,
|
||||
&["effect give @e[tag=T,limit=1] minecraft:health_boost 2 1"],
|
||||
),
|
||||
("noboost", 3, 10.0, &[]),
|
||||
("heal", 5, 5.0, &[]),
|
||||
(
|
||||
"base",
|
||||
6,
|
||||
10.0,
|
||||
&["attribute @e[tag=T,limit=1] minecraft:max_health base set 14"],
|
||||
),
|
||||
];
|
||||
for (name, n, h, setup) in cases {
|
||||
let tag = format!("h205_{name}");
|
||||
assert!(game.run_command(&format!(
|
||||
"summon minecraft:pig {}.5 200 4.5 {{UUID:[I;205,0,0,{n}],Tags:[\"{tag}\"],PersistenceRequired:1b,Silent:1b,NoAI:1b,Health:{h}f}}",
|
||||
2 * n
|
||||
)));
|
||||
for c in setup {
|
||||
assert!(game.run_command(&c.replace('T', &tag)), "{name}: {c}");
|
||||
}
|
||||
}
|
||||
// Setup controls: the effect is there, nothing healed on the add.
|
||||
assert_eq!(boost(game, 1), Some((2000, 1)));
|
||||
assert_eq!(boost(game, 2), Some((40, 1)));
|
||||
for (_, n, h, _) in cases {
|
||||
assert_eq!(health(game, n), h, "UUID {n}: no heal on the add");
|
||||
}
|
||||
for (name, n, _, _) in cases {
|
||||
let level = if name == "heal" { 0 } else { 1 };
|
||||
assert!(game.run_command(&format!(
|
||||
"effect give @e[tag=h205_{name},limit=1] minecraft:instant_health 1 {level}"
|
||||
)));
|
||||
let _ = n;
|
||||
}
|
||||
game.tick_world(true).unwrap();
|
||||
// The one tick: the boosts counted down once.
|
||||
assert_eq!(boost(game, 1), Some((1999, 1)), "the mobs ticked once");
|
||||
let mut failures = Vec::new();
|
||||
let check = |game: &Game<'_>,
|
||||
failures: &mut Vec<String>,
|
||||
phase: &str,
|
||||
expected: [(i32, f32); 5]| {
|
||||
for (n, h) in expected {
|
||||
if health(game, n) != h {
|
||||
failures.push(format!(
|
||||
"{phase}: UUID {n} Health {}, expected {h}",
|
||||
health(game, n)
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
check(
|
||||
game,
|
||||
&mut failures,
|
||||
"healed",
|
||||
[(1, 18.0), (2, 18.0), (3, 10.0), (5, 9.0), (6, 14.0)],
|
||||
);
|
||||
assert!(
|
||||
game.run_command("effect clear @e[tag=h205_boost,limit=1] minecraft:health_boost")
|
||||
);
|
||||
check(
|
||||
game,
|
||||
&mut failures,
|
||||
"cleared",
|
||||
[(1, 10.0), (2, 18.0), (3, 10.0), (5, 9.0), (6, 14.0)],
|
||||
);
|
||||
for _ in 0..40 {
|
||||
game.tick_world(true).unwrap();
|
||||
}
|
||||
if boost(game, 2).is_some() {
|
||||
failures.push("expiry: the effect did not run out in 40 ticks".into());
|
||||
}
|
||||
check(
|
||||
game,
|
||||
&mut failures,
|
||||
"expired",
|
||||
[(1, 10.0), (2, 10.0), (3, 10.0), (5, 9.0), (6, 14.0)],
|
||||
);
|
||||
assert!(failures.is_empty(), "mob health boost: {failures:#?}");
|
||||
});
|
||||
}
|
||||
|
||||
/// Existing-behavior preservation, not parity: a sulfur cube's size and a
|
||||
/// tame wolf write their maximum straight into the cache, outside the
|
||||
/// attribute model. Health Boost leaves those caches (and Health) as they
|
||||
/// are; the attribute model's own refresh never replaces them.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and a scratch HORIZON_HOME"]
|
||||
fn health_boost_keeps_a_size_or_tame_maximum_owned_outside_the_attributes() {
|
||||
with_game(|game| {
|
||||
// tag, UUID word, summon, amplifier
|
||||
let cases = [
|
||||
// size 3: cache 12, the attribute model 20
|
||||
("h205_cube", 20, "sulfur_cube 4.5 200 8.5 {Size:2", 1),
|
||||
// size 5: cache 20 = the model's 20 by coincidence
|
||||
("h205_cube_equal", 22, "sulfur_cube 6.5 200 8.5 {Size:4", 1),
|
||||
// tame: cache 40, model 8; Health Boost II
|
||||
("h205_wolf", 21, "wolf 8.5 200 8.5 {", 1),
|
||||
// tame: Health Boost VIII (+32) makes the model 40 = the cache
|
||||
("h205_wolf_equal", 23, "wolf 10.5 200 8.5 {", 7),
|
||||
];
|
||||
let mut ids = Vec::new();
|
||||
for (tag, n, summon, _) in cases {
|
||||
let sep = if summon.ends_with('{') { "" } else { "," };
|
||||
assert!(game.run_command(&format!(
|
||||
"summon minecraft:{summon}{sep}UUID:[I;205,0,0,{n}],Tags:[\"{tag}\"],NoAI:1b,PersistenceRequired:1b,Silent:1b}}"
|
||||
)));
|
||||
let i = pig(game, n);
|
||||
if tag.starts_with("h205_wolf") {
|
||||
horizon_client::mobs::tame::set_tame(&mut game.mobs[i], true);
|
||||
}
|
||||
ids.push(i);
|
||||
}
|
||||
let state = |game: &Game<'_>| -> Vec<(f32, f32)> {
|
||||
ids.iter()
|
||||
.map(|&i| (game.mobs[i].attrs.max_health, game.mobs[i].health))
|
||||
.collect()
|
||||
};
|
||||
let before = state(game);
|
||||
assert_eq!(
|
||||
before.iter().map(|s| s.0).collect::<Vec<_>>(),
|
||||
[12.0, 20.0, 40.0, 40.0]
|
||||
);
|
||||
assert_eq!(
|
||||
horizon_client::mobs::finalize::max_health(&game.mobs[ids[1]]),
|
||||
20.0,
|
||||
"the coincidence: the model's value equals the size cache"
|
||||
);
|
||||
for (tag, _, _, amp) in cases {
|
||||
assert!(game.run_command(&format!(
|
||||
"effect give @e[tag={tag},limit=1] minecraft:health_boost 100 {amp}"
|
||||
)));
|
||||
}
|
||||
assert_eq!(
|
||||
horizon_client::mobs::finalize::max_health(&game.mobs[ids[3]]),
|
||||
40.0,
|
||||
"the coincidence: the boosted model equals the tame cache"
|
||||
);
|
||||
assert_eq!(
|
||||
state(game),
|
||||
before,
|
||||
"the size/tame cache and Health keep their owner"
|
||||
);
|
||||
for (tag, _, _, _) in cases {
|
||||
assert!(game.run_command(&format!(
|
||||
"effect clear @e[tag={tag},limit=1] minecraft:health_boost"
|
||||
)));
|
||||
}
|
||||
assert_eq!(state(game), before, "and after the clear");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod depleted_absorption_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, commands::Target, load_resources};
|
||||
|
||||
@@ -116,7 +116,7 @@ fn sprite(name: &str) -> String {
|
||||
|
||||
impl Game<'_> {
|
||||
/// The book the open screen has, if any.
|
||||
fn active_book(&self) -> Option<BookType> {
|
||||
pub(super) fn active_book(&self) -> Option<BookType> {
|
||||
if self.screen != Screen::Inventory || self.station.is_some() {
|
||||
return None;
|
||||
}
|
||||
@@ -908,6 +908,55 @@ impl Game<'_> {
|
||||
inside(self.cursor, x, y, w, h)
|
||||
})
|
||||
}
|
||||
/// Whether the crafting book's entry can be crafted from what the menu holds now.
|
||||
pub(super) fn recipe_craftable(&mut self, entry_index: usize) -> bool {
|
||||
let available = self.book_available(BookType::Crafting);
|
||||
self.recipe_ui.book.as_ref().is_some_and(|book| {
|
||||
book.entries
|
||||
.get(entry_index)
|
||||
.is_some_and(|entry| book.can_craft(entry, &available))
|
||||
})
|
||||
}
|
||||
/// The crafting book's entry by what a rebuilt book keeps of it (`pad_craft::RecipeKey`).
|
||||
pub(super) fn recipe_stable_key(
|
||||
&self,
|
||||
entry_index: usize,
|
||||
) -> Option<super::pad_craft::RecipeKey> {
|
||||
let entry = self.recipe_ui.book.as_ref()?.entries.get(entry_index)?;
|
||||
Some(super::pad_craft::RecipeKey {
|
||||
id: entry.id.clone(),
|
||||
display: entry.display_id,
|
||||
})
|
||||
}
|
||||
/// Where the crafting book holds the recipe now: none once the book has it no more.
|
||||
pub(super) fn recipe_entry_index(&self, key: &super::pad_craft::RecipeKey) -> Option<usize> {
|
||||
self.recipe_ui
|
||||
.book
|
||||
.as_ref()?
|
||||
.entries
|
||||
.iter()
|
||||
.position(|e| e.id == key.id && e.display_id == key.display)
|
||||
}
|
||||
/// What the crafting book's entry makes: none when the book has no such entry.
|
||||
pub(super) fn recipe_results(&self, entry_index: usize) -> &[ItemStack] {
|
||||
self.recipe_ui
|
||||
.book
|
||||
.as_ref()
|
||||
.and_then(|book| book.entries.get(entry_index))
|
||||
.map_or(&[], Entry::results)
|
||||
}
|
||||
/// Places one set of the crafting book's entry, as a click on its button places it.
|
||||
pub(super) fn recipe_place_once(&mut self, entry_index: usize) {
|
||||
self.recipe_ui.last_placed = self
|
||||
.recipe_ui
|
||||
.book
|
||||
.as_ref()
|
||||
.and_then(|b| b.entries.get(entry_index))
|
||||
.map(|e| e.id.clone());
|
||||
self.recipe_ui.ghost.clear();
|
||||
self.recipe_ui.overlay = None;
|
||||
self.place_recipe(BookType::Crafting, entry_index, false);
|
||||
}
|
||||
/// The recipe book's buttons a controller can focus: the book button,
|
||||
/// recipes, page arrows, the craftable filter and the tabs (GUI pixels;
|
||||
/// the search box registers itself as a field).
|
||||
@@ -1299,6 +1348,8 @@ impl Game<'_> {
|
||||
self.recipe_ui.ghost.clear();
|
||||
self.fill_ghost(book_type, &entry);
|
||||
}
|
||||
// The server is missing an item the client counted: nothing was placed.
|
||||
self.direct_craft_refused();
|
||||
}
|
||||
|
||||
// ---- ghost recipe ----
|
||||
|
||||
@@ -8010,7 +8010,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// startDestroyBlock refuses a restricted break before predicting it.
|
||||
if self.remote_break_restricted() {
|
||||
if self.remote_break_restricted(hit.pos) {
|
||||
self.mining = None;
|
||||
return;
|
||||
}
|
||||
@@ -8193,12 +8193,21 @@ impl Game<'_> {
|
||||
}
|
||||
self.mining = None;
|
||||
}
|
||||
/// `Player.blockActionRestricted` as far as this client reads it: a
|
||||
/// spectator never breaks, an adventure player only with a main-hand
|
||||
/// item the server gave `can_break`. Whether its predicate matches the
|
||||
/// block is left to the server, which sends the block back if not.
|
||||
fn remote_break_restricted(&self) -> bool {
|
||||
/// `Player.blockActionRestricted` for the block at `pos`: a spectator
|
||||
/// never breaks, an adventure player only with a main-hand item whose
|
||||
/// `can_break` matches the block (`ItemStack.canBreakBlockInAdventureMode`).
|
||||
/// A predicate this client could not decode (exact components) only
|
||||
/// tells that the server gave one, and the server sends the block back
|
||||
/// if it does not match.
|
||||
fn remote_break_restricted(&self, pos: [i32; 3]) -> bool {
|
||||
use horizon_protocol::item::{WirePatch, component::CAN_BREAK};
|
||||
if self
|
||||
.held()
|
||||
.and_then(|s| s.data())
|
||||
.is_some_and(|d| d.other("minecraft:can_break").is_some())
|
||||
{
|
||||
return self.block_action_restricted(pos);
|
||||
}
|
||||
self.spectator()
|
||||
|| (!self.may_build()
|
||||
&& !self
|
||||
@@ -8294,7 +8303,18 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
.unwrap_or((false, false));
|
||||
let passes = is_painting || frame_passes || boat_passes || cart_passes;
|
||||
// Cushion.interact on a client: PASS when sneaked at or when
|
||||
// something already sits on it, CONSUME (no swing) otherwise.
|
||||
let is_cushion = self.cushions.iter().any(|c| c.id as i32 == hit.id);
|
||||
let cushion_passes = is_cushion
|
||||
&& (sneaking
|
||||
|| self.riding.is_some_and(|r| r as i32 == hit.id)
|
||||
|| self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.extra.mount.is_some_and(|r| r as i32 == hit.id)));
|
||||
let passes =
|
||||
is_painting || frame_passes || boat_passes || cart_passes || cushion_passes;
|
||||
// Otherwise the main hand's turn takes it; there is no off hand turn.
|
||||
if response.is_none() && hand == Hand::Off && !passes {
|
||||
return false;
|
||||
@@ -8326,8 +8346,9 @@ impl Game<'_> {
|
||||
}
|
||||
// Minecart.interact boards the cart and answers CONSUME on the client,
|
||||
// which ends the click without an arm swing. AbstractBoat.interact
|
||||
// answers SUCCESS there, which swings.
|
||||
if remote_object_swings(response, is_rideable_cart) {
|
||||
// answers SUCCESS there, which swings. Cushion.interact answers
|
||||
// CONSUME there too.
|
||||
if remote_object_swings(response, is_rideable_cart || is_cushion) {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
return true;
|
||||
@@ -8553,6 +8574,28 @@ impl Game<'_> {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
return true;
|
||||
} else if let Some(stack) = held.as_ref()
|
||||
&& &*stack.item == "minecraft:potion"
|
||||
&& stack.potion() == Some("minecraft:water")
|
||||
&& hit.face != [0, -1, 0]
|
||||
&& self.block_in_tag("minecraft:convertible_to_mud", &self.block_name(hit.pos))
|
||||
&& (self.may_build()
|
||||
|| self.with_hand(hand, |g| g.can_place_on_in_adventure(hit.pos)))
|
||||
{
|
||||
// PotionItem.useOn on a client level: SUCCESS. The bottle is
|
||||
// poured out into the hand and the block turns to mud here at
|
||||
// once; the sounds (`playSound(null, ...)`) and the splash
|
||||
// particles (`sendParticles`) are the server's.
|
||||
if let Some(mud) = self.pack.assets.registry.default_state("minecraft:mud") {
|
||||
let pos =
|
||||
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
self.predict_blocks(&vec![(pos, mud)], seq);
|
||||
}
|
||||
self.with_hand(hand, |g| {
|
||||
g.filled_result(ItemStack::new("minecraft:glass_bottle", 1))
|
||||
});
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
} else if let Some(stack) = held.as_ref()
|
||||
&& (self.predict_item_on_block(stack, &hit, hand)
|
||||
|| self.predict_map_on_banner(stack, &hit))
|
||||
@@ -9143,6 +9186,28 @@ impl Game<'_> {
|
||||
}
|
||||
return succeeded;
|
||||
}
|
||||
// KnowledgeBookItem.use on this client: the book is used up either
|
||||
// way. With recipes it is SUCCESS (swings, ends the click); with none
|
||||
// it is FAIL, and the other hand gets its turn. The recipes go as
|
||||
// their NBT on the wire, and the item's own default is an empty list,
|
||||
// so a stack whose patch sets them has some.
|
||||
if &*held.item == "minecraft:knowledge_book" {
|
||||
/// `DataComponents.RECIPES`' registry id (26.3).
|
||||
const RECIPES: i32 = 68;
|
||||
let has_recipes = held
|
||||
.wire_patch()
|
||||
.is_some_and(|p| horizon_protocol::item::WirePatch::adds(&p, RECIPES))
|
||||
|| held
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:recipes"))
|
||||
.is_some_and(|t| matches!(t, Tag::List(l) if !l.is_empty()));
|
||||
self.with_hand(hand, Game::consume_held);
|
||||
if has_recipes {
|
||||
self.apply_use_cooldown(&held);
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
return has_recipes;
|
||||
}
|
||||
// Food starts being eaten (Consumable.startConsuming). A food that
|
||||
// can't be eaten (full and not always-edible) fails the use
|
||||
// (InteractionResult.FAIL): startUseItem tries the other hand, and
|
||||
@@ -9775,6 +9840,23 @@ impl Game<'_> {
|
||||
.own_attribute("minecraft:attack_knockback")
|
||||
.map_or(0.0, |v| v as f32);
|
||||
let sprinting = self.player.as_ref().is_some_and(|p| p.sprinting);
|
||||
// attackVisualEffects: a RemotePlayer's hurtClient is true, so a
|
||||
// critical hit (full strength, canCriticalAttack) with damage left
|
||||
// after baseDamageScaleFactor runs LocalPlayer.crit, this client's
|
||||
// own CRIT emitter beside the one the server's Animate brings.
|
||||
// magicBoost is 0 on a client, so no ENCHANTED_HIT one.
|
||||
if damage * (0.2 + strength * strength * 0.8) > 0.0 && self.critical_attack(strength) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let m = &self.mobs[mob];
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
let (width, height) = (m.kind.width, m.kind.height);
|
||||
self.particles.tracking_emitter(
|
||||
ParticleOptions::simple(ParticleType::Crit),
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
}
|
||||
if !horizon_client::attack_strength::attacker_slows_after_hit(
|
||||
damage, knockback, strength, sprinting,
|
||||
) {
|
||||
@@ -9845,7 +9927,7 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
self.send(S::Punch(Empty));
|
||||
}
|
||||
} else if self.remote_break_restricted() {
|
||||
} else if self.remote_break_restricted(hit.pos) {
|
||||
// continueDestroyBlock returned false: no swing.
|
||||
} else if !air {
|
||||
self.remote_mining(false);
|
||||
@@ -10150,6 +10232,20 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A QUICK_MOVE on the server's open menu with nothing predicted: the
|
||||
/// client's slots stay as they are and every slot it moved comes back from
|
||||
/// the server (`broadcastChanges`), the answer the next step waits for.
|
||||
/// A prediction the server agrees with gets no answer at all.
|
||||
pub(super) fn remote_quick_move_unpredicted(&mut self, internal: usize) -> bool {
|
||||
let (container, menu) = self.remote_menu();
|
||||
let Some(slot) = Self::menu_slot_index(&menu, internal) else {
|
||||
return false;
|
||||
};
|
||||
let carried = self.carried.clone();
|
||||
let click = (slot as i16, 0, Input::QuickMove, Vec::new(), carried);
|
||||
self.send_clicks(container, vec![click]);
|
||||
true
|
||||
}
|
||||
/// A Shift double click on a server (`AbstractContainerScreen.mouseReleased`):
|
||||
/// a predicted QUICK_MOVE for every slot the engine moves, each sent.
|
||||
pub(super) fn remote_quick_move_all(&mut self, slot: usize, moved: &ItemStack) {
|
||||
@@ -11327,6 +11423,7 @@ pub(super) fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
|
||||
| "goat_horn"
|
||||
| "bundle"
|
||||
| "brush"
|
||||
| "knowledge_book"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::Game;
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{
|
||||
first_person::Hand,
|
||||
mobs::{Mob, Species, armadillo, equine, llama, nautilus},
|
||||
mobs::{Mob, Species, armadillo, equine, llama, nautilus, panda, villager},
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -47,6 +47,21 @@ impl Game<'_> {
|
||||
if m.species() == Species::Armadillo && armadillo::client_rejects_food(m, item) {
|
||||
return false;
|
||||
}
|
||||
// Panda.mobInteract: a scared panda passes and one lying on its back
|
||||
// takes any item (it gets up on the server). Its bamboo is taken on a
|
||||
// client only by a baby that ages up; an adult's breeding and eating
|
||||
// need a ServerLevel, so the click passes to the item.
|
||||
if m.species() == Species::Panda {
|
||||
if panda::scared(m) {
|
||||
return false;
|
||||
}
|
||||
if panda::is_on_back(m) {
|
||||
return true;
|
||||
}
|
||||
if m.kind.food.iter().any(|f| f.as_str() == item) {
|
||||
return baby && !m.extra.age_locked;
|
||||
}
|
||||
}
|
||||
// AgeableMob.mobInteract: a golden dandelion on a baby that can be age
|
||||
// locked is SUCCESS on a client too (the server sets the lock).
|
||||
if self.age_lock_takes(m, item) {
|
||||
@@ -63,8 +78,11 @@ impl Game<'_> {
|
||||
}
|
||||
let sneaking = self.keys.held(Action::Sneak);
|
||||
match m.species() {
|
||||
// Villager / WanderingTrader.mobInteract: trading, or a baby's head shake.
|
||||
Species::Villager | Species::WanderingTrader => item != "minecraft:villager_spawn_egg",
|
||||
// Villager.mobInteract: trading, or a baby's head shake. A sleeping
|
||||
// villager falls back to super.mobInteract, which passes.
|
||||
Species::Villager => item != "minecraft:villager_spawn_egg" && !villager::sleeping(m),
|
||||
// WanderingTrader.mobInteract: trading.
|
||||
Species::WanderingTrader => item != "minecraft:villager_spawn_egg",
|
||||
// AbstractHorse, Camel: ridden (or their inventory opened); a baby only eats.
|
||||
s if equine::is_equine(s)
|
||||
|| llama::is_llama(s)
|
||||
@@ -72,8 +90,14 @@ impl Game<'_> {
|
||||
{
|
||||
!baby
|
||||
}
|
||||
// Pig / Strider.mobInteract: a saddled one is ridden.
|
||||
Species::Pig | Species::Strider => m.extra.saddled && !sneaking,
|
||||
// Pig / Strider.mobInteract: a saddled one nobody rides is ridden
|
||||
// (`!isVehicle()`).
|
||||
Species::Pig | Species::Strider => {
|
||||
m.extra.saddled
|
||||
&& !sneaking
|
||||
&& self.riding != Some(m.id)
|
||||
&& !self.mobs.iter().any(|o| o.extra.mount == Some(m.id))
|
||||
}
|
||||
// HappyGhast.mobInteract: a harnessed adult is ridden.
|
||||
Species::HappyGhast => !baby && m.extra.body.is_some(),
|
||||
// TamableAnimal (wolf, cat, parrot) on a client: CONSUME once tame.
|
||||
@@ -162,6 +186,11 @@ impl Game<'_> {
|
||||
Hand::Off => self.offhand.as_ref(),
|
||||
};
|
||||
let item = stack.map_or("", |s| &*s.item);
|
||||
// NameTagItem.interactLivingEntity: only a named tag answers (SUCCESS);
|
||||
// an unnamed one passes on to the trader's trading (SUCCESS_SERVER).
|
||||
if m.species() == Species::WanderingTrader && item == "minecraft:name_tag" {
|
||||
return stack.is_some_and(|s| s.custom_name().is_some());
|
||||
}
|
||||
mob_swings(m, item)
|
||||
}
|
||||
/// `AbstractHorse.handleEating` (`Llama`'s and `Camel`'s too) on this
|
||||
@@ -254,8 +283,15 @@ fn mob_swings(m: &Mob, item: &str) -> bool {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Panda.mobInteract: its bamboo answers SUCCESS_SERVER.
|
||||
// Panda.mobInteract: getting up off its back is SUCCESS, and its
|
||||
// bamboo answers SUCCESS_SERVER.
|
||||
Species::Panda if panda::is_on_back(m) => return true,
|
||||
Species::Panda if food => return false,
|
||||
// WanderingTrader.mobInteract: trading answers SUCCESS_SERVER, and so
|
||||
// does a spawn egg (Mob.checkAndHandleImportantInteractions). A lead is
|
||||
// CONSUME on the client (Entity.interact: a trader can't be leashed), so
|
||||
// nothing swings; a named name tag is decided by the caller.
|
||||
Species::WanderingTrader if item != "minecraft:name_tag" => return false,
|
||||
// Cat.mobInteract: food tames an untamed cat, and heals a hurt
|
||||
// tame one: SUCCESS before Animal.mobInteract is reached.
|
||||
Species::Cat if food && (!m.extra.tame || hurt) => return true,
|
||||
|
||||
@@ -239,7 +239,9 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let entry = servers::load(&file).into_iter().find(|s| s.ip == address);
|
||||
// ServerList.get: the shown entries, then the hidden ones.
|
||||
let (shown, hidden) = servers::load_all(&file);
|
||||
let entry = shown.into_iter().chain(hidden).find(|s| s.ip == address);
|
||||
let setting = entry.as_ref().and_then(|e| e.accept_textures);
|
||||
state.address = entry.map(|e| e.ip);
|
||||
state.packs.connect(Prompt::for_server(setting));
|
||||
@@ -297,8 +299,10 @@ impl Game<'_> {
|
||||
/// The joined server's entry in the server list: its `acceptTextures`.
|
||||
fn server_setting(&self) -> Option<bool> {
|
||||
let address = self.server_packs.address.as_deref()?;
|
||||
servers::load(&multiplayer::servers_file(&saves()))
|
||||
let (shown, hidden) = servers::load_all(&multiplayer::servers_file(&saves()));
|
||||
shown
|
||||
.into_iter()
|
||||
.chain(hidden)
|
||||
.find(|s| s.ip == address)?
|
||||
.accept_textures
|
||||
}
|
||||
@@ -348,11 +352,19 @@ impl Game<'_> {
|
||||
// ServerList.saveSingleServer.
|
||||
if let Some(address) = &self.server_packs.address {
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let mut list = servers::load(&file);
|
||||
let (mut list, mut hidden) = servers::load_all(&file);
|
||||
let mut found = false;
|
||||
for entry in list.iter_mut().filter(|s| &s.ip == address) {
|
||||
entry.accept_textures = setting;
|
||||
found = true;
|
||||
}
|
||||
if let Err(e) = servers::save(&file, &list) {
|
||||
// Not among the shown entries: the hidden ones (a Direct Connection's).
|
||||
if !found {
|
||||
for entry in hidden.iter_mut().filter(|s| &s.ip == address) {
|
||||
entry.accept_textures = setting;
|
||||
}
|
||||
}
|
||||
if let Err(e) = servers::save_all(&file, &list, &hidden) {
|
||||
log::warn!("Horizon: could not save {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2534,6 +2534,26 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// AdventureModePredicate.addToTooltip under its header, for
|
||||
// can_break and then can_place_on. A block tag's blocks are listed by
|
||||
// id, as this side keeps tags unordered.
|
||||
for (text, c) in horizon_client::item_tooltip::adventure_lines(
|
||||
stack,
|
||||
&display,
|
||||
&|tag| {
|
||||
let mut blocks: Vec<String> = self
|
||||
.items()
|
||||
.block_tags
|
||||
.get(tag)
|
||||
.map(|s| s.iter().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
blocks.sort();
|
||||
blocks
|
||||
},
|
||||
&translate,
|
||||
) {
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
if display.shows("minecraft:damage")
|
||||
&& let Some(info) = item_info
|
||||
&& info.max_damage > 0
|
||||
|
||||
@@ -289,13 +289,14 @@ impl Game<'_> {
|
||||
self.award_custom_stat("total_world_time", 1);
|
||||
if alive {
|
||||
self.award_custom_stat("time_since_death", 1);
|
||||
if !sleeping {
|
||||
self.award_custom_stat("time_since_rest", 1);
|
||||
}
|
||||
}
|
||||
if crouching {
|
||||
self.award_custom_stat("sneak_time", 1);
|
||||
}
|
||||
// ServerPlayer.doTick: dead or alive, every tick out of bed.
|
||||
if !sleeping {
|
||||
self.award_custom_stat("time_since_rest", 1);
|
||||
}
|
||||
}
|
||||
fn tick_movement_stats(&mut self) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! `/data modify storage … value[] set value "bad"`: `StorageDataAccessor`
|
||||
//! edits the stored compound itself, and `AllElementsNode.setTag` clears a
|
||||
//! non-empty array before it rejects the string. The command fails, and the
|
||||
//! storage keeps the cleared array, with no `setData` and so no save of its
|
||||
//! own (tools/test_storage_alias_parity.py measures the same on vanilla 26.3).
|
||||
use super::*;
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game
|
||||
}
|
||||
|
||||
fn stored(game: &Game<'_>, id: &str) -> Tag {
|
||||
game.score.storage.get(id)
|
||||
}
|
||||
fn snbt(text: &str) -> Tag {
|
||||
parse_snbt(text).unwrap().0
|
||||
}
|
||||
/// Whether saving the storage now writes a file (it is dirty).
|
||||
fn writes(game: &mut Game<'_>, name: &str) -> bool {
|
||||
let dir = std::env::temp_dir().join(format!("h246-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
game.score.storage.save(&dir);
|
||||
let wrote = dir.join("data").exists();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
wrote
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_rejected_wildcard_set_keeps_the_storage_s_cleared_array() {
|
||||
let mut game = game();
|
||||
assert!(game.run_command("data merge storage h246:witness {v:123}"));
|
||||
for (kind, initial, cleared) in [
|
||||
("byte", "[B;1b,2b]", "[B;]"),
|
||||
("int", "[I;1,2]", "[I;]"),
|
||||
("long", "[L;1L,2L]", "[L;]"),
|
||||
] {
|
||||
let id = format!("h246:{kind}");
|
||||
assert!(game.run_command(&format!("data merge storage {id} {{value:{initial}}}")));
|
||||
assert!(writes(&mut game, kind), "{kind}: the merge is saved");
|
||||
assert!(
|
||||
!game.run_command(&format!(
|
||||
"data modify storage {id} value[] set value \"bad\""
|
||||
)),
|
||||
"{kind}: the command fails"
|
||||
);
|
||||
assert_eq!(
|
||||
stored(&game, &id),
|
||||
snbt(&format!("{{value:{cleared}}}")),
|
||||
"{kind}: the array was cleared in place"
|
||||
);
|
||||
assert!(!writes(&mut game, kind), "{kind}: no save of its own");
|
||||
assert_eq!(stored(&game, "h246:witness"), snbt("{v:123}"));
|
||||
}
|
||||
// Nothing to change: nothing cleared.
|
||||
assert!(game.run_command("data merge storage h246:same {value:[I;7,7]}"));
|
||||
assert!(!game.run_command("data modify storage h246:same value[] set value 7"));
|
||||
assert_eq!(stored(&game, "h246:same"), snbt("{value:[I;7,7]}"));
|
||||
}
|
||||
|
||||
/// Runs `command`, which must fail because nothing changed.
|
||||
fn fails_unchanged(game: &mut Game<'_>, command: &str) {
|
||||
game.chat_capture = Some(Vec::new());
|
||||
assert!(!game.run_command(command), "{command}");
|
||||
let said = game.chat_capture.take().unwrap_or_default();
|
||||
assert_eq!(
|
||||
said.first()
|
||||
.map(horizon_client::text::Text::plain)
|
||||
.as_deref(),
|
||||
Some("Nothing changed. The specified properties already have these values"),
|
||||
"{command}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The storage file a save writes under `dir`, read back.
|
||||
fn saved(
|
||||
game: &mut Game<'_>,
|
||||
name: &str,
|
||||
) -> Option<horizon_client::command_storage::CommandStorage> {
|
||||
let dir = std::env::temp_dir().join(format!("h246-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
game.score.storage.save(&dir);
|
||||
let back = dir
|
||||
.join("data")
|
||||
.exists()
|
||||
.then(|| horizon_client::command_storage::CommandStorage::load(&dir));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
back
|
||||
}
|
||||
|
||||
/// Added controls (after the baseline): the failure keeps the storage's
|
||||
/// save state as it was, a later set saves what the failure left, and a
|
||||
/// failure on an id that is not stored, or one that changed nothing, stores
|
||||
/// nothing new. An entity's data stays a copy.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_failure_keeps_the_save_state_and_stores_nothing_new() {
|
||||
let mut game = game();
|
||||
// Already marked for saving: the failure leaves it so, and the save
|
||||
// writes the cleared array.
|
||||
assert!(game.run_command("data merge storage h246:dirty {value:[I;1,2]}"));
|
||||
assert!(!game.run_command("data modify storage h246:dirty value[] set value \"bad\""));
|
||||
let back = saved(&mut game, "dirty").expect("still marked for saving");
|
||||
assert_eq!(back.get("h246:dirty"), snbt("{value:[I;]}"));
|
||||
// Saved, then failed: nothing to write, until a later set saves both.
|
||||
assert!(game.run_command("data merge storage h246:clean {value:[I;1,2]}"));
|
||||
assert!(saved(&mut game, "clean-before").is_some());
|
||||
assert!(!game.run_command("data modify storage h246:clean value[] set value \"bad\""));
|
||||
assert!(
|
||||
saved(&mut game, "clean-after").is_none(),
|
||||
"the failure saves nothing"
|
||||
);
|
||||
assert!(game.run_command("data merge storage h246:later {v:1}"));
|
||||
let back = saved(&mut game, "later").expect("the set marks it");
|
||||
assert_eq!(back.get("h246:clean"), snbt("{value:[I;]}"));
|
||||
assert_eq!(back.get("h246:later"), snbt("{v:1}"));
|
||||
// A failure on an id that is not stored stores nothing.
|
||||
let keys = game.score.storage.keys();
|
||||
fails_unchanged(
|
||||
&mut game,
|
||||
"data modify storage h246ghost:none {} merge value {}",
|
||||
);
|
||||
assert_eq!(game.score.storage.keys(), keys, "no new id");
|
||||
assert!(game.run_command("data merge storage h246:mark {v:2}"));
|
||||
let back = saved(&mut game, "ghost").unwrap();
|
||||
assert!(
|
||||
!back.keys().iter().any(|k| k.starts_with("h246ghost:")),
|
||||
"no new namespace"
|
||||
);
|
||||
// A failure that changed nothing keeps every id and its contents.
|
||||
assert!(game.run_command("data merge storage h246:same {value:[I;7,7]}"));
|
||||
let keys = game.score.storage.keys();
|
||||
fails_unchanged(
|
||||
&mut game,
|
||||
"data modify storage h246:same value[] set value 7",
|
||||
);
|
||||
assert_eq!(game.score.storage.keys(), keys);
|
||||
assert_eq!(stored(&game, "h246:same"), snbt("{value:[I;7,7]}"));
|
||||
// An entity's data is a copy: its UUID stays.
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
assert!(game.run_command("summon armor_stand 4.5 100 4.5 {NoGravity:1b,UUID:[I;582,0,0,1]}"));
|
||||
let stand = "00000246-0000-0000-0000-000000000001";
|
||||
fails_unchanged(
|
||||
&mut game,
|
||||
&format!("data modify entity {stand} UUID[] set value \"bad\""),
|
||||
);
|
||||
assert!(game.run_command(&format!(
|
||||
"execute if data entity {stand} {{UUID:[I;582,0,0,1]}}"
|
||||
)));
|
||||
}
|
||||
@@ -490,6 +490,7 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
self.mark_motion(out.len(), Self::MOTION_OBJECT | u64::from(t.id));
|
||||
let fuse = t.fuse as f32 - alpha as f32 + 1.0;
|
||||
let scale = if fuse < 10.0 {
|
||||
let g = (1.0 - fuse / 10.0).clamp(0.0, 1.0);
|
||||
|
||||
@@ -188,6 +188,14 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
|
||||
};
|
||||
text
|
||||
}
|
||||
// AdventureModePredicate: the blocks an adventure player may break
|
||||
// or place on, which this client matches as the server does.
|
||||
Component::Raw(bytes) if matches!(*kind, component::CAN_BREAK | CAN_PLACE_ON) => {
|
||||
let Some(predicate) = adventure_predicate_tag(bytes, builtin) else {
|
||||
continue;
|
||||
};
|
||||
predicate
|
||||
}
|
||||
// Read past, not decoded: the item keeps its prototype's value.
|
||||
Component::Raw(_) => continue,
|
||||
// Trim materials and patterns are synced registries, as enchantments are.
|
||||
@@ -422,6 +430,90 @@ fn block_entity_data_tag(bytes: &[u8], builtin: &Builtin) -> Option<Tag> {
|
||||
Some(Tag::Compound(data))
|
||||
}
|
||||
|
||||
/// `DataComponents.CAN_PLACE_ON`'s registry id (26.3); `CAN_BREAK` follows it.
|
||||
const CAN_PLACE_ON: i32 = 14;
|
||||
|
||||
/// `AdventureModePredicate.STREAM_CODEC` (a list of `BlockPredicate`s) as
|
||||
/// `AdventureModePredicate.CODEC` saves it: one predicate, or a list of them
|
||||
/// (`ExtraCodecs.compactListCodec`). Each keeps its `blocks` (a `#tag`, one
|
||||
/// block id or a list), `state` and `nbt`; `DataComponentMatchers` are read
|
||||
/// past (`BlockPredicate.matches(BlockInWorld)` does not test them), and a
|
||||
/// predicate with exact components, whose values this side cannot read, gives
|
||||
/// up on the whole component.
|
||||
fn adventure_predicate_tag(bytes: &[u8], builtin: &Builtin) -> Option<Tag> {
|
||||
let blocks = builtin.get("minecraft:block")?;
|
||||
let mut r = Reader::new(bytes);
|
||||
let n = r.count(r.remaining()).ok()?;
|
||||
let mut predicates = Vec::with_capacity(n.min(64));
|
||||
for _ in 0..n {
|
||||
let mut p: Vec<(&str, Tag)> = Vec::new();
|
||||
// ByteBufCodecs.holderSet: 0 and a tag id, or the size plus one and the ids.
|
||||
if r.bool().ok()? {
|
||||
let set = match r.varint().ok()? {
|
||||
0 => Tag::String(format!("#{}", r.string(32767).ok()?).as_str().into()),
|
||||
size => {
|
||||
let size = usize::try_from(size).ok()?.checked_sub(1)?;
|
||||
if size > r.remaining() {
|
||||
return None;
|
||||
}
|
||||
let mut ids = (0..size)
|
||||
.map(|_| Some(Tag::String(blocks.name(r.varint().ok()?)?.into())))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
if ids.len() == 1 {
|
||||
ids.pop()?
|
||||
} else {
|
||||
Tag::List(ids)
|
||||
}
|
||||
}
|
||||
};
|
||||
p.push(("blocks", set));
|
||||
}
|
||||
// StatePropertiesPredicate: each property an exact value, or a range.
|
||||
if r.bool().ok()? {
|
||||
let mut state: Vec<(String, Tag)> = Vec::new();
|
||||
for _ in 0..r.count(r.remaining()).ok()? {
|
||||
let name = r.string(32767).ok()?;
|
||||
let matcher = if r.bool().ok()? {
|
||||
Tag::String(r.string(32767).ok()?.as_str().into())
|
||||
} else {
|
||||
let mut range = Vec::new();
|
||||
for bound in ["min", "max"] {
|
||||
if r.bool().ok()? {
|
||||
range.push((bound, Tag::String(r.string(32767).ok()?.as_str().into())));
|
||||
}
|
||||
}
|
||||
Tag::compound(range)
|
||||
};
|
||||
state.push((name, matcher));
|
||||
}
|
||||
p.push((
|
||||
"state",
|
||||
Tag::compound(state.iter().map(|(k, v)| (k.as_str(), v.clone()))),
|
||||
));
|
||||
}
|
||||
// NbtPredicate.STREAM_CODEC: a compound.
|
||||
if r.bool().ok()? {
|
||||
p.push(("nbt", r.nbt().ok()?));
|
||||
}
|
||||
// DataComponentExactPredicate: typed component values.
|
||||
if r.count(r.remaining()).ok()? != 0 {
|
||||
return None;
|
||||
}
|
||||
// DataComponentPredicate: a type id and the predicate as NBT.
|
||||
for _ in 0..r.count(r.remaining()).ok()? {
|
||||
r.bool().ok()?;
|
||||
r.varint().ok()?;
|
||||
r.nbt().ok()?;
|
||||
}
|
||||
predicates.push(Tag::compound(p));
|
||||
}
|
||||
if predicates.len() == 1 {
|
||||
predicates.pop()
|
||||
} else {
|
||||
Some(Tag::List(predicates))
|
||||
}
|
||||
}
|
||||
|
||||
/// `SignText.STREAM_CODEC` as `SignText.CODEC` saves it: the four messages,
|
||||
/// the filtered four when the server sent them, the dye colour and the glow.
|
||||
fn sign_text_tag(bytes: &[u8]) -> Option<Tag> {
|
||||
|
||||
@@ -671,6 +671,84 @@ pub fn unbreakable_line(
|
||||
.then(|| translate("item.unbreakable").unwrap_or_else(|| "item.unbreakable".into()))
|
||||
}
|
||||
|
||||
/// `ItemStack.addDetailsToTooltip`'s adventure lines, `can_break` then
|
||||
/// `can_place_on`: each shown one adds an empty line, its gray header
|
||||
/// (`AdventureModePredicate.CAN_BREAK_HEADER` / `CAN_PLACE_HEADER`) and
|
||||
/// `AdventureModePredicate.computeTooltip`: `item.canUse.unknown` in gray when
|
||||
/// some predicate names no blocks, else every distinct block of the
|
||||
/// predicates' holder sets by its name (`Block.getName`) in dark gray.
|
||||
/// `tag_blocks` lists the blocks of a block tag.
|
||||
pub fn adventure_lines(
|
||||
stack: &ItemStack,
|
||||
display: &TooltipDisplay,
|
||||
tag_blocks: &dyn Fn(&str) -> Vec<String>,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, u32)> {
|
||||
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
|
||||
let namespaced = |id: &str| {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
};
|
||||
let (gray, dark_gray) = (rgba(170, 170, 170, 255), rgba(85, 85, 85, 255));
|
||||
let mut lines = Vec::new();
|
||||
for (component, header) in [
|
||||
("minecraft:can_break", "item.canBreak"),
|
||||
("minecraft:can_place_on", "item.canPlace"),
|
||||
] {
|
||||
let Some(tag) = stack.data().and_then(|d| d.other(component)) else {
|
||||
continue;
|
||||
};
|
||||
if !display.shows(component) {
|
||||
continue;
|
||||
}
|
||||
lines.push((String::new(), u32::MAX));
|
||||
lines.push((text(header), gray));
|
||||
let predicates: Vec<&Tag> = match tag {
|
||||
Tag::List(list) => list.iter().collect(),
|
||||
single => vec![single],
|
||||
};
|
||||
if predicates.iter().any(|p| p.get("blocks").is_none()) {
|
||||
lines.push((text("item.canUse.unknown"), gray));
|
||||
continue;
|
||||
}
|
||||
let mut blocks: Vec<String> = Vec::new();
|
||||
for p in predicates {
|
||||
let ids = match p.get("blocks") {
|
||||
Some(Tag::String(entry)) => {
|
||||
let entry = entry.to_string_lossy();
|
||||
match entry.strip_prefix('#') {
|
||||
Some(tag) => tag_blocks(&namespaced(tag)),
|
||||
None => vec![namespaced(&entry)],
|
||||
}
|
||||
}
|
||||
Some(Tag::List(entries)) => entries
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Tag::String(id) => Some(namespaced(&id.to_string_lossy())),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for id in ids {
|
||||
if !blocks.contains(&id) {
|
||||
blocks.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in blocks {
|
||||
lines.push((
|
||||
text(&format!("block.{}", id.replacen(':', ".", 1))),
|
||||
dark_gray,
|
||||
));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// `OminousBottleAmplifier.addToTooltip`: the effect the bottle gives,
|
||||
/// `MobEffectInstance(BAD_OMEN, 120000, amplifier)`, listed as
|
||||
/// `PotionContents.addPotionTooltip` lists it, in the colour of Bad Omen's
|
||||
|
||||
@@ -75,6 +75,13 @@ pub struct EntityState {
|
||||
pub horizontal_collision: bool,
|
||||
/// `Entity.needsSync`: send its changes this tick, due or not.
|
||||
pub needs_sync: bool,
|
||||
/// `Entity.syncPosition`: a living entity bounced this tick; where it is
|
||||
/// becomes a step of the next move sent (`SteppedInterpolationTracker`).
|
||||
pub sync_position: bool,
|
||||
/// `LivingEntity.createInterpolationHandler`: a `SteppedInterpolationTracker`
|
||||
/// on the server, so its moves are sent as stepped paths. Every other entity
|
||||
/// has a no-op tracker and sends a linear one.
|
||||
pub stepped: bool,
|
||||
/// Outside `DistanceManager.inEntityTickingRange`: `ChunkMap.tick` sends
|
||||
/// its changes only if it changed section or needs sync (its lead aside,
|
||||
/// which `Leashable` sends at once).
|
||||
@@ -119,6 +126,8 @@ impl EntityState {
|
||||
self.vertical_collision = false;
|
||||
self.horizontal_collision = false;
|
||||
self.needs_sync = false;
|
||||
self.sync_position = false;
|
||||
self.stepped = false;
|
||||
self.idle = false;
|
||||
self.bare = false;
|
||||
self.leash = None;
|
||||
@@ -252,6 +261,11 @@ struct ServerEntity {
|
||||
velocity: [f64; 3],
|
||||
/// NewMinecartBehavior.lerpSteps accumulated across tracking intervals.
|
||||
minecart_steps: Vec<play::MinecartStep>,
|
||||
/// SteppedInterpolationTracker: the positions where it bounced since the
|
||||
/// last move sent, with the ticks between them, and the ticks since the
|
||||
/// last one (-1 before the first update).
|
||||
tracked_steps: Vec<([f64; 3], i32)>,
|
||||
ticks_since_step: i32,
|
||||
/// trackedDataValues: the entity data the clients hold.
|
||||
meta: Vec<(u8, i32, Value)>,
|
||||
equipment: Vec<(u8, Option<Stack>)>,
|
||||
@@ -294,6 +308,42 @@ fn encode(v: f64) -> i64 {
|
||||
horizon_core::jmath::round(v * 4096.0)
|
||||
}
|
||||
|
||||
/// `VecDeltaCodec.tryEncode`: the path as deltas from `base`, a stepped one
|
||||
/// step by step from the step before; None when a delta is too big for a short.
|
||||
fn try_encode(base: [f64; 3], path: &PositionPath) -> Option<VecDelta> {
|
||||
let delta = |from: [f64; 3], to: [f64; 3]| {
|
||||
let d = [0, 1, 2].map(|k| encode(to[k]) - encode(from[k]));
|
||||
d.iter()
|
||||
.all(|v| i16::try_from(*v).is_ok())
|
||||
.then(|| d.map(|v| v as i16))
|
||||
};
|
||||
match path {
|
||||
PositionPath::Linear(p) => delta(base, *p).map(VecDelta::Linear),
|
||||
PositionPath::Stepped(steps) => {
|
||||
let mut from = base;
|
||||
let mut out = Vec::with_capacity(steps.len());
|
||||
for (p, ticks) in steps {
|
||||
out.push((delta(from, *p)?, *ticks));
|
||||
from = *p;
|
||||
}
|
||||
Some(VecDelta::Stepped(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `VecDelta.hasDeltaX` and `hasDeltaZ` (any step's, for a stepped delta), as
|
||||
/// the nonzero x and z `full_precision_required` reads.
|
||||
fn delta_axes(delta: &VecDelta) -> [i64; 3] {
|
||||
match delta {
|
||||
VecDelta::Linear(d) => d.map(i64::from),
|
||||
VecDelta::Stepped(steps) => [
|
||||
i64::from(steps.iter().any(|(d, _)| d[0] != 0)),
|
||||
0,
|
||||
i64::from(steps.iter().any(|(d, _)| d[2] != 0)),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerEntity.isFullPrecisionEncodingRequired`: an item whose last move was
|
||||
/// stopped by a block rests where the collision solver left it, which is not on
|
||||
/// the 1/4096 grid a delta decodes to; vanilla sends its exact position instead.
|
||||
@@ -331,6 +381,8 @@ impl ServerEntity {
|
||||
head: pack_degrees(e.head_yaw),
|
||||
velocity: e.velocity,
|
||||
minecart_steps: e.minecart_steps.clone().unwrap_or_default(),
|
||||
tracked_steps: Vec::new(),
|
||||
ticks_since_step: -1,
|
||||
meta: e.meta.clone(),
|
||||
equipment: e.equipment.clone(),
|
||||
attributes: e.attributes.clone(),
|
||||
@@ -404,6 +456,15 @@ impl ServerEntity {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SteppedInterpolationTracker.addStep`: a step at `position` with the
|
||||
/// ticks since the last one, if any passed.
|
||||
fn add_step(&mut self, position: [f64; 3]) {
|
||||
if self.ticks_since_step > 0 {
|
||||
self.tracked_steps.push((position, self.ticks_since_step));
|
||||
self.ticks_since_step = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes since the last update (ServerEntity.sendChanges): movement and
|
||||
/// rotation every update interval (positions at least every 60 ticks, a
|
||||
/// full position sync every 400 or when the entity lands or leaves the
|
||||
@@ -421,6 +482,23 @@ impl ServerEntity {
|
||||
self.passengers.extend_from_slice(passengers);
|
||||
}
|
||||
self.send_leash(e, out);
|
||||
// SteppedInterpolationTracker.updateTracking: a tick more since the
|
||||
// last step, and a step where the entity bounced (Entity.syncPosition,
|
||||
// which only living entities' movement sets).
|
||||
if e.stepped {
|
||||
self.ticks_since_step += 1;
|
||||
if e.sync_position {
|
||||
self.add_step(e.position);
|
||||
}
|
||||
}
|
||||
// ServerEntity.sendChanges: a bounce moves the update clock onto its next
|
||||
// send tick (UpdateInterval.nextInterval; 0 for NEVER), so it goes out now.
|
||||
if e.sync_position {
|
||||
self.tick = self
|
||||
.tick
|
||||
.checked_div(self.interval)
|
||||
.map_or(0, |n| (n + 1) * self.interval);
|
||||
}
|
||||
// SynchedEntityData.isDirty: once for every client.
|
||||
let dirty = e.meta != self.meta;
|
||||
let due = self.interval != 0 && self.tick.is_multiple_of(self.interval);
|
||||
@@ -443,6 +521,9 @@ impl ServerEntity {
|
||||
)));
|
||||
self.rotation = rotation;
|
||||
}
|
||||
// interpolationTracker.clear: a passenger's steps are never sent.
|
||||
self.tracked_steps.clear();
|
||||
self.ticks_since_step = 0;
|
||||
self.base = e.position;
|
||||
self.was_riding = true;
|
||||
self.send_dirty(e, dirty, out);
|
||||
@@ -462,9 +543,20 @@ impl ServerEntity {
|
||||
self.teleport_delay += 1;
|
||||
let d: f64 = (0..3).map(|k| (e.position[k] - self.base[k]).powi(2)).sum();
|
||||
let send_position = self.arrow || d >= 7.629_394_5e-6 || self.tick.is_multiple_of(60);
|
||||
// SteppedInterpolationTracker.getPositionPath, then clear: the
|
||||
// current position is the last step of a bounced path.
|
||||
if e.stepped {
|
||||
self.add_step(e.position);
|
||||
}
|
||||
let path = if self.tracked_steps.is_empty() {
|
||||
PositionPath::Linear(e.position)
|
||||
} else {
|
||||
PositionPath::Stepped(std::mem::take(&mut self.tracked_steps))
|
||||
};
|
||||
self.ticks_since_step = 0;
|
||||
let sync = EntityPositionSync {
|
||||
id: e.id,
|
||||
path: PositionPath::Linear(e.position),
|
||||
path: path.clone(),
|
||||
yaw: e.yaw,
|
||||
pitch: e.pitch,
|
||||
on_ground: e.on_ground,
|
||||
@@ -480,15 +572,12 @@ impl ServerEntity {
|
||||
self.was_riding = false;
|
||||
Some(play::Clientbound::EntityPositionSync(sync))
|
||||
} else if send_position {
|
||||
let delta = [0, 1, 2].map(|k| encode(e.position[k]) - encode(self.base[k]));
|
||||
if delta.iter().any(|v| i16::try_from(*v).is_err())
|
||||
|| full_precision_required(e, delta)
|
||||
{
|
||||
Some(play::Clientbound::EntityPositionSync(sync))
|
||||
} else {
|
||||
let delta = try_encode(self.base, &path)
|
||||
.filter(|d| !full_precision_required(e, delta_axes(d)));
|
||||
if let Some(delta) = delta {
|
||||
let m = MoveEntity {
|
||||
id: e.id,
|
||||
delta: Some(VecDelta::Linear(delta.map(|v| v as i16))),
|
||||
delta: Some(delta),
|
||||
rotation: rotate.then_some((rotation.1, rotation.0)),
|
||||
on_ground: e.on_ground,
|
||||
};
|
||||
@@ -497,6 +586,8 @@ impl ServerEntity {
|
||||
} else {
|
||||
play::Clientbound::MoveEntityPos(MoveEntityPos(m))
|
||||
})
|
||||
} else {
|
||||
Some(play::Clientbound::EntityPositionSync(sync))
|
||||
}
|
||||
} else if rotate {
|
||||
Some(play::Clientbound::MoveEntityRot(MoveEntityRot(
|
||||
@@ -1363,6 +1454,8 @@ mod tests {
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
sync_position: false,
|
||||
stepped: false,
|
||||
idle: false,
|
||||
bare: false,
|
||||
leash: None,
|
||||
|
||||
@@ -958,6 +958,294 @@ pub fn stack_to_network(
|
||||
) -> Option<Stack> {
|
||||
network_stack(&item_id, &synced_id, s)
|
||||
}
|
||||
/// `ProvidesTrimMaterial.STREAM_CODEC`, `ProvidesBannerPatterns.STREAM_CODEC` and
|
||||
/// `AdventureModePredicate.STREAM_CODEC`: encode supported typed saved forms.
|
||||
/// A failed child discards this entire local buffer, including its prefix.
|
||||
fn builtin_component(
|
||||
name: &str,
|
||||
value: &horizon_world::nbt::Tag,
|
||||
ids: &dyn Fn(&str, &str) -> Option<i32>,
|
||||
) -> Option<horizon_protocol::item::Component> {
|
||||
use horizon_protocol::buf::Writer;
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut w = Writer::new();
|
||||
match name {
|
||||
"minecraft:provides_trim_material" => match value {
|
||||
Tag::String(name) => {
|
||||
let name = name.to_string().ok()?;
|
||||
let id = ids(
|
||||
"minecraft:trim_material",
|
||||
&horizon_world::datafix::dynamic::namespaced(&name),
|
||||
)?;
|
||||
if id < 0 {
|
||||
return None;
|
||||
}
|
||||
w.varint(id.checked_add(1)?);
|
||||
}
|
||||
Tag::Compound(map) => {
|
||||
if map.keys().any(|k| {
|
||||
!matches!(
|
||||
k.to_string().ok().as_deref(),
|
||||
Some("palette_id" | "description")
|
||||
)
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let Tag::String(palette) = value.get("palette_id")? else {
|
||||
return None;
|
||||
};
|
||||
let description @ Tag::String(_) = value.get("description")? else {
|
||||
return None;
|
||||
};
|
||||
w.varint(0);
|
||||
w.string(&horizon_world::datafix::dynamic::namespaced(
|
||||
&palette.to_string().ok()?,
|
||||
));
|
||||
w.bytes(&horizon_world::nbt::write_network(description, Default::default()).ok()?);
|
||||
}
|
||||
_ => return None,
|
||||
},
|
||||
"minecraft:provides_banner_patterns" => {
|
||||
write_holder_set("minecraft:banner_pattern", value, ids, &mut w)?
|
||||
}
|
||||
"minecraft:can_place_on" | "minecraft:can_break" => {
|
||||
let predicates = match value {
|
||||
Tag::List(list) if !list.is_empty() => list.as_slice(),
|
||||
Tag::Compound(_) => std::slice::from_ref(value),
|
||||
_ => return None,
|
||||
};
|
||||
w.varint(i32::try_from(predicates.len()).ok()?);
|
||||
for predicate in predicates {
|
||||
write_block_predicate(predicate, ids, &mut w)?;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(horizon_protocol::item::Component::Raw(w.data))
|
||||
}
|
||||
|
||||
/// `ByteBufCodecs.holderSet`: tags or direct registry entries, without item-id remapping.
|
||||
fn write_holder_set(
|
||||
registry: &str,
|
||||
value: &horizon_world::nbt::Tag,
|
||||
ids: &dyn Fn(&str, &str) -> Option<i32>,
|
||||
w: &mut horizon_protocol::buf::Writer,
|
||||
) -> Option<()> {
|
||||
use horizon_world::nbt::Tag;
|
||||
if let Tag::String(name) = value {
|
||||
let name = name.to_string().ok()?;
|
||||
if let Some(tag) = name.strip_prefix('#') {
|
||||
if tag.is_empty() {
|
||||
return None;
|
||||
}
|
||||
w.varint(0);
|
||||
w.string(&horizon_world::datafix::dynamic::namespaced(tag));
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
let entries = match value {
|
||||
Tag::List(list) => list.as_slice(),
|
||||
Tag::String(_) => std::slice::from_ref(value),
|
||||
_ => return None,
|
||||
};
|
||||
w.varint(i32::try_from(entries.len()).ok()?.checked_add(1)?);
|
||||
for entry in entries {
|
||||
let Tag::String(name) = entry else {
|
||||
return None;
|
||||
};
|
||||
let name = name.to_string().ok()?;
|
||||
if name.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
let id = ids(
|
||||
registry,
|
||||
&horizon_world::datafix::dynamic::namespaced(&name),
|
||||
)?;
|
||||
if id < 0 {
|
||||
return None;
|
||||
}
|
||||
w.varint(id);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// `BlockPredicate.STREAM_CODEC`: every admitted field is a conjunction.
|
||||
fn write_block_predicate(
|
||||
value: &horizon_world::nbt::Tag,
|
||||
ids: &dyn Fn(&str, &str) -> Option<i32>,
|
||||
w: &mut horizon_protocol::buf::Writer,
|
||||
) -> Option<()> {
|
||||
use horizon_world::nbt::Tag;
|
||||
let Tag::Compound(map) = value else {
|
||||
return None;
|
||||
};
|
||||
if map.keys().any(|k| {
|
||||
!matches!(
|
||||
k.to_string().ok().as_deref(),
|
||||
Some("blocks" | "state" | "nbt" | "components" | "predicates")
|
||||
)
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
w.bool(value.get("blocks").is_some());
|
||||
if let Some(blocks) = value.get("blocks") {
|
||||
write_holder_set("minecraft:block", blocks, ids, w)?;
|
||||
}
|
||||
w.bool(value.get("state").is_some());
|
||||
if let Some(Tag::Compound(state)) = value.get("state") {
|
||||
// StatePropertiesPredicate's codec iterates a Java HashMap. Keep the
|
||||
// existing map implementation and a private UTF-16 String key adapter.
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct Key<'a>(&'a horizon_world::nbt::JavaString);
|
||||
impl horizon_world::java_hash_map::JavaKey for Key<'_> {
|
||||
fn java_hash(&self) -> i32 {
|
||||
self.0
|
||||
.0
|
||||
.iter()
|
||||
.fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(*c as i32))
|
||||
}
|
||||
fn comparable_compare(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.0.cmp(other.0))
|
||||
}
|
||||
fn tie_break(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.0.cmp(other.0)
|
||||
}
|
||||
}
|
||||
let mut ordered = horizon_world::java_hash_map::JavaHashMap::default();
|
||||
for (key, matcher) in state {
|
||||
ordered.put(Key(key), matcher);
|
||||
}
|
||||
w.varint(i32::try_from(state.len()).ok()?);
|
||||
let mut cursor = ordered.cursor();
|
||||
while let Some((key, matcher)) = ordered.next_entry(&mut cursor).ok()? {
|
||||
w.string(&key.0.to_string().ok()?);
|
||||
match matcher {
|
||||
Tag::String(exact) => {
|
||||
w.bool(true);
|
||||
w.string(&exact.to_string().ok()?);
|
||||
}
|
||||
Tag::Compound(range) => {
|
||||
if range
|
||||
.keys()
|
||||
.any(|k| !matches!(k.to_string().ok().as_deref(), Some("min" | "max")))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
w.bool(false);
|
||||
for bound in ["min", "max"] {
|
||||
w.bool(matcher.get(bound).is_some());
|
||||
if let Some(v) = matcher.get(bound) {
|
||||
let Tag::String(v) = v else {
|
||||
return None;
|
||||
};
|
||||
w.string(&v.to_string().ok()?);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
} else if value.get("state").is_some() {
|
||||
return None;
|
||||
}
|
||||
w.bool(value.get("nbt").is_some());
|
||||
if let Some(nbt) = value.get("nbt") {
|
||||
let parsed;
|
||||
let nbt = if let Tag::String(text) = nbt {
|
||||
let text = text.to_string().ok()?;
|
||||
let (tag, end) = horizon_world::nbt::parse_snbt(&text).ok()?;
|
||||
if !text[end..].trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
parsed = tag;
|
||||
&parsed
|
||||
} else {
|
||||
nbt
|
||||
};
|
||||
if !matches!(nbt, Tag::Compound(_)) {
|
||||
return None;
|
||||
}
|
||||
w.bytes(&horizon_world::nbt::write_network(nbt, Default::default()).ok()?);
|
||||
}
|
||||
write_matchers(value, ids, w)
|
||||
}
|
||||
|
||||
/// `DataComponentMatchers.STREAM_CODEC`: bounded exact-name and partial custom-data forms.
|
||||
fn write_matchers(
|
||||
value: &horizon_world::nbt::Tag,
|
||||
ids: &dyn Fn(&str, &str) -> Option<i32>,
|
||||
w: &mut horizon_protocol::buf::Writer,
|
||||
) -> Option<()> {
|
||||
use horizon_world::nbt::Tag;
|
||||
for (field, expected, registry) in [
|
||||
(
|
||||
"components",
|
||||
"minecraft:custom_name",
|
||||
"minecraft:data_component_type",
|
||||
),
|
||||
(
|
||||
"predicates",
|
||||
"minecraft:custom_data",
|
||||
"minecraft:data_component_predicate_type",
|
||||
),
|
||||
] {
|
||||
let Some(matchers) = value.get(field) else {
|
||||
w.varint(0);
|
||||
continue;
|
||||
};
|
||||
let Tag::Compound(map) = matchers else {
|
||||
return None;
|
||||
};
|
||||
w.varint(i32::try_from(map.len()).ok()?);
|
||||
for (name, matcher) in map {
|
||||
let name = horizon_world::datafix::dynamic::namespaced(&name.to_string().ok()?);
|
||||
if name != expected {
|
||||
return None;
|
||||
}
|
||||
let id = ids(registry, &name)?;
|
||||
if id < 0 {
|
||||
return None;
|
||||
}
|
||||
if field == "components" {
|
||||
let Tag::String(_) = matcher else {
|
||||
return None;
|
||||
};
|
||||
w.varint(id);
|
||||
w.bytes(&horizon_world::nbt::write_network(matcher, Default::default()).ok()?);
|
||||
} else {
|
||||
let parsed;
|
||||
let compound = if let Tag::String(text) = matcher {
|
||||
let text = text.to_string().ok()?;
|
||||
let (tag, end) = horizon_world::nbt::parse_snbt(&text).ok()?;
|
||||
if !text[end..].trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
parsed = tag;
|
||||
&parsed
|
||||
} else {
|
||||
matcher
|
||||
};
|
||||
if !matches!(compound, Tag::Compound(_)) {
|
||||
return None;
|
||||
}
|
||||
// The official partial-matcher stream codec consumes a TAG String
|
||||
// here, unlike BlockPredicate's COMPOUND_TAG above.
|
||||
let text = crate::snbt::to_snbt(compound);
|
||||
w.bool(true);
|
||||
w.varint(id);
|
||||
w.bytes(
|
||||
&horizon_world::nbt::write_network(
|
||||
&Tag::String(text.as_str().into()),
|
||||
Default::default(),
|
||||
)
|
||||
.ok()?,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn network_stack(
|
||||
item_id: &dyn Fn(&str) -> i32,
|
||||
synced_id: &dyn Fn(&str, &str) -> Option<i32>,
|
||||
@@ -1010,6 +1298,33 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Unit,
|
||||
));
|
||||
}
|
||||
// These built-ins use their own stream codecs, not the custom NBT route.
|
||||
for (name, kind) in [
|
||||
(
|
||||
"minecraft:provides_trim_material",
|
||||
horizon_protocol::item::component::PROVIDES_TRIM_MATERIAL,
|
||||
),
|
||||
(
|
||||
"minecraft:provides_banner_patterns",
|
||||
horizon_protocol::item::component::PROVIDES_BANNER_PATTERNS,
|
||||
),
|
||||
(
|
||||
"minecraft:can_place_on",
|
||||
horizon_protocol::item::component::CAN_PLACE_ON,
|
||||
),
|
||||
(
|
||||
"minecraft:can_break",
|
||||
horizon_protocol::item::component::CAN_BREAK,
|
||||
),
|
||||
] {
|
||||
if d.other(&format!("!{name}")).is_some() {
|
||||
stack.removed.push(kind);
|
||||
} else if let Some(value) = d.other(name)
|
||||
&& let Some(component) = builtin_component(name, value, synced_id)
|
||||
{
|
||||
stack.added.push((kind, component));
|
||||
}
|
||||
}
|
||||
for (name, value) in &d.other {
|
||||
let (removed, kind) = match name.strip_prefix('!') {
|
||||
Some(kind) => (true, kind),
|
||||
@@ -1470,6 +1785,99 @@ mod tests {
|
||||
use horizon_protocol::item::{Component, component};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
fn builtin_predicate_streams_keep_typed_conjunctions_and_reject_whole_unknown_forms() {
|
||||
let ids = |registry: &str, name: &str| match (registry, name) {
|
||||
("minecraft:block", "minecraft:chest") => Some(245),
|
||||
("minecraft:data_component_type", "minecraft:custom_name") => Some(6),
|
||||
("minecraft:data_component_predicate_type", "minecraft:custom_data") => Some(4),
|
||||
_ => None,
|
||||
};
|
||||
// Original value boundaries independently consumed by the pinned Java codecs.
|
||||
for (input, expected) in [
|
||||
(
|
||||
r#"{blocks:"minecraft:chest",nbt:"{Lock:{}}"}"#,
|
||||
"010102f50100010a0a00044c6f636b00000000",
|
||||
),
|
||||
(
|
||||
r#"{blocks:"minecraft:chest",components:{"minecraft:custom_name":"Box"}}"#,
|
||||
"010102f50100000106080003426f7800",
|
||||
),
|
||||
(
|
||||
r#"{blocks:"minecraft:chest",predicates:{"minecraft:custom_data":{a:1}}}"#,
|
||||
"010102f5010000000101040800057b613a317d",
|
||||
),
|
||||
] {
|
||||
let tag = horizon_world::nbt::parse_snbt(input).unwrap().0;
|
||||
let encoded = builtin_component("minecraft:can_break", &tag, &ids).unwrap();
|
||||
let Component::Raw(bytes) = encoded else {
|
||||
panic!("fresh encoding")
|
||||
};
|
||||
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(hex, expected, "{input}");
|
||||
let mut stack = horizon_protocol::item::Stack::new(1, 1);
|
||||
stack
|
||||
.added
|
||||
.push((component::CAN_BREAK, Component::Raw(bytes)));
|
||||
let mut w = Writer::new();
|
||||
horizon_protocol::item::Slot::write(&Some(stack), &mut w);
|
||||
let mut r = Reader::new(&w.data);
|
||||
let decoded = horizon_protocol::item::Slot::read(&mut r).unwrap().unwrap();
|
||||
assert!(decoded.component(component::CAN_BREAK).is_some());
|
||||
assert_eq!(r.remaining(), 0);
|
||||
}
|
||||
for input in [
|
||||
r#"{blocks:"minecraft:chest",unknown:1}"#,
|
||||
r#"{blocks:"minecraft:chest",state:{age:{min:"3",unknown:"7"}}}"#,
|
||||
r#"{blocks:"minecraft:chest",components:{"minecraft:custom_name":"Box","minecraft:damage":1}}"#,
|
||||
r#"{blocks:"minecraft:chest",predicates:{"minecraft:custom_data":{a:1},"minecraft:damage":{}}}"#,
|
||||
r#"[{blocks:"minecraft:chest"},{blocks:"minecraft:missing"}]"#,
|
||||
r#"{blocks:"minecraft:chest",nbt:"{a:1} trailing"}"#,
|
||||
] {
|
||||
let tag = horizon_world::nbt::parse_snbt(input).unwrap().0;
|
||||
assert!(
|
||||
builtin_component("minecraft:can_break", &tag, &ids).is_none(),
|
||||
"{input}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_removal_markers_survive_actual_saved_stack_adoption() {
|
||||
for (name, kind) in [
|
||||
(
|
||||
"minecraft:provides_trim_material",
|
||||
component::PROVIDES_TRIM_MATERIAL,
|
||||
),
|
||||
(
|
||||
"minecraft:provides_banner_patterns",
|
||||
component::PROVIDES_BANNER_PATTERNS,
|
||||
),
|
||||
("minecraft:can_place_on", component::CAN_PLACE_ON),
|
||||
("minecraft:can_break", component::CAN_BREAK),
|
||||
] {
|
||||
let input = Tag::compound([
|
||||
("id", Tag::String("minecraft:stone".into())),
|
||||
("count", Tag::Int(1)),
|
||||
(
|
||||
"components",
|
||||
Tag::Compound(
|
||||
[(
|
||||
format!("!{name}").as_str().into(),
|
||||
Tag::Compound(Default::default()),
|
||||
)]
|
||||
.into(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
let actual = horizon_items::item_nbt::item_from_nbt(&input).unwrap();
|
||||
assert!(actual.data().unwrap().other(&format!("!{name}")).is_some());
|
||||
let sent = stack_to_network(|_| 1, |_, _| None, &Some(actual)).unwrap();
|
||||
assert_eq!(sent.removed, [kind]);
|
||||
assert!(sent.added.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_lines_reach_the_client() {
|
||||
let lines = vec![
|
||||
|
||||
@@ -378,6 +378,9 @@ pub struct Hub {
|
||||
/// The names a server mod shows over players' heads
|
||||
/// (`native_set_name_tag`), by UUID: their list entries' profile name.
|
||||
pub name_tags: std::collections::HashMap<u128, String>,
|
||||
/// The name the server's user cache knew a joining player by, by UUID, looked up before the
|
||||
/// cache learns the new one (PlayerList.placeNewPlayer's `oldName`); taken by the join line.
|
||||
pub former_names: std::collections::HashMap<u128, String>,
|
||||
last_clock: i64,
|
||||
last_rate: f32,
|
||||
ticking: (f32, bool),
|
||||
@@ -551,6 +554,26 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
/// PlayerList.placeNewPlayer's join line: `multiplayer.player.joined`, or
|
||||
/// `multiplayer.player.joined.renamed` with the cached old name when the
|
||||
/// player's name changed (ignoring case) since the user cache last saw it.
|
||||
fn joined_line(&mut self, uuid: u128, name: &str) -> Tag {
|
||||
let display = self.player_display_name(uuid, name);
|
||||
match self
|
||||
.former_names
|
||||
.remove(&uuid)
|
||||
.filter(|old| !old.eq_ignore_ascii_case(name))
|
||||
{
|
||||
Some(old) => yellow_names(
|
||||
"multiplayer.player.joined.renamed",
|
||||
vec![display, string(&old)],
|
||||
),
|
||||
None => yellow_names("multiplayer.player.joined", vec![display]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A yellow translation of player names (`multiplayer.player.joined`).
|
||||
fn yellow_names(key: &str, names: Vec<Tag>) -> Tag {
|
||||
Tag::compound([
|
||||
@@ -747,6 +770,7 @@ impl Hub {
|
||||
list_names: std::collections::HashMap::new(),
|
||||
display_names: std::collections::HashMap::new(),
|
||||
name_tags: std::collections::HashMap::new(),
|
||||
former_names: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,7 +1049,8 @@ impl Hub {
|
||||
}));
|
||||
}
|
||||
/// ListPlayersCommand's reply: the players in join order (the host of a
|
||||
/// LAN world first), as display names or names with ids, joined by
|
||||
/// LAN world first), as display names (Player.getDisplayName, with the
|
||||
/// team's colour and affixes) or names with ids, joined by
|
||||
/// gray commas (ComponentUtils.formatList).
|
||||
pub fn list_reply(&self, uuids: bool) -> Tag {
|
||||
let players: Vec<(u128, &str)> = self
|
||||
@@ -1047,7 +1072,7 @@ impl Hub {
|
||||
),
|
||||
])
|
||||
} else {
|
||||
display_name(uuid, name)
|
||||
self.player_display_name(uuid, name)
|
||||
}
|
||||
};
|
||||
let names = match players.len() {
|
||||
@@ -1453,10 +1478,7 @@ impl Hub {
|
||||
// not placed.
|
||||
if !held {
|
||||
let joined = C::SystemChat(play::SystemChat {
|
||||
content: yellow_names(
|
||||
"multiplayer.player.joined",
|
||||
vec![self.player_display_name(self.guests[i].uuid, &name)],
|
||||
),
|
||||
content: self.joined_line(uuid, &name),
|
||||
overlay: false,
|
||||
})
|
||||
.encode();
|
||||
@@ -1624,10 +1646,7 @@ impl Hub {
|
||||
};
|
||||
if announce {
|
||||
let joined = C::SystemChat(play::SystemChat {
|
||||
content: yellow_names(
|
||||
"multiplayer.player.joined",
|
||||
vec![self.player_display_name(uuid, &name)],
|
||||
),
|
||||
content: self.joined_line(uuid, &name),
|
||||
overlay: false,
|
||||
})
|
||||
.encode();
|
||||
@@ -3304,6 +3323,8 @@ impl Hub {
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
sync_position: false,
|
||||
stepped: false,
|
||||
idle: false,
|
||||
bare: false,
|
||||
minecart_steps: None,
|
||||
|
||||
@@ -1462,6 +1462,9 @@ pub struct Mob {
|
||||
/// `Entity.needsSync`: knocked back, pushed or jumped since its server
|
||||
/// last sent it, so the next update goes out at once.
|
||||
pub needs_sync: bool,
|
||||
/// `Entity.syncPosition`: it bounced this tick (`restituteMovementAfterCollisions`),
|
||||
/// so its tracker keeps this position as a step of the next move it sends.
|
||||
pub sync_position: bool,
|
||||
/// `getAttributeValue(KNOCKBACK_RESISTANCE)` with the modifiers of the
|
||||
/// items it wears or holds, which the game works out each tick for a mob
|
||||
/// that has any (the mob itself knows no item): `None` for the rest.
|
||||
@@ -1727,6 +1730,8 @@ pub enum MobEvent {
|
||||
ChestOpen {
|
||||
chest: [i32; 3],
|
||||
open: bool,
|
||||
/// The golem, the source of the container's game event.
|
||||
mob: u32,
|
||||
},
|
||||
/// `BeeEnterHiveGoal.start`: the bee goes into its hive (the host stores
|
||||
/// its tag with `BeehiveBlockEntity.addOccupant` and discards it).
|
||||
@@ -2247,6 +2252,12 @@ pub enum MobEvent {
|
||||
Buck {
|
||||
mob: u32,
|
||||
},
|
||||
/// `Entity.unRide`'s `ejectPassengers` before a move (a shulker's
|
||||
/// teleport): whoever rides `mob` gets off where it stood, `from`.
|
||||
EjectPassengers {
|
||||
mob: u32,
|
||||
from: Vec3,
|
||||
},
|
||||
/// A piglin's admiring is over: barter or keep the item.
|
||||
FinishAdmiring {
|
||||
mob: u32,
|
||||
@@ -3263,6 +3274,7 @@ impl Mob {
|
||||
horizontal_collision: false,
|
||||
vertical_collision_below: false,
|
||||
needs_sync: false,
|
||||
sync_position: false,
|
||||
gear_knockback_resistance: None,
|
||||
hurt_time: 0,
|
||||
invulnerable: 0,
|
||||
@@ -4358,6 +4370,10 @@ impl Mob {
|
||||
} else {
|
||||
self.extra.effects.push((effect, ticks, amplifier));
|
||||
}
|
||||
// MobEffect.addAttributeModifiers, then refreshDirtyAttributes.
|
||||
if effect == "minecraft:health_boost" {
|
||||
finalize::health_boost_changed(self);
|
||||
}
|
||||
}
|
||||
pub fn speed_bonus(&self) -> f64 {
|
||||
ai::speed_bonus(self)
|
||||
|
||||
@@ -725,9 +725,9 @@ pub fn tick(
|
||||
super::equine::ai_step(m, w, events);
|
||||
}
|
||||
// LivingEntity.aiStep ends in the water's hurt with or without AI;
|
||||
// a NoAI enderman's hurts still teleport it (Enderman.hurtServer).
|
||||
// a NoAI enderman's or shulker's hurts still teleport it (hurtServer).
|
||||
hurt_by_water(m, w, events);
|
||||
enderman_hurt_teleports(m, w, events);
|
||||
hurt_teleports(m, w, events);
|
||||
// Ravager.aiStep runs outside serverAiStep: NoAI still advances
|
||||
// attack/stun/roar, including the stun-to-roar transition.
|
||||
if m.species() == Species::Ravager {
|
||||
@@ -1314,8 +1314,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
|
||||
// invulnerable time of that tick has already dropped.
|
||||
let was_on_fire = m.fire_ticks > 0;
|
||||
block_contact_damage(m, fire);
|
||||
// Enderman.hurtServer teleports inside the hurt (fire, lava, a block).
|
||||
enderman_hurt_teleports(m, w, events);
|
||||
// Enderman.hurtServer and Shulker.hurtServer teleport inside the hurt
|
||||
// (fire, lava, a block, an effect).
|
||||
hurt_teleports(m, w, events);
|
||||
if !m.alive() {
|
||||
m.death_time += 1;
|
||||
}
|
||||
@@ -1734,7 +1735,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
|
||||
// Mannequin.isEffectiveAi: an immovable one never travels.
|
||||
m.travel(w, input, events);
|
||||
// A fall's hurt (checkFallDamage) teleports at once.
|
||||
enderman_hurt_teleports(m, w, events);
|
||||
hurt_teleports(m, w, events);
|
||||
}
|
||||
// LivingEntity.aiStep: applyEffectsFromBlocks follows travel.
|
||||
m.apply_effects_from_blocks(w, events);
|
||||
@@ -1756,16 +1757,20 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
|
||||
fn hurt_by_water(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) {
|
||||
m.damage(1.0, Damage::of(DamageKind::Drown));
|
||||
enderman_hurt_teleports(m, w, events);
|
||||
hurt_teleports(m, w, events);
|
||||
}
|
||||
}
|
||||
/// The teleport `Enderman.hurtServer` makes inside a hurt, after the hurt's
|
||||
/// sound; with or without AI.
|
||||
fn enderman_hurt_teleports(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
/// The teleport `Enderman.hurtServer` or `Shulker.hurtServer` makes inside a
|
||||
/// hurt, after the hurt's sound; with or without AI.
|
||||
fn hurt_teleports(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if m.species() == Species::Enderman && m.extra.counter2 > 0 {
|
||||
events.append(&mut m.outbox);
|
||||
super::enderman::run_pending_teleports(m, w, events);
|
||||
}
|
||||
if m.species() == Species::Shulker && m.extra.flag {
|
||||
events.append(&mut m.outbox);
|
||||
super::shulker::hurt_teleport(m, w, events);
|
||||
}
|
||||
}
|
||||
impl Mob {
|
||||
/// LivingEntity.isPushable (and the push it takes from `Entity.push(Entity)`):
|
||||
@@ -3107,7 +3112,16 @@ fn tick_effects(m: &mut Mob, mods: Option<&dyn super::ModEffectTicks>) {
|
||||
m.absorption = (m.absorption - 4.0 * (e.2 + 1) as f32).max(0.0);
|
||||
}
|
||||
}
|
||||
let boost_gone = m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.any(|e| e.0 == "minecraft:health_boost" && e.1 <= 0 && e.1 != -1);
|
||||
m.extra.effects.retain(|e| e.1 > 0 || e.1 == -1);
|
||||
// LivingEntity.onEffectsRemoved -> refreshDirtyAttributes.
|
||||
if boost_gone {
|
||||
super::finalize::health_boost_changed(m);
|
||||
}
|
||||
m.extra
|
||||
.hidden_effects
|
||||
.retain(|h| m.extra.effects.iter().any(|e| e.0 == *h));
|
||||
|
||||
@@ -475,6 +475,13 @@ impl Goal for TakeBlock {
|
||||
pos: [x, y, z],
|
||||
state: cx.w.world.air,
|
||||
});
|
||||
// level.gameEvent(BLOCK_DESTROY, pos, Context.of(enderman, blockState)).
|
||||
cx.events.push(MobEvent::GameEvent {
|
||||
event: "minecraft:block_destroy",
|
||||
at: [f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5],
|
||||
source: m.id,
|
||||
state,
|
||||
});
|
||||
m.extra.carried = Some(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,6 +749,20 @@ pub fn zombie_attributes(m: &mut Mob, special: f32, reason: Reason) {
|
||||
pub fn max_health(m: &Mob) -> f32 {
|
||||
value(m, Attr::MaxHealth) as f32
|
||||
}
|
||||
/// `LivingEntity.onAttributeUpdated(MAX_HEALTH)` after a Health Boost
|
||||
/// change: the cached maximum follows the attribute model again, and Health
|
||||
/// above it is lowered (nothing is healed). Sulfur cubes and wolves write
|
||||
/// their maximum straight into the cache (`sulfur_cube::set_size`,
|
||||
/// `tame::set_tame`), outside the model, so they keep theirs.
|
||||
pub fn health_boost_changed(m: &mut Mob) {
|
||||
if matches!(m.species(), Species::SulfurCube | Species::Wolf) {
|
||||
return;
|
||||
}
|
||||
refresh(m, Attr::MaxHealth);
|
||||
if m.health > m.attrs.max_health {
|
||||
m.health = m.attrs.max_health;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- species facts --------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1984,9 +1984,11 @@ impl RestitutionWorld for MobRestitution<'_, '_> {
|
||||
fn bounce_event(&mut self) -> Result<(), ()> {
|
||||
Ok(())
|
||||
}
|
||||
/// Nothing to mark: the bounce is already the velocity of this tick, and
|
||||
/// the mob tracker sends the position and velocity it reads.
|
||||
fn mark_position_for_sync(&mut self) {}
|
||||
/// `Entity.syncPosition`: the tracker keeps where the mob bounced as a
|
||||
/// step of its next move (`SteppedInterpolationTracker.updateTracking`).
|
||||
fn mark_position_for_sync(&mut self) {
|
||||
self.mob.sync_position = true;
|
||||
}
|
||||
fn set_velocity(&mut self, velocity: Vec3) -> Result<(), ()> {
|
||||
self.mob.velocity = velocity;
|
||||
Ok(())
|
||||
|
||||
@@ -271,9 +271,10 @@ fn pack_diff(pos: [i32; 3], to: [i32; 3]) -> i32 {
|
||||
| ((to[1] - pos[1] + 8) & 0xFF) << 8
|
||||
| (to[2] - pos[2] + 8) & 0xFF
|
||||
}
|
||||
/// `Shulker.teleportSomewhere`: up to five tries within 8 blocks.
|
||||
/// `Shulker.teleportSomewhere`: up to five tries within 8 blocks; never
|
||||
/// without AI.
|
||||
fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
|
||||
if !m.alive() {
|
||||
if m.no_ai || !m.alive() {
|
||||
return false;
|
||||
}
|
||||
let old = block_pos(m);
|
||||
@@ -290,6 +291,9 @@ fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
|
||||
);
|
||||
if t[1] <= w.world.bottom_y()
|
||||
|| !w.facts.is_air(w.world.block(t[0], t[1], t[2]))
|
||||
// WorldBorder.isWithinBounds(BlockPos): the block's corner, no margin.
|
||||
|| w.border
|
||||
.is_some_and(|b| !b.is_within_bounds(f64::from(t[0]), f64::from(t[2]), 0.0))
|
||||
|| !no_collision(m, w, unit.deflate(Vec3::new(1.0e-6, 1.0e-6, 1.0e-6)))
|
||||
{
|
||||
continue;
|
||||
@@ -305,6 +309,13 @@ fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// unRide: whoever rides it gets off where it stands, and it gets
|
||||
// off its own vehicle (it lands at the target below).
|
||||
events.push(MobEvent::EjectPassengers {
|
||||
mob: m.id,
|
||||
from: m.position,
|
||||
});
|
||||
super::riders::stop_riding(m, None);
|
||||
m.extra.variant3 = face;
|
||||
events.push(MobEvent::Sound(
|
||||
"entity.shulker.teleport",
|
||||
@@ -528,34 +539,57 @@ pub fn accepts(m: &Mob, source: &Damage) -> bool {
|
||||
/// chance to teleport away.
|
||||
pub fn after_hurt(m: &mut Mob) {
|
||||
if m.health < m.attrs.max_health * 0.5 && m.random.next_int_bound(4).unwrap_or(1) == 0 {
|
||||
// The teleport needs the world: ask for it on the next tick.
|
||||
// The teleport needs the world, which `Mob::damage` has not: whoever
|
||||
// dealt the hurt runs it right after (`hurt_teleport`,
|
||||
// `run_pending_teleports`).
|
||||
m.extra.flag = true;
|
||||
}
|
||||
}
|
||||
/// `Shulker.hurtServer` after a shulker bullet's damage: unless the
|
||||
/// low-health teleport was drawn instead (`else if`), `hitByShulkerBullet`
|
||||
/// runs on the next tick. Whether the shell is open is read here, at hurt
|
||||
/// time, so a goal opening it before that tick cannot change the outcome.
|
||||
/// runs right after the hurt (`run_pending_teleports`). Whether the shell is
|
||||
/// open is read here, at hurt time.
|
||||
pub fn hit_by_bullet(m: &mut Mob) {
|
||||
if m.species() == Species::Shulker && !m.extra.flag && m.extra.counter != 0 {
|
||||
m.extra.flag2 = true;
|
||||
}
|
||||
}
|
||||
/// The deferred teleport of `after_hurt` and `hitByShulkerBullet`.
|
||||
/// Whether a hurt left `Shulker.hurtServer` a teleport to make.
|
||||
pub fn teleport_pending(m: &Mob) -> bool {
|
||||
m.species() == Species::Shulker && (m.extra.flag || m.extra.flag2)
|
||||
}
|
||||
/// `Shulker.hurtServer`'s low-health `teleportSomewhere()`, inside the hurt
|
||||
/// `after_hurt` drew it for (a hurt within the shulker's own tick).
|
||||
pub fn hurt_teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if m.species() == Species::Shulker && m.extra.flag {
|
||||
m.extra.flag = false;
|
||||
teleport(m, w, events);
|
||||
}
|
||||
}
|
||||
/// The teleport `Shulker.hurtServer` makes inside a hurt: the low-health
|
||||
/// `teleportSomewhere()` (`after_hurt`), else a bullet's
|
||||
/// `hitByShulkerBullet()` (`hit_by_bullet`).
|
||||
pub fn run_pending_teleports(
|
||||
m: &mut Mob,
|
||||
w: &MobWorld,
|
||||
level_random: &mut LegacyRandom,
|
||||
events: &mut Vec<MobEvent>,
|
||||
) {
|
||||
hurt_teleport(m, w, events);
|
||||
if m.extra.flag2 {
|
||||
m.extra.flag2 = false;
|
||||
hit_by_shulker_bullet(m, w, level_random, events);
|
||||
}
|
||||
}
|
||||
/// What a hurt left that no one ran right after it (a hurt the game layer
|
||||
/// does not settle at once).
|
||||
pub fn ai_step(
|
||||
m: &mut Mob,
|
||||
w: &MobWorld,
|
||||
level_random: &mut LegacyRandom,
|
||||
events: &mut Vec<MobEvent>,
|
||||
) {
|
||||
if m.extra.flag {
|
||||
m.extra.flag = false;
|
||||
teleport(m, w, events);
|
||||
}
|
||||
if m.extra.flag2 {
|
||||
m.extra.flag2 = false;
|
||||
hit_by_shulker_bullet(m, w, level_random, events);
|
||||
}
|
||||
run_pending_teleports(m, w, level_random, events);
|
||||
}
|
||||
/// `Shulker.hitByShulkerBullet` (only queued by an open shulker): it
|
||||
/// teleports, and leaves a new one of its colour behind unless shulkers
|
||||
|
||||
@@ -307,7 +307,7 @@ fn opener_area(w: &MobWorld, p: [i32; 3]) -> Aabb {
|
||||
/// 3): the opener count changes and the first opener or the last closer
|
||||
/// plays the chest's sound (`ChestBlockEntity.playSound`, at the middle of
|
||||
/// a double chest from its right half, none from its left).
|
||||
fn open_or_close(cx: &mut Bx, p: [i32; 3], open: bool) {
|
||||
fn open_or_close(cx: &mut Bx, p: [i32; 3], open: bool, mob: u32) {
|
||||
let w = cx.w;
|
||||
let count = w
|
||||
.world
|
||||
@@ -347,7 +347,11 @@ fn open_or_close(cx: &mut Bx, p: [i32; 3], open: bool) {
|
||||
let pitch = cx.lr.next_float() * 0.1 + 0.9;
|
||||
cx.events.push(MobEvent::Sound(sound, at, 0.5, pitch));
|
||||
}
|
||||
cx.events.push(MobEvent::ChestOpen { chest: p, open });
|
||||
cx.events.push(MobEvent::ChestOpen {
|
||||
chest: p,
|
||||
open,
|
||||
mob,
|
||||
});
|
||||
}
|
||||
|
||||
impl Transport {
|
||||
@@ -582,7 +586,7 @@ impl Transport {
|
||||
if self.ticks == 1 {
|
||||
// container.startOpen, before setOpenedChestPos.
|
||||
for h in &t.halves {
|
||||
open_or_close(cx, *h, true);
|
||||
open_or_close(cx, *h, true, m.id);
|
||||
}
|
||||
m.extra.opened_chest = Some(t.pos);
|
||||
m.extra.variant3 = state;
|
||||
@@ -597,7 +601,7 @@ impl Transport {
|
||||
// keeps Container's empty list, so a double chest is left
|
||||
// to the openers recheck).
|
||||
if t.halves.len() == 1 && has_open(cx.w, m, t.pos) {
|
||||
open_or_close(cx, t.pos, false);
|
||||
open_or_close(cx, t.pos, false, m.id);
|
||||
}
|
||||
m.extra.opened_chest = None;
|
||||
}
|
||||
|
||||
@@ -1747,7 +1747,7 @@ impl Screen {
|
||||
},
|
||||
Some(50),
|
||||
) => {
|
||||
let mut list = servers::load(&servers_file(saves));
|
||||
let (mut list, mut hidden) = servers::load_all(&servers_file(saves));
|
||||
let mut entry = ServerEntry::new(name.trim(), address.trim());
|
||||
entry.accept_textures = *packs;
|
||||
match index.and_then(|i| list.get_mut(i)) {
|
||||
@@ -1756,9 +1756,20 @@ impl Screen {
|
||||
e.ip = entry.ip;
|
||||
e.accept_textures = entry.accept_textures;
|
||||
}
|
||||
None => list.push(entry),
|
||||
// addServerCallback: ServerList.unhide brings back a hidden entry
|
||||
// for the address, given the new name and icon
|
||||
// (copyNameIconFrom); it keeps its other settings.
|
||||
None => match hidden.iter().position(|h| h.ip == entry.ip) {
|
||||
Some(h) => {
|
||||
let mut e = hidden.remove(h);
|
||||
e.name = entry.name;
|
||||
e.icon = entry.icon;
|
||||
list.push(e);
|
||||
}
|
||||
None => list.push(entry),
|
||||
},
|
||||
}
|
||||
let _ = servers::save(&servers_file(saves), &list);
|
||||
let _ = servers::save_all(&servers_file(saves), &list, &hidden);
|
||||
*self = Self::list(saves);
|
||||
}
|
||||
(
|
||||
@@ -1776,8 +1787,10 @@ impl Screen {
|
||||
) => *self = Self::list(saves),
|
||||
(Self::Disconnected { retry, .. }, Some(55)) => *retry = None,
|
||||
(Self::Direct { address }, Some(52)) => {
|
||||
let address = direct_address(address);
|
||||
remember_direct_join(saves, &address);
|
||||
return Outcome::Connect {
|
||||
address: direct_address(address),
|
||||
address,
|
||||
lan: false,
|
||||
};
|
||||
}
|
||||
@@ -2098,6 +2111,26 @@ impl Screen {
|
||||
}
|
||||
}
|
||||
|
||||
/// `JoinMultiplayerScreen.directJoinCallback`: an address no entry has, shown
|
||||
/// or hidden (`ServerList.get`), gets a hidden one named
|
||||
/// `selectServer.defaultName`, which keeps the server's resource pack and Code
|
||||
/// of Conduct answers. A friend's invite gets none.
|
||||
pub fn remember_direct_join(saves: &Path, address: &str) {
|
||||
if address.starts_with(crate::net::FRIEND) {
|
||||
return;
|
||||
}
|
||||
let file = servers_file(saves);
|
||||
let _guard = SAVING.lock();
|
||||
let (shown, hidden) = servers::load_all(&file);
|
||||
if shown.iter().chain(&hidden).any(|s| s.ip == address) {
|
||||
return;
|
||||
}
|
||||
let name = crate::lang::get_or("selectServer.defaultName", "Minecraft Server");
|
||||
if let Err(e) = servers::add_hidden(&file, ServerEntry::new(&name, address)) {
|
||||
log::warn!("could not save {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct Connection's address; a full invite code pasted there joins the
|
||||
/// friend (a short relay code could be a host name, so it needs Join a Friend).
|
||||
fn direct_address(address: &str) -> String {
|
||||
|
||||
@@ -144,6 +144,10 @@ impl Tracker {
|
||||
pub fn address(&self) -> Option<&str> {
|
||||
self.address.as_deref()
|
||||
}
|
||||
/// That server was found on the local network.
|
||||
pub fn lan(&self) -> bool {
|
||||
self.lan
|
||||
}
|
||||
/// A connection to `address` as `name` starts: a new address starts
|
||||
/// counting anew.
|
||||
pub fn connecting(&mut self, address: &str, name: &str, lan: bool) {
|
||||
|
||||
@@ -695,7 +695,9 @@ pub fn encode_block_entity(
|
||||
if let Some(record) = j.record() {
|
||||
entries.push(("RecordItem", item_to_nbt(record)));
|
||||
}
|
||||
entries.push(("ticks_since_song_started", Tag::Long(j.ticks)));
|
||||
if j.song_clock_present {
|
||||
entries.push(("ticks_since_song_started", Tag::Long(j.ticks)));
|
||||
}
|
||||
}
|
||||
BlockEntity::Beacon(b) => {
|
||||
if let Some(p) = &b.primary {
|
||||
@@ -799,8 +801,10 @@ pub fn encode_block_entity(
|
||||
}
|
||||
}
|
||||
// Encoded above.
|
||||
BlockEntity::DaylightDetector | BlockEntity::CopperGolemStatue(_) | BlockEntity::Mod(_) => {
|
||||
}
|
||||
BlockEntity::DaylightDetector
|
||||
| BlockEntity::Bell
|
||||
| BlockEntity::CopperGolemStatue(_)
|
||||
| BlockEntity::Mod(_) => {}
|
||||
// PotentSulfurBlockEntity.saveAdditional.
|
||||
BlockEntity::PotentSulfur(sulfur) => {
|
||||
entries.push(("countdown", Tag::Int(sulfur.waiting_countdown)))
|
||||
@@ -938,13 +942,18 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
}
|
||||
if id == "minecraft:jukebox" {
|
||||
let record = tag.get("RecordItem").and_then(item_from_nbt);
|
||||
let ticks = number(tag.get("ticks_since_song_started")).unwrap_or(0);
|
||||
let clock = number(tag.get("ticks_since_song_started"));
|
||||
let ticks = clock.unwrap_or(0);
|
||||
// JukeboxBlockEntity.loadAdditional adopts a saved clock separately
|
||||
// from a record-only load. Catalog-dependent load validation is separate.
|
||||
let song_clock_present = record.is_some() && clock.is_some();
|
||||
let playing = record.is_some();
|
||||
return Some((
|
||||
pos,
|
||||
BlockEntity::Jukebox(crate::containers::Jukebox {
|
||||
items: [record],
|
||||
ticks,
|
||||
song_clock_present,
|
||||
playing,
|
||||
}),
|
||||
));
|
||||
@@ -1124,6 +1133,7 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
| BlockEntity::EndGateway(_)
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Bell
|
||||
| BlockEntity::Mod(_) => {}
|
||||
// `BlockEntity.loadAdditional`: the components the block entity keeps.
|
||||
BlockEntity::CopperGolemStatue(components) => {
|
||||
@@ -8753,3 +8763,122 @@ mod far_census {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod jukebox_song_clock_tests {
|
||||
use super::*;
|
||||
use crate::containers::{BlockEntity, Jukebox};
|
||||
|
||||
fn input(clock: Option<i64>, with_record: bool) -> Tag {
|
||||
let record = if with_record {
|
||||
"RecordItem:{id:\"minecraft:music_disc_cat\",count:1,components:{\"minecraft:custom_data\":{h032:1}}},"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let clock = clock.map_or(String::new(), |n| format!("ticks_since_song_started:{n}L,"));
|
||||
nbt::parse_snbt(&format!(
|
||||
"{{id:\"minecraft:jukebox\",x:4,y:200,z:4,{record}{clock}}}"
|
||||
))
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn record_kept(tag: &Tag) {
|
||||
let record = tag.get("RecordItem").expect("actual modeled record");
|
||||
assert_eq!(
|
||||
record.get("id"),
|
||||
Some(&Tag::String("minecraft:music_disc_cat".into()))
|
||||
);
|
||||
assert_eq!(record.get("count"), Some(&Tag::Int(1)));
|
||||
assert_eq!(
|
||||
record
|
||||
.get("components")
|
||||
.and_then(|c| c.get("minecraft:custom_data"))
|
||||
.and_then(|c| c.get("h032")),
|
||||
Some(&Tag::Int(1))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn model_save_preserves_song_clock_presence_without_raw() {
|
||||
let mut failures = Vec::new();
|
||||
let mut check = |label: &str, tag: &Tag, expected: Option<i64>| {
|
||||
let got = tag.get("ticks_since_song_started").cloned();
|
||||
let want = expected.map(Tag::Long);
|
||||
if got != want {
|
||||
failures.push(format!("{label}: {got:?}, expected {want:?}"));
|
||||
}
|
||||
};
|
||||
for (label, clock, with_record) in [
|
||||
("empty", None, false),
|
||||
("record-only", None, true),
|
||||
("loaded-zero", Some(0), true),
|
||||
("loaded-41", Some(41), true),
|
||||
] {
|
||||
let (pos, model) =
|
||||
decode_block_entity(&input(clock, with_record)).expect("actual load path");
|
||||
let BlockEntity::Jukebox(j) = &model else {
|
||||
panic!("wrong modeled type")
|
||||
};
|
||||
assert_eq!(j.record().is_some(), with_record);
|
||||
assert_eq!(j.ticks, clock.unwrap_or(0));
|
||||
// H032 must preserve the separate legacy record-only adoption policy.
|
||||
assert_eq!(j.playing, with_record);
|
||||
let saved = encode_block_entity(pos, &model, "minecraft:jukebox");
|
||||
let command = block_entity_data(pos, &model, "minecraft:jukebox", None);
|
||||
check(label, &saved, clock);
|
||||
check(label, &command, clock);
|
||||
if with_record {
|
||||
record_kept(&saved);
|
||||
record_kept(&command);
|
||||
} else {
|
||||
assert!(saved.get("RecordItem").is_none());
|
||||
}
|
||||
if clock.is_some() {
|
||||
let mut detached = model.clone();
|
||||
let BlockEntity::Jukebox(j) = &mut detached else {
|
||||
unreachable!()
|
||||
};
|
||||
j.playing = false;
|
||||
// Decoded clock state, not this separate legacy flag, owns saving.
|
||||
let saved = encode_block_entity(pos, &detached, "minecraft:jukebox");
|
||||
check("clock independent of legacy playing", &saved, clock);
|
||||
record_kept(&saved);
|
||||
}
|
||||
}
|
||||
let empty = BlockEntity::Jukebox(Jukebox::default());
|
||||
check(
|
||||
"default modeled/no raw",
|
||||
&encode_block_entity((4, 200, 4), &empty, "minecraft:jukebox"),
|
||||
None,
|
||||
);
|
||||
let raw = input(Some(41), true);
|
||||
let (pos, loaded_model) = decode_block_entity(&raw).unwrap();
|
||||
let loaded = encode_block_entity(pos, &loaded_model, "minecraft:jukebox");
|
||||
let BlockEntity::Jukebox(j) = loaded_model else {
|
||||
unreachable!()
|
||||
};
|
||||
let current = BlockEntity::Jukebox(Jukebox {
|
||||
items: j.items,
|
||||
playing: true,
|
||||
..Default::default()
|
||||
});
|
||||
let current = encode_block_entity(pos, ¤t, "minecraft:jukebox");
|
||||
check("modeled record-only/no raw", ¤t, None);
|
||||
record_kept(¤t);
|
||||
// The chunk writer uses this real overlay after encoding its model.
|
||||
// A stale input clock must not resurrect the removed modeled field.
|
||||
let merged = merge_block_entity(&raw, &loaded, ¤t);
|
||||
check("contradictory raw clock", &merged, None);
|
||||
record_kept(&merged);
|
||||
let canonical = crate::block_entity_nbt::canonical(&merged).unwrap();
|
||||
check(
|
||||
"chunk canonical after actual model overlay",
|
||||
&canonical,
|
||||
None,
|
||||
);
|
||||
record_kept(&canonical);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"modeled song clock presence mismatches: {failures:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,8 @@ pub struct WorldEntry {
|
||||
pub file_fixing: bool,
|
||||
/// `BackupStatus.DOWNGRADE`: a newer version saved the world.
|
||||
pub downgrade: bool,
|
||||
/// `LevelSummary.isExperimental`: an experimental feature flag is enabled.
|
||||
pub experimental: bool,
|
||||
}
|
||||
impl WorldEntry {
|
||||
/// `LevelSummary.isCompatible`: the saved series is this build's.
|
||||
@@ -249,6 +251,7 @@ impl WorldEntry {
|
||||
incompatible: None,
|
||||
file_fixing: false,
|
||||
downgrade: false,
|
||||
experimental: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,12 +589,38 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
|
||||
incompatible: crate::save::incompatible_version(&path),
|
||||
file_fixing: crate::save::needs_file_fixes(&path),
|
||||
downgrade: crate::save::downgraded_from(&path).is_some(),
|
||||
experimental: experimental_features(data),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
worlds.sort_by_key(|w| std::cmp::Reverse(w.modified));
|
||||
worlds
|
||||
}
|
||||
/// `FeatureFlags.isExperimental` of `LevelStorageSource.parseFeatureFlagsFromSummary`:
|
||||
/// level.dat's `enabled_features` name a known flag other than `minecraft:vanilla`
|
||||
/// (unknown names are dropped).
|
||||
fn experimental_features(data: &horizon_world::nbt::Tag) -> bool {
|
||||
const EXPERIMENTAL: [&str; 3] = [
|
||||
"minecraft:trade_rebalance",
|
||||
"minecraft:redstone_experiments",
|
||||
"minecraft:minecart_improvements",
|
||||
];
|
||||
let Some(horizon_world::nbt::Tag::List(flags)) = data.get("enabled_features") else {
|
||||
return false;
|
||||
};
|
||||
flags.iter().any(|flag| {
|
||||
let horizon_world::nbt::Tag::String(name) = flag else {
|
||||
return false;
|
||||
};
|
||||
let name = name.to_string_lossy();
|
||||
let id = if name.contains(':') {
|
||||
name
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
EXPERIMENTAL.contains(&id.as_str())
|
||||
})
|
||||
}
|
||||
/// LevelSummary.createInfo: a hardcore world's mode in red (CommonColors.RED),
|
||||
/// else `gameMode.` + its GameType name, then commands and the version.
|
||||
fn world_info(world: &WorldEntry) -> crate::text::Text {
|
||||
@@ -644,6 +673,18 @@ fn world_info(world: &WorldEntry) -> crate::text::Text {
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
// An experimental world says so in yellow (ChatFormatting.YELLOW).
|
||||
let (mode, commands) = if world.experimental {
|
||||
let mode = mode
|
||||
.append(crate::text::Text::literal(format!("{commands}, ")))
|
||||
.append(crate::text::Text::colored(
|
||||
lang::get_or("selectWorld.experimental", "Experimental"),
|
||||
0xFFFF55,
|
||||
));
|
||||
(mode, String::new())
|
||||
} else {
|
||||
(mode, commands)
|
||||
};
|
||||
// A world that asks for a backup shows its version in italics, red when downgrading.
|
||||
let version = crate::text::Text::literal(world.version.clone());
|
||||
let version = if !world.should_backup() {
|
||||
@@ -4121,6 +4162,9 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
}
|
||||
Mode::Multiplayer(screen) => match screen.confirm() {
|
||||
crate::multiplayer::Outcome::Connect { address, lan } => {
|
||||
if matches!(screen, crate::multiplayer::Screen::Direct { .. }) {
|
||||
crate::multiplayer::remember_direct_join(&self.saves, &address);
|
||||
}
|
||||
(Action::Connect { address, lan }, true)
|
||||
}
|
||||
_ => (Action::None, false),
|
||||
|
||||
Loaded 100 of 266 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user