mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
190
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5900ab3879 | ||
|
|
ce3138558b | ||
|
|
fceebdd3e8 | ||
|
|
a933474876 | ||
|
|
a17461dbf2 | ||
|
|
3fc2a90249 | ||
|
|
7af51e09c0 | ||
|
|
2e3b316fd7 | ||
|
|
b552215543 | ||
|
|
a74b1533a2 | ||
|
|
9ebcfff142 | ||
|
|
2f32258d2e | ||
|
|
2d3a627789 | ||
|
|
12c5675af0 | ||
|
|
79c8956a98 | ||
|
|
c47ffefcc6 | ||
|
|
68e78f6768 | ||
|
|
3b1db11a92 | ||
|
|
3ea99036f2 | ||
|
|
18fd1972ae | ||
|
|
7f3e6aadf1 | ||
|
|
9645a614f8 | ||
|
|
58bc4f52fa | ||
|
|
4b3f0a101d | ||
|
|
37c65a6dc0 | ||
|
|
34f3675684 | ||
|
|
9af2c16ff9 | ||
|
|
c7875bf691 | ||
|
|
e47dc48b52 | ||
|
|
531e1769cc | ||
|
|
5e9c26315b | ||
|
|
14c69c4268 | ||
|
|
a7510c356f | ||
|
|
837871e2bb | ||
|
|
ba73ad9640 | ||
|
|
5a25deeea0 | ||
|
|
02c0edb9bd | ||
|
|
eb4b6a221c | ||
|
|
91cae80dcd | ||
|
|
2d46ad8351 | ||
|
|
2d18a39483 | ||
|
|
efc7df84df | ||
|
|
97c18074d3 | ||
|
|
f99cb9f40d | ||
|
|
86f5a5fa51 | ||
|
|
292b9db66d | ||
|
|
a99a0acc44 | ||
|
|
82d3963b02 | ||
|
|
089706379c | ||
|
|
7a9350a76b | ||
|
|
10e6a85f45 | ||
|
|
033b9b7ed9 | ||
|
|
514b4a2d8b | ||
|
|
30d2181127 | ||
|
|
a820b20ed9 | ||
|
|
d015dedd11 | ||
|
|
1884fd4a6e | ||
|
|
0a10abffcb | ||
|
|
dd48a84566 | ||
|
|
eeab8b0f7a | ||
|
|
e3f6f9d4dc | ||
|
|
6dce0e87b1 | ||
|
|
2eb96ddb7c | ||
|
|
d966b1e59b | ||
|
|
7ad4926351 | ||
|
|
9bb3fc077e | ||
|
|
2aa9056259 | ||
|
|
3012234d7a | ||
|
|
8117e2767f | ||
|
|
9d4c247e75 | ||
|
|
bc5ec6fb46 | ||
|
|
e5ebeddb90 | ||
|
|
d0b42fb88e | ||
|
|
bca77d1ac4 | ||
|
|
c7de637f0f | ||
|
|
bcf2a34a10 | ||
|
|
407053f37c | ||
|
|
c79b5be2e4 | ||
|
|
30027e32a6 | ||
|
|
f8582fb678 | ||
|
|
9777b525b7 | ||
|
|
34dd3de8f4 | ||
|
|
1a052041dc | ||
|
|
815c09b1f8 | ||
|
|
7b11005688 | ||
|
|
475f64b79d | ||
|
|
9ca282df1e | ||
|
|
40f8b9600d | ||
|
|
57213668a0 | ||
|
|
ab099b2701 | ||
|
|
a1d3693ff5 | ||
|
|
03d8ffe2c6 | ||
|
|
6cb5e4954e | ||
|
|
2ba33bd146 | ||
|
|
4f6e4e72b1 | ||
|
|
0ee20da35c | ||
|
|
b7749dd0d2 | ||
|
|
ddfa71a46f | ||
|
|
18b544770f | ||
|
|
21fd3a6b06 | ||
|
|
620dfc6e0d | ||
|
|
dfb28e42d0 | ||
|
|
c3a12bf792 | ||
|
|
d601715126 | ||
|
|
e70ead5bfe | ||
|
|
9ae37fcb50 | ||
|
|
b39c4b8dd4 | ||
|
|
8866e618b5 | ||
|
|
7732ce986f | ||
|
|
42ef865c34 | ||
|
|
e8891849a4 | ||
|
|
ee0af20e16 | ||
|
|
9ab0e2b6a8 | ||
|
|
498de72da2 | ||
|
|
7250944a7c | ||
|
|
0104eeaf33 | ||
|
|
04672c9613 | ||
|
|
fa4f38f824 | ||
|
|
ac33fb74ec | ||
|
|
729a224eef | ||
|
|
14dac749e3 | ||
|
|
10d0132a53 | ||
|
|
209fa169cd | ||
|
|
17fa6e826c | ||
|
|
66bec37b55 | ||
|
|
0fe747ad51 | ||
|
|
e38e25c1a8 | ||
|
|
5be7c02d0c | ||
|
|
e11471cda0 | ||
|
|
731a35612a | ||
|
|
22054eee90 | ||
|
|
b1450f1ef2 | ||
|
|
5ac1c82eb9 | ||
|
|
abdaf71ecd | ||
|
|
e184ab7a42 | ||
|
|
6384234e37 | ||
|
|
3caf28c4d6 | ||
|
|
52f0e38d41 | ||
|
|
4c9075a072 | ||
|
|
7ae20a5b29 | ||
|
|
4e82a62489 | ||
|
|
67b6e75c0b | ||
|
|
64cd55cf74 | ||
|
|
8909e0510c | ||
|
|
5b5ebfcce3 | ||
|
|
ef0714a99a | ||
|
|
66607c9eab | ||
|
|
1a80673d50 | ||
|
|
faeff96cf9 | ||
|
|
43e407562f | ||
|
|
bafd010865 | ||
|
|
a55c825467 | ||
|
|
6c716f457b | ||
|
|
024415e09d | ||
|
|
b90f9ec1ef | ||
|
|
951b88a3c2 | ||
|
|
dca6ebba0e | ||
|
|
6af81b600f | ||
|
|
317f30427e | ||
|
|
91f1f839ab | ||
|
|
1c6c6c0b97 | ||
|
|
a1761dcb65 | ||
|
|
cf6b91a710 | ||
|
|
2b2aecd975 | ||
|
|
766bfbcdfa | ||
|
|
d75f767925 | ||
|
|
76d748caf8 | ||
|
|
d62a1e5d20 | ||
|
|
1862395e4b | ||
|
|
2c8b933be4 | ||
|
|
077da2e06b | ||
|
|
5589edb118 | ||
|
|
f661443fa9 | ||
|
|
442d52a038 | ||
|
|
004ad993d9 | ||
|
|
42a57b8286 | ||
|
|
7a3a19697a | ||
|
|
de141bb7cb | ||
|
|
8526305faa | ||
|
|
5179f97582 | ||
|
|
b37528f5d6 | ||
|
|
2a98f584df | ||
|
|
e50eb7c71f | ||
|
|
cfd2dc8392 | ||
|
|
c987778e97 | ||
|
|
9841ce7824 | ||
|
|
83a6f5c7b9 | ||
|
|
9e71837df9 | ||
|
|
d338bcd070 | ||
|
|
408a75aeb3 |
No files matched your search
@@ -61,6 +61,16 @@ fn f32_tag(v: f32) -> Tag {
|
||||
Tag::Float(v.to_bits())
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.loadAdditional`: BlockPos.CODEC and Level.isInSpawnableBounds.
|
||||
pub fn gateway_exit(tag: &Tag) -> Option<[i32; 3]> {
|
||||
let Tag::IntArray(v) = tag else { return None };
|
||||
let p: [i32; 3] = v.as_slice().try_into().ok()?;
|
||||
((-30_000_000..30_000_000).contains(&p[0])
|
||||
&& (-20_000_000..20_000_000).contains(&p[1])
|
||||
&& (-30_000_000..30_000_000).contains(&p[2]))
|
||||
.then_some(p)
|
||||
}
|
||||
|
||||
/// What a block entity reads and writes, in `saveAdditional` order.
|
||||
struct Fields<'a> {
|
||||
input: &'a Tag,
|
||||
@@ -424,10 +434,7 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
}
|
||||
"minecraft:end_gateway" => {
|
||||
f.long("Age", 0);
|
||||
f.keep("exit_portal", |t| {
|
||||
is_pos(t)
|
||||
&& matches!(t, Tag::IntArray(v) if v[1].abs() <= 30_000_000 && v[0].abs() <= 30_000_000 && v[2].abs() <= 30_000_000)
|
||||
});
|
||||
f.keep("exit_portal", |t| gateway_exit(t).is_some());
|
||||
if flag(tag.get("ExactTeleport")) == Some(true) {
|
||||
f.put("ExactTeleport", boolean(true));
|
||||
}
|
||||
|
||||
@@ -242,6 +242,13 @@ mod tests {
|
||||
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
|
||||
let ru = Language::load(&[&empty], "ru_ru");
|
||||
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
|
||||
// Every bundled string carries both languages: a key added to one file
|
||||
// and forgotten in the other shows as a key name on screen.
|
||||
for key in en.map().keys() {
|
||||
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
|
||||
}
|
||||
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
|
||||
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
|
||||
"horizon.options.chat_delay": "Chat Delay",
|
||||
"horizon.options.lightbar.health": "Health",
|
||||
"horizon.options.lightbar.dimension": "Dimension",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Focus",
|
||||
"horizon.options.menu_pointer": "Pointer",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
|
||||
"horizon.options.search": "Search settings...",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader Packs",
|
||||
"horizon.pages.controller": "Controller Settings",
|
||||
"horizon.pages.controller_binds": "Controller Binds",
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
|
||||
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
|
||||
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
|
||||
"horizon.pages.open.graphics": "Graphics...",
|
||||
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
|
||||
"horizon.pages.open.shader_packs": "Shader Packs...",
|
||||
"horizon.pages.open.controller": "Controller Settings...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "AI Agent...",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
|
||||
"horizon.pages.open.controller_binds": "Controller Binds...",
|
||||
"horizon.pages.open.horizon_settings": "Horizon Settings...",
|
||||
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
|
||||
"horizon.mods.stopped": " (Stopped)",
|
||||
"horizon.mods.dev": " (Dev)",
|
||||
"horizon.mods.settings": "Settings",
|
||||
"horizon.mods.open_folder": "Open Mod Folder",
|
||||
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Copy Connection Details",
|
||||
"horizon.multiplayer.join": "Join",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
|
||||
"horizon.multiplayer.join_friend": "Join a Friend",
|
||||
"horizon.multiplayer.invite_code": "Invite Code",
|
||||
"horizon.multiplayer.relay": "Relay (optional)"
|
||||
}
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Мёртвая зона обзора",
|
||||
"horizon.options.button_glyphs": "Символы кнопок",
|
||||
"horizon.options.menu_navigation": "Навигация по меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
|
||||
"horizon.options.chat_delay": "Задержка чата",
|
||||
"horizon.options.lightbar.health": "Здоровье",
|
||||
"horizon.options.lightbar.dimension": "Измерение",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Указатель",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
|
||||
"horizon.options.search": "Поиск настроек...",
|
||||
"horizon.pages.mods": "Моды",
|
||||
"horizon.pages.shader_packs": "Шейдерпаки",
|
||||
"horizon.pages.controller": "Настройки контроллера",
|
||||
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
|
||||
"horizon.pages.extras": "Дополнения Horizon",
|
||||
"horizon.pages.settings": "Настройки Horizon",
|
||||
"horizon.pages.agent": "ИИ-агент",
|
||||
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
|
||||
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
|
||||
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
|
||||
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
|
||||
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
|
||||
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
|
||||
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
|
||||
"horizon.pages.open.graphics": "Графика...",
|
||||
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
|
||||
"horizon.pages.open.shader_packs": "Шейдерпаки...",
|
||||
"horizon.pages.open.controller": "Настройки контроллера...",
|
||||
"horizon.pages.open.extras": "Дополнения...",
|
||||
"horizon.pages.open.mods": "Моды...",
|
||||
"horizon.pages.open.agent": "ИИ-агент...",
|
||||
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
|
||||
"horizon.pages.open.controller_binds": "Назначения кнопок...",
|
||||
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
|
||||
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
|
||||
"horizon.mods.stopped": " (остановлен)",
|
||||
"horizon.mods.dev": " (разработка)",
|
||||
"horizon.mods.settings": "Настройки",
|
||||
"horizon.mods.open_folder": "Открыть папку модов",
|
||||
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Скопировать данные подключения",
|
||||
"horizon.multiplayer.join": "Подключиться",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
|
||||
"horizon.multiplayer.join_friend": "Играть с другом",
|
||||
"horizon.multiplayer.invite_code": "Код приглашения",
|
||||
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
|
||||
}
|
||||
@@ -142,8 +142,8 @@ const GENES: [&str; 7] = [
|
||||
"aggressive",
|
||||
];
|
||||
const MEMORIES: [&str; 7] = [
|
||||
"long_jump_cooldown",
|
||||
"ram_cooldown",
|
||||
"long_jump_cooling_down",
|
||||
"ram_cooldown_ticks",
|
||||
"attack_target_cooldown",
|
||||
"hunted_recently",
|
||||
"dig_cooldown",
|
||||
@@ -154,11 +154,11 @@ const MEMORIES: [&str; 7] = [
|
||||
fn variant(m: &Mob, regs: &SpawnRegistries, v: &mut Vec<String>) {
|
||||
use Species as S;
|
||||
let s = m.species();
|
||||
let named = |reg: &finalize::VariantRegistry, i: u8| {
|
||||
format!(
|
||||
"minecraft:{}",
|
||||
let named = |reg: &finalize::VariantRegistry, i: Option<u8>| {
|
||||
let name = i.map_or("classic", |i| {
|
||||
reg.names.get(i as usize).map_or("?", |n| n.as_str())
|
||||
)
|
||||
});
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let var = format!("minecraft:{}", finalize::variant_name(s, m.extra.variant));
|
||||
match s {
|
||||
@@ -506,6 +506,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
difficulty,
|
||||
data: &data,
|
||||
created: Vec::new(),
|
||||
jockey_rider: None,
|
||||
};
|
||||
group = finalize::finalize(&mut cx, &mut mob, reason, group);
|
||||
let created = std::mem::take(&mut cx.created);
|
||||
|
||||
@@ -792,7 +792,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
chunk,
|
||||
&mut gen_random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |l, r, _| spawn(l, r).is_some(),
|
||||
&mut |e| match e {
|
||||
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
|
||||
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//!
|
||||
//! usage: recipe_book_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::recipe_book::{BookType, RecipeIndex, Settings};
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::recipes::RecipeBookChangeSettings,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
@@ -93,8 +97,23 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
_ => BookType::Smoker,
|
||||
}
|
||||
.index();
|
||||
book.open[i] = op["open"].as_bool().unwrap_or(false);
|
||||
book.filtering[i] = op["filtering"].as_bool().unwrap_or(false);
|
||||
let packet = RecipeBookChangeSettings {
|
||||
book_type: i as i32,
|
||||
open: op["open"].as_bool().unwrap_or(false),
|
||||
filtering: op["filtering"].as_bool().unwrap_or(false),
|
||||
};
|
||||
let mut wire = Writer::new();
|
||||
RecipeBookChangeSettings::write(&packet, &mut wire);
|
||||
extra = format!(
|
||||
" packet={}",
|
||||
wire.data
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
);
|
||||
let decoded = RecipeBookChangeSettings::read(&mut Reader::new(&wire.data))?;
|
||||
book.open[decoded.book_type as usize] = decoded.open;
|
||||
book.filtering[decoded.book_type as usize] = decoded.filtering;
|
||||
}
|
||||
"save" => {
|
||||
let tag = book.write();
|
||||
|
||||
@@ -179,7 +179,7 @@ fn agent() -> ureq::Agent {
|
||||
}
|
||||
|
||||
/// The discovery document, fetched once per game (a failure is retried next time).
|
||||
fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
|
||||
if let Some(d) = CACHE.lock().unwrap().clone() {
|
||||
return Ok(d);
|
||||
|
||||
@@ -450,6 +450,54 @@ mod tests {
|
||||
assert_eq!(view_chunks(32), 2573);
|
||||
assert_eq!(view_chunks(0), 0);
|
||||
}
|
||||
/// The status a fake control answers with: the player is where it should
|
||||
/// be, nothing is queued, and the world holds `chunks` resident chunks.
|
||||
fn status(chunks: u64) -> String {
|
||||
serde_json::json!({
|
||||
"player": {"position": [0.0, 64.0, 0.0]},
|
||||
"pending_chunks": 0,
|
||||
"screen": "playing",
|
||||
"render_distance": 12,
|
||||
"chunks": chunks,
|
||||
"generated_chunks": chunks,
|
||||
"meshed_sections": 0,
|
||||
"streaming": {},
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_load_waits_for_the_chunks_a_teleport_starts() {
|
||||
// Right after a teleport the game reports steady counts and nothing
|
||||
// queued for over a second, before the integrated server has sent the
|
||||
// first chunk: the quiet rule alone would call that loaded, and the
|
||||
// scene would be measured over an empty view (#1749).
|
||||
const LATE: usize = 30;
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counted = std::sync::Arc::clone(&polls);
|
||||
let server = thread::spawn(move || {
|
||||
let mut seen = 0;
|
||||
while let Ok(request) = rx.recv() {
|
||||
if request.line == "status" {
|
||||
let chunks = if seen < LATE { 0 } else { 400 };
|
||||
seen += 1;
|
||||
counted.store(seen, std::sync::atomic::Ordering::SeqCst);
|
||||
let _ = request.reply.send(status(chunks));
|
||||
} else {
|
||||
let _ = request.reply.send("{}".to_owned());
|
||||
}
|
||||
}
|
||||
});
|
||||
let control = Control(tx);
|
||||
wait_loaded(&control, |_| true).expect("the scene loads");
|
||||
assert!(
|
||||
polls.load(std::sync::atomic::Ordering::SeqCst) > LATE,
|
||||
"the load finished before the chunks arrived"
|
||||
);
|
||||
drop(control);
|
||||
let _ = server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_matches_vanilla() {
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
//! (`entity-models.json`); transforms, animation and textures follow each
|
||||
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
|
||||
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
|
||||
use crate::{hud::Rect, mesher::Vertex, world::World};
|
||||
use crate::{
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
world::World,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_core::geometry::{
|
||||
Direction,
|
||||
@@ -149,6 +153,8 @@ pub fn draw_part(
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -648,6 +654,8 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
layer: 0,
|
||||
color: c,
|
||||
light: call.light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -734,7 +742,7 @@ pub struct Scene<'a> {
|
||||
/// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
|
||||
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
|
||||
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
}
|
||||
@@ -1286,11 +1294,12 @@ impl BlockEntityRenderer {
|
||||
};
|
||||
// SkullBlockRenderer.getRenderType: the profile's skin when it
|
||||
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
|
||||
let texture = if *kind == SkullType::Player {
|
||||
(scene.skull)(pos).unwrap_or(texture)
|
||||
let skin = if *kind == SkullType::Player {
|
||||
(scene.skull)(pos)
|
||||
} else {
|
||||
texture
|
||||
None
|
||||
};
|
||||
let texture = skin.as_deref().unwrap_or(texture);
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
@@ -1385,6 +1394,7 @@ impl BlockEntityRenderer {
|
||||
m *= around(Mat4::from_rotation_y(turn * decay), pivot);
|
||||
}
|
||||
}
|
||||
let first = out.len();
|
||||
if let Some(paint) =
|
||||
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
|
||||
{
|
||||
@@ -1402,6 +1412,11 @@ impl BlockEntityRenderer {
|
||||
draw_part(part, side, m, &none, paint, out);
|
||||
}
|
||||
}
|
||||
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
|
||||
// back faces: the flat `top` and `bottom` planes show one side each.
|
||||
for vertex in &mut out[first..] {
|
||||
vertex.normal = CULL_BACK;
|
||||
}
|
||||
}
|
||||
Kind::EndPortal => {
|
||||
// TheEndPortalRenderer.TRANSFORMATION: y from 6/16 to 12/16; only the
|
||||
@@ -1714,6 +1729,8 @@ impl BeamPart {
|
||||
layer: 6,
|
||||
color,
|
||||
light: self.wrap,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1784,6 +1801,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
|
||||
layer,
|
||||
color: 0,
|
||||
light: 0,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2108,4 +2126,70 @@ mod tests {
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
}
|
||||
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
|
||||
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
|
||||
/// boxes, so without that cull both sides of each plane are drawn and they
|
||||
/// z-fight: the pot renders wrong.
|
||||
#[test]
|
||||
fn the_decorated_pot_culls_its_back_faces() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let registry = assets.registry;
|
||||
let renderer = BlockEntityRenderer::load(&root, registry).unwrap();
|
||||
let state = registry.default_state("minecraft:decorated_pot").unwrap();
|
||||
let sprites: HashMap<String, Rect> = [
|
||||
"entity/decorated_pot/decorated_pot_base",
|
||||
"entity/decorated_pot/decorated_pot_side",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
(
|
||||
name.to_owned(),
|
||||
Rect {
|
||||
u0: 0.25,
|
||||
v0: 0.125,
|
||||
u1: 0.375,
|
||||
v1: 0.25,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.5,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
|
||||
// Vanilla submits 28 quads for a plain pot, and the cull keeps them all.
|
||||
assert_eq!(
|
||||
verts.len() / 4,
|
||||
28,
|
||||
"the pot submits all of vanilla's quads"
|
||||
);
|
||||
assert!(
|
||||
verts.iter().all(|v| v.normal == CULL_BACK),
|
||||
"entitySolid culls the pot's back faces"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ pub fn geometry(
|
||||
color: 0xffff_ffff,
|
||||
light: stage_light(stage),
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,14 +76,11 @@ impl<'a> IntoIterator for &'a ChunkMap {
|
||||
}
|
||||
}
|
||||
impl crate::light::LightWorld for ChunkMap {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
self.columns.state(x, y, z)
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<crate::chunk::Section>> {
|
||||
self.columns.section(cx, s, cz)
|
||||
}
|
||||
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
self.columns.light(x, y, z)
|
||||
}
|
||||
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
|
||||
self.columns.set_light(x, y, z, packed);
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
|
||||
self.columns.set_section_light(cx, s, cz, light);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +103,13 @@ mod tests {
|
||||
for column in map.values_mut() {
|
||||
Arc::make_mut(column).light_loaded = true;
|
||||
}
|
||||
crate::light::LightWorld::set_light(&mut map, 0, 0, 0, 0);
|
||||
crate::light::LightWorld::set_section_light(
|
||||
&mut map,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
crate::chunk::Light::Uniform(0),
|
||||
);
|
||||
assert_eq!(inserted, map.membership_revision());
|
||||
map.remove(&(0, 0));
|
||||
map.insert((1, 0), chunk);
|
||||
|
||||
@@ -179,6 +179,8 @@ impl Mesh {
|
||||
layer: 4,
|
||||
color: u32::MAX,
|
||||
light: shade,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,6 +54,27 @@ fn strider_liquid_shape() -> VoxelShape {
|
||||
VoxelShape::from_cube([1, 2, 1], &[true, false]).expect("a valid half-block column")
|
||||
}
|
||||
|
||||
// Vanilla scopes this direction to Entity.move while a piston pushes it.
|
||||
thread_local! {
|
||||
static PISTON_NOCLIP: std::cell::Cell<Option<horizon_core::geometry::Direction>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Suppress the moving part of pistons moving in this direction during a push.
|
||||
pub fn with_piston_noclip<T>(
|
||||
direction: horizon_core::geometry::Direction,
|
||||
f: impl FnOnce() -> T,
|
||||
) -> T {
|
||||
struct Reset(Option<horizon_core::geometry::Direction>);
|
||||
impl Drop for Reset {
|
||||
fn drop(&mut self) {
|
||||
PISTON_NOCLIP.with(|value| value.set(self.0));
|
||||
}
|
||||
}
|
||||
let previous = PISTON_NOCLIP.with(|value| value.replace(Some(direction)));
|
||||
let _reset = Reset(previous);
|
||||
f()
|
||||
}
|
||||
|
||||
pub struct SceneCollisions<'w, 'r> {
|
||||
pub world: &'w World,
|
||||
pub binding: &'w BlockCollisionRegistry<'r>,
|
||||
@@ -140,8 +161,21 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
|
||||
border.collision_shape().map(Some).map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes.
|
||||
impl<'a, W> EntityCollisions<'a, W>
|
||||
where
|
||||
W: BlockCollisionWorld<Error = io::Error> + EntityMovementWorld<Error = io::Error>,
|
||||
{
|
||||
/// `EntityCollisions` for a body the world border also stops: the border
|
||||
/// and the body's own x and z (`WorldBorder.isInsideCloseToBorder`).
|
||||
pub fn with_border(blocks: W, border: &'a BorderGeometry, at: [f64; 2]) -> Self {
|
||||
Self {
|
||||
blocks,
|
||||
entities: &[],
|
||||
border: Some((border, at)),
|
||||
}
|
||||
}
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes and,
|
||||
/// with a border, at the world border's walls.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
@@ -149,12 +183,11 @@ impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.blocks.context(bounds);
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
context: CollisionContext::position(bounds.min_position().y),
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
@@ -324,7 +357,7 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> {
|
||||
return Ok(None);
|
||||
};
|
||||
pistons
|
||||
.collision_shape(self, p, piston, None)
|
||||
.collision_shape(self, p, piston, PISTON_NOCLIP.with(|value| value.get()))
|
||||
.map(Some)
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
@@ -388,6 +421,42 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A collision world without blocks: no chunk is resident, so only the
|
||||
/// world border stops a body.
|
||||
pub struct EmptyCollisions;
|
||||
|
||||
impl BlockCollisionWorld for EmptyCollisions {
|
||||
type Error = io::Error;
|
||||
type Chunk = ChunkPos;
|
||||
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<ChunkPos>> {
|
||||
Ok(None)
|
||||
}
|
||||
fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result<CollisionBlock> {
|
||||
Err(io::Error::other("no chunk is resident"))
|
||||
}
|
||||
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
_: CollisionBlock,
|
||||
_: BlockPos,
|
||||
_: &CollisionContext<'_>,
|
||||
) -> io::Result<VoxelShape> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityMovementWorld for EmptyCollisions {
|
||||
type Error = io::Error;
|
||||
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collision queries over any block lookup (test worlds without chunks):
|
||||
/// every chunk is present, nothing is suffocating, no entities collide.
|
||||
pub struct LookupCollisions<'w, 'r> {
|
||||
@@ -475,3 +544,70 @@ impl PowderSnowWorld for LookupCollisions<'_, '_> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod piston_noclip_tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Direction;
|
||||
|
||||
#[test]
|
||||
fn piston_noclip_is_scoped_and_restored_after_unwind() {
|
||||
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
|
||||
with_piston_noclip(Direction::East, || {
|
||||
assert_eq!(
|
||||
PISTON_NOCLIP.with(|value| value.get()),
|
||||
Some(Direction::East)
|
||||
);
|
||||
let _ =
|
||||
std::panic::catch_unwind(|| with_piston_noclip(Direction::West, || panic!("test")));
|
||||
assert_eq!(
|
||||
PISTON_NOCLIP.with(|value| value.get()),
|
||||
Some(Direction::East)
|
||||
);
|
||||
});
|
||||
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod border_movement_tests {
|
||||
use super::*;
|
||||
|
||||
/// A border 10 wide around the origin: its wall's inner face is at x = 5.
|
||||
fn border() -> BorderGeometry {
|
||||
BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border")
|
||||
}
|
||||
|
||||
/// `WorldBorder.isInsideCloseToBorder` and `WorldBorder.getCollisionShape`:
|
||||
/// a body inside close to the border collides with the border's walls.
|
||||
#[test]
|
||||
fn a_body_next_to_the_border_stops_at_its_wall() {
|
||||
// A body 0.6 wide, 0.4 blocks short of the wall, moved east into it.
|
||||
let bounds = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6));
|
||||
let border = border();
|
||||
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.3, 0.3]);
|
||||
let moved = collisions
|
||||
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
|
||||
.expect("the movement resolves");
|
||||
// The wall stops the body 0.4 blocks short of the whole move.
|
||||
assert!(
|
||||
(moved.x - 0.4).abs() < 1.0e-9,
|
||||
"the body was not stopped by the border: {moved:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Away from the border, the same body moves the whole way.
|
||||
#[test]
|
||||
fn a_body_away_from_the_border_moves_whole() {
|
||||
let bounds = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6));
|
||||
let border = border();
|
||||
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [0.3, 0.3]);
|
||||
let moved = collisions
|
||||
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
|
||||
.expect("the movement resolves");
|
||||
assert!(
|
||||
(moved.x - 0.51).abs() < 1.0e-9,
|
||||
"the body did not move the whole way: {moved:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,18 @@ impl Clicks {
|
||||
self.skip_next_release = true;
|
||||
}
|
||||
|
||||
/// The screen that takes the pointer over starts with its own state:
|
||||
/// `AbstractContainerScreen` holds `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen, so a screen that opens (or closes) drops
|
||||
/// them. A press whose release never arrived -- the pointer left the
|
||||
/// window -- would otherwise keep the drag running, and `press` answers
|
||||
/// `None` for every later press, so the quick move and the clicks go
|
||||
/// dead on every screen after it.
|
||||
pub fn cancel_drag(&mut self) {
|
||||
self.quick_crafting = None;
|
||||
self.skip_next_release = false;
|
||||
}
|
||||
|
||||
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
|
||||
/// `target`, if any. A press holding a stack starts a quick craft drag
|
||||
/// instead. `cloning` is `keyPickItem` with infinite materials.
|
||||
@@ -301,4 +313,35 @@ mod tests {
|
||||
Some(Input::Pickup)
|
||||
);
|
||||
}
|
||||
|
||||
/// #1737: a press whose release never arrives (the pointer left the
|
||||
/// window) leaves `isQuickCrafting` set, and `press` answers `None` for
|
||||
/// every later press, so the Shift quick move and the clicks on the
|
||||
/// inventory go dead. The drag belongs to the screen that has it
|
||||
/// (`AbstractContainerScreen` holds `isQuickCrafting`), so the screen
|
||||
/// that takes the pointer over drops it and the quick move works again.
|
||||
#[test]
|
||||
fn a_lost_release_does_not_wedge_the_quick_move() {
|
||||
let mut clicks = Clicks::default();
|
||||
let slot = Target::<usize>::Slot(3);
|
||||
// Pressed with a stack carried: a quick craft drag starts, and its
|
||||
// release never arrives.
|
||||
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
|
||||
assert!(clicks.quick_crafting().is_some());
|
||||
// The stuck drag swallows the next press: the Shift quick move stops
|
||||
// working and the inventory stops answering.
|
||||
assert_eq!(clicks.press(&slot, Button::Right, true, true, false), None);
|
||||
// The screen that takes the pointer over starts with its own state.
|
||||
clicks.cancel_drag();
|
||||
assert!(clicks.quick_crafting().is_none());
|
||||
// The quick move works again.
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Right, true, true, false),
|
||||
Some(Input::QuickMove)
|
||||
);
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Left, true, false, false),
|
||||
Some(Input::Pickup)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,10 +98,19 @@ pub enum BlockEntity {
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
/// `TheEndGatewayBlockEntity`: its age and configured destination.
|
||||
EndGateway(EndGateway),
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EndGateway {
|
||||
pub age: i64,
|
||||
pub exit_portal: Option<[i32; 3]>,
|
||||
pub exact_teleport: bool,
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -190,6 +199,9 @@ impl Skull {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkullSkin {
|
||||
pub download: Option<(String, String)>,
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<crate::skins::NameOrId>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
impl SkullSkin {
|
||||
@@ -198,10 +210,21 @@ impl SkullSkin {
|
||||
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
// A `Dynamic` profile (a name or id only) resolves through the
|
||||
// services when the skull is drawn.
|
||||
let dynamic = crate::skins::dynamic_nbt(profile);
|
||||
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
|
||||
let fallback = crate::skins::profile_uuid_nbt(profile)
|
||||
.or_else(|| {
|
||||
crate::skins::nbt_text(profile.get("name"))
|
||||
.map(|name| horizon_protocol::crypto::offline_uuid(&name))
|
||||
})
|
||||
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
|
||||
Self { download, fallback }
|
||||
Self {
|
||||
download,
|
||||
dynamic,
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
|
||||
@@ -366,6 +389,9 @@ impl BlockEntity {
|
||||
if is_skull(name) {
|
||||
return Some(Self::Skull(Skull::default()));
|
||||
}
|
||||
if name == "minecraft:end_gateway" {
|
||||
return Some(Self::EndGateway(EndGateway::default()));
|
||||
}
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
@@ -418,6 +444,7 @@ impl BlockEntity {
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
}
|
||||
@@ -436,6 +463,7 @@ impl BlockEntity {
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -456,6 +484,7 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
@@ -480,6 +509,7 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! End gateway travel for the entities that step into one:
|
||||
//! `EndGatewayBlock.entityInside` (the gateway takes the entity in and starts
|
||||
//! its 40 tick cooldown), `Entity.setAsInsidePortal` and `Entity.handlePortal`
|
||||
//! (the entity goes through on the next tick), and
|
||||
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
|
||||
use crate::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// The End gateway travel of one entity.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Traveller {
|
||||
/// The entity's portal process and cooldown (`Entity.portalProcess`).
|
||||
state: PortalState,
|
||||
}
|
||||
|
||||
impl Traveller {
|
||||
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
|
||||
/// through it on the next tick.
|
||||
pub fn enter(&mut self, gateway: BlockPos) {
|
||||
self.state.set_inside(gateway, ENTITY_DELAY);
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal`: the entity goes through the gateway it entered
|
||||
/// (the End gateway has no transition time), or stays where it is.
|
||||
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
|
||||
self.state.handle(allowed, 0, ENTITY_DELAY)
|
||||
}
|
||||
|
||||
/// Whether the entity's travel is over: it has no portal process left and
|
||||
/// its portal cooldown has run out.
|
||||
pub fn is_done(&self) -> bool {
|
||||
self.state.cooldown == 0 && self.state.process.is_none()
|
||||
}
|
||||
|
||||
/// The entity's portal cooldown, the ticks it stays out of a portal.
|
||||
pub fn cooldown(&self) -> i32 {
|
||||
self.state.cooldown
|
||||
}
|
||||
}
|
||||
|
||||
/// The End gateway block an entity's box swept through
|
||||
/// (`Entity.checkInsideBlocks`, which calls `EndGatewayBlock.entityInside`).
|
||||
pub fn swept_gateway(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
mut is_gateway: impl FnMut(BlockPos) -> bool,
|
||||
) -> Option<BlockPos> {
|
||||
let half = f64::from(width) / 2.0;
|
||||
let bounds = horizon_core::geometry::Aabb::new(
|
||||
Vec3::new(to.x - half, to.y, to.z - half),
|
||||
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
|
||||
)
|
||||
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
|
||||
let mut found = None;
|
||||
let _ =
|
||||
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, _| {
|
||||
if is_gateway(p) {
|
||||
found = Some(p);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
|
||||
/// lands at a gateway's exit.
|
||||
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The gateway of the issue: a block at [702, 100, 0] in the End.
|
||||
const GATEWAY: [i32; 3] = [702, 100, 0];
|
||||
|
||||
fn at(x: f64, y: f64, z: f64) -> Vec3 {
|
||||
Vec3::new(x, y, z)
|
||||
}
|
||||
|
||||
fn is_gateway(p: BlockPos) -> bool {
|
||||
[p.x, p.y, p.z] == GATEWAY
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_and_a_mob_in_a_gateway_are_found_in_it() {
|
||||
// A dropped item, 0.25 wide, standing in the gateway block.
|
||||
let item = at(702.5, 100.5, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(item, item, 0.25, 0.25, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
// A cow, 0.9 wide and 1.4 tall, in the same block.
|
||||
let cow = at(702.5, 100.0, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(cow, cow, 0.9, 1.4, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_out_of_the_gateway_is_not_in_it() {
|
||||
// Two blocks above the gateway: nothing to travel through.
|
||||
let above = at(702.5, 102.5, 0.5);
|
||||
assert_eq!(swept_gateway(above, above, 0.25, 0.25, is_gateway), None);
|
||||
// Beside the gateway, outside its block.
|
||||
let beside = at(704.5, 100.5, 0.5);
|
||||
assert_eq!(swept_gateway(beside, beside, 0.9, 1.4, is_gateway), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_falling_through_the_gateway_goes_through_it() {
|
||||
// A cow falling past the gateway block passes through it
|
||||
// (Entity.checkInsideBlocks visits the blocks along the way).
|
||||
let from = at(702.5, 103.0, 0.5);
|
||||
let to = at(702.5, 99.0, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(from, to, 0.9, 1.4, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_entity_goes_through_the_gateway_on_the_tick_after_it_entered() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
let mut traveller = Traveller::default();
|
||||
// Nothing happens while the entity is outside the gateway.
|
||||
assert_eq!(traveller.travel(true), None);
|
||||
// It enters the gateway, and goes through on the next tick.
|
||||
traveller.enter(gateway);
|
||||
assert_eq!(traveller.travel(true), Some(gateway));
|
||||
// Its portal cooldown keeps it from going back at once.
|
||||
assert_eq!(traveller.cooldown(), ENTITY_DELAY);
|
||||
assert!(!traveller.is_done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
let mut traveller = Traveller::default();
|
||||
traveller.enter(gateway);
|
||||
// A mob that cannot use a portal does not go through.
|
||||
assert_eq!(traveller.travel(false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
|
||||
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
|
||||
// centre, the same spot the player arrives at.
|
||||
assert_eq!(bottom_center([8, 100, 0]), at(8.5, 100.0, 0.5));
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ struct Globals {
|
||||
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
|
||||
end_portal_sprites: array<vec4<f32>, 2>,
|
||||
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
|
||||
// y: Nether cardinal lighting, z: how many dynamic_lights are set.
|
||||
// y: Nether cardinal lighting, z: how many dynamic_lights are set,
|
||||
// w: the glint scroll clock (`Glint::scroll_ticks`).
|
||||
time: vec4<f32>,
|
||||
crumbling_sprite: vec4<f32>,
|
||||
// Selection outline parameters (read by terrain.wgsl).
|
||||
@@ -27,6 +28,12 @@ struct Globals {
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
@group(0) @binding(3) var lightmap: texture_2d<f32>;
|
||||
@group(0) @binding(4) var lightmap_sampler: sampler;
|
||||
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
|
||||
@group(0) @binding(5) var glint_tex: texture_2d<f32>;
|
||||
@group(0) @binding(6) var glint_sampler: sampler;
|
||||
// The armour layers' GlintSampler: vanilla samples enchanted_glint_armor with
|
||||
// the same repeating, linearly filtered sampler as the item glint.
|
||||
@group(0) @binding(7) var glint_armor_tex: texture_2d<f32>;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -44,6 +51,11 @@ struct Out {
|
||||
// entity.vsh's lightMapColor, multiplied in after the overlay.
|
||||
@location(9) light_color: vec4<f32>,
|
||||
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
|
||||
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
|
||||
@location(11) glint_uv: vec2<f32>,
|
||||
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
|
||||
@location(12) @interpolate(flat) cull: u32,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -92,7 +104,10 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
// minecraft_mix_light: the vertex colour times the cardinal light only.
|
||||
var c = color;
|
||||
var back = c;
|
||||
if normal.w != 0.0 {
|
||||
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
|
||||
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
|
||||
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
|
||||
if abs(normal.w) >= 0.5 {
|
||||
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
|
||||
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
|
||||
}
|
||||
@@ -103,18 +118,23 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
|
||||
out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
|
||||
out.overlay = overlay;
|
||||
out.glint_uv = vec2(0.0);
|
||||
if overlay == 4u { out.spherical = length(position); }
|
||||
return out;
|
||||
}
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
|
||||
return entity_vertex(position, uv, overlay, color, light, normal);
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
return out;
|
||||
}
|
||||
// Retained bolt-local geometry, translated using the same f64 camera
|
||||
// subtraction as the former CPU path before adding each local f32 position.
|
||||
// The bolt offset is instance attribute 7: 6 is the entity vertex's glint UV.
|
||||
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
|
||||
@location(6) offset: vec3<f32>) -> Out {
|
||||
@location(7) offset: vec3<f32>) -> Out {
|
||||
return entity_vertex(offset + position, uv, overlay, color, light, normal);
|
||||
}
|
||||
struct CloudInfo {
|
||||
@@ -166,6 +186,27 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
if d >= end { return 1.0; }
|
||||
return (d - start) / (end - start);
|
||||
}
|
||||
// TextureTransform.setupGlintTexturing(0.16), ARMOR_ENTITY_GLINT_TEXTURING.
|
||||
// globals.time.w is (Util.getMillis() * glintSpeed * 8) modulo 330000, the
|
||||
// common period of both offsets.
|
||||
fn armor_glint_uv(model: vec2<f32>) -> vec2<f32> {
|
||||
let ticks = globals.time.w;
|
||||
let offset0 = (ticks % 110000.0) / 110000.0;
|
||||
let offset1 = (ticks % 30000.0) / 30000.0;
|
||||
let angle = 0.17453292;
|
||||
let c = cos(angle);
|
||||
let s = sin(angle);
|
||||
let scaled = model * 0.16;
|
||||
return vec2(scaled.x * c - scaled.y * s - offset0,
|
||||
scaled.x * s + scaled.y * c + offset1);
|
||||
}
|
||||
// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha`
|
||||
// is squared along with the sample.
|
||||
fn glint_over(color: vec4<f32>, glint: vec4<f32>) -> vec4<f32> {
|
||||
let alpha = globals.fog_ends.w;
|
||||
let g = alpha * glint;
|
||||
return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha));
|
||||
}
|
||||
// CRUMBLING_UV_BEGIN
|
||||
fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>, dimensions: vec2<u32>) -> vec2<f32> {
|
||||
let size = vec2<f32>(dimensions);
|
||||
@@ -198,30 +239,43 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// clouds.fsh: alpha fades out towards the cloud fog end.
|
||||
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
|
||||
}
|
||||
if color.a < 0.1 { discard; }
|
||||
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
|
||||
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
|
||||
// lit texture, then the lightmap, then the glint and fog.
|
||||
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
|
||||
color *= in.light_color;
|
||||
if in.overlay == 7u || in.overlay == 8u {
|
||||
// Enchantment glint (overlay 7, 8 with the hurt tint): the purple
|
||||
// enchanted_glint sheen drifting diagonally over the item's shape.
|
||||
let px = in.uv * vec2<f32>(textureDimensions(atlas));
|
||||
let t = globals.misc.y * 0.05;
|
||||
let band = sin((px.x + px.y) * 0.9 - t * 6.0) * 0.5 + 0.5;
|
||||
let band2 = sin((px.x - px.y) * 0.6 + t * 4.0) * 0.5 + 0.5;
|
||||
let shimmer = pow(band * 0.6 + band2 * 0.4, 2.0);
|
||||
let glint = vec3(0.50, 0.25, 0.80) * (0.25 + 0.75 * shimmer);
|
||||
color = vec4(min(color.rgb + glint * 0.6, vec3(1.0)), color.a);
|
||||
// Enchantment glint: item.fsh's GLINT path, sampled from
|
||||
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
|
||||
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
|
||||
} else if in.overlay == 13u {
|
||||
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
|
||||
color = glint_over(
|
||||
color,
|
||||
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
|
||||
);
|
||||
}
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
return color;
|
||||
return celestial_color(in);
|
||||
}
|
||||
|
||||
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
|
||||
// premultiplied for an additive (one, one) blend, and as alpha how much of it
|
||||
// the texel adds, which the blend takes off the frame's alpha. HDR output
|
||||
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
|
||||
// the dark rest of their quads or the dim End sky.
|
||||
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
let added = color.rgb * color.a;
|
||||
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
}
|
||||
|
||||
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
|
||||
|
||||
@@ -186,6 +186,8 @@ mod tests {
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
};
|
||||
let prior = vertex([0.0; 3]);
|
||||
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
|
||||
|
||||
@@ -88,6 +88,11 @@ impl Base<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mobs that save `Age` and `AgeLocked`.
|
||||
fn saves_age(s: crate::mobs::Species) -> bool {
|
||||
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
|
||||
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
|
||||
}
|
||||
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
|
||||
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
|
||||
/// not allays, villagers, golems, dolphins or fish.
|
||||
@@ -225,26 +230,65 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
let id = m.kind.id.as_str();
|
||||
// Only breedable animals have an age; a loaded creature that was saved without
|
||||
// one (a frog) stays without.
|
||||
let aged = raw.is_none_or(|r| r.get("Age").is_some());
|
||||
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
|
||||
// AgeableMob.addAdditionalSaveData, only if canBeABaby (not a frog or a
|
||||
// parrot); a tadpole saves its own age.
|
||||
let species = m.species();
|
||||
if saves_age(species) {
|
||||
map.insert("Age".into(), Tag::Int(m.extra.age));
|
||||
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
for (key, value) in [
|
||||
("Age", Tag::Int(0)),
|
||||
("ForcedAge", Tag::Int(0)),
|
||||
("AgeLocked", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
if species != crate::mobs::Species::Tadpole {
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
}
|
||||
}
|
||||
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
|
||||
if is_animal(id) {
|
||||
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
|
||||
}
|
||||
let species = m.species();
|
||||
if let Some((_, variant)) =
|
||||
crate::mobs::finalize::species_variant_component(species, m.extra.variant)
|
||||
{
|
||||
map.insert("variant".into(), string(&variant));
|
||||
}
|
||||
if species == crate::mobs::Species::Chicken {
|
||||
map.insert("EggLayTime".into(), Tag::Int(m.extra.egg_time));
|
||||
}
|
||||
let sounds = crate::mobs::finalize::sound_variants(species);
|
||||
if !sounds.is_empty() {
|
||||
let sound = m.extra.sound_variant.and_then(|i| sounds.get(i as usize));
|
||||
let sound = sound.copied().unwrap_or("classic");
|
||||
map.insert(
|
||||
"sound_variant".into(),
|
||||
string(&format!("minecraft:{sound}")),
|
||||
);
|
||||
}
|
||||
match id {
|
||||
// Fox.addAdditionalSaveData; Horizon models only the type.
|
||||
"minecraft:fox" => {
|
||||
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
for (key, value) in [
|
||||
("Trusted", Tag::List(Vec::new())),
|
||||
("Sleeping", Tag::Byte(0)),
|
||||
("Sitting", Tag::Byte(0)),
|
||||
("Crouching", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
|
||||
}
|
||||
"minecraft:goat" => {
|
||||
map.insert(
|
||||
"IsScreamingGoat".into(),
|
||||
Tag::Byte(i8::from(m.extra.screaming)),
|
||||
);
|
||||
let [left, right] = m.extra.lost_horns;
|
||||
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
|
||||
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
|
||||
}
|
||||
"minecraft:creeper" => {
|
||||
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
@@ -260,10 +304,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
|
||||
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
|
||||
let variant = crate::mobs::tame::CAT_VARIANTS[(m.extra.variant as usize).min(10)];
|
||||
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
|
||||
map.entry("sound_variant".into())
|
||||
.or_insert_with(|| string("minecraft:classic"));
|
||||
}
|
||||
"minecraft:wolf" => {
|
||||
if let Some(owner) = m.extra.owner {
|
||||
@@ -273,10 +313,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
|
||||
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
|
||||
let variant = crate::mobs::tame::WOLF_VARIANTS[(m.extra.variant as usize).min(8)].0;
|
||||
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
|
||||
map.entry("sound_variant".into())
|
||||
.or_insert_with(|| string("minecraft:classic"));
|
||||
}
|
||||
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
|
||||
// Only Piglin.addAdditionalSaveData writes CannotHunt.
|
||||
@@ -311,6 +347,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
| "minecraft:trader_llama" => {
|
||||
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
|
||||
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
|
||||
// AbstractHorse.addAdditionalSaveData; Horizon models neither.
|
||||
for key in ["EatingHaystack", "Bred"] {
|
||||
map.entry(key.into()).or_insert(Tag::Byte(0));
|
||||
}
|
||||
if let Some(owner) = m.extra.owner {
|
||||
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
@@ -417,6 +457,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
"minecraft:villager" => {
|
||||
map.insert("VillagerData".into(), villager_data(m));
|
||||
// A loaded villager with VillagerData is finalized.
|
||||
map.insert("VillagerDataFinalized".into(), Tag::Byte(1));
|
||||
let t = m.extra.trade.as_deref().cloned().unwrap_or_default();
|
||||
map.insert("Xp".into(), Tag::Int(t.xp));
|
||||
map.insert("LastRestock".into(), Tag::Long(t.last_restock));
|
||||
@@ -566,9 +608,64 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
) {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
}
|
||||
// NeutralMob.addPersistentAngerSaveData; the bee saves its own.
|
||||
if matches!(
|
||||
id,
|
||||
"minecraft:iron_golem"
|
||||
| "minecraft:wolf"
|
||||
| "minecraft:polar_bear"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:enderman"
|
||||
) {
|
||||
map.entry("anger_end_time".into()).or_insert(Tag::Long(-1));
|
||||
}
|
||||
if let Some(a) = m.extra.attributes.as_deref() {
|
||||
put_attributes(map, m, a);
|
||||
}
|
||||
// The attribute their load makes, which AttributeMap.pack then saves:
|
||||
// Zombie and Piglin.setBaby (even for an adult) the speed, Rabbit.setVariant
|
||||
// the attack damage.
|
||||
use crate::mobs::finalize::Attr;
|
||||
let made = if crate::mobs::doors::is_zombie(species) || species == crate::mobs::Species::Piglin
|
||||
{
|
||||
Some(Attr::MovementSpeed)
|
||||
} else if species == crate::mobs::Species::Rabbit {
|
||||
Some(Attr::AttackDamage)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(attr) = made {
|
||||
let base = crate::mobs::finalize::base_value(m, attr);
|
||||
let mut list = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let attr_id = format!("minecraft:{}", attr.name());
|
||||
if !list
|
||||
.iter()
|
||||
.any(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
|
||||
{
|
||||
list.push(Tag::compound([
|
||||
("id", string(&attr_id)),
|
||||
("base", Tag::Double(base.to_bits())),
|
||||
]));
|
||||
map.insert("attributes".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
// Mob.addAdditionalSaveData: the chances that are not the default.
|
||||
if let Some(chances) = &m.extra.drop_chances {
|
||||
let set: Vec<(&str, Tag)> = crate::mobs::drops::SLOTS
|
||||
.iter()
|
||||
.zip(chances.iter())
|
||||
.filter(|(_, c)| **c != crate::mobs::drops::DEFAULT_DROP_CHANCE)
|
||||
.map(|(slot, c)| (*slot, Tag::Float(c.to_bits())))
|
||||
.collect();
|
||||
if set.is_empty() {
|
||||
map.remove(&"drop_chances".into());
|
||||
} else {
|
||||
map.insert("drop_chances".into(), Tag::compound(set));
|
||||
}
|
||||
}
|
||||
crate::mobs::raider::write_nbt(m, map);
|
||||
crate::mobs::riders::write_nbt(m, map);
|
||||
// LivingEntity equipment: what the mob holds now.
|
||||
@@ -800,6 +897,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
|
||||
}
|
||||
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
|
||||
// LivingEntity.setHealth clamps to the max health: a horse or llama is made
|
||||
// with the type's and saved before it drops to the one it rolled. (Only
|
||||
// the base is read here, so the clamp would cut a leader zombie's bonus.)
|
||||
if crate::mobs::equine::is_equine(m.species()) {
|
||||
m.health = m.health.min(m.attrs.max_health);
|
||||
}
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
@@ -841,7 +944,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.custom_name = custom_name(tag);
|
||||
m.custom_name_text = tag.get("CustomName").map(name_text);
|
||||
m.extra.left_handed = flag(tag, "LeftHanded");
|
||||
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
|
||||
if let Some(Tag::Compound(c)) = tag.get("drop_chances") {
|
||||
let mut chances = [crate::mobs::drops::DEFAULT_DROP_CHANCE; 8];
|
||||
for (chance, slot) in chances.iter_mut().zip(crate::mobs::drops::SLOTS) {
|
||||
if let Some(Tag::Float(v)) = c.get(&slot.into()) {
|
||||
*chance = f32::from_bits(*v);
|
||||
}
|
||||
}
|
||||
m.extra.drop_chances = Some(Box::new(chances));
|
||||
}
|
||||
if saves_age(m.species()) {
|
||||
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
@@ -935,20 +1047,45 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.vector[0] = 0.7;
|
||||
}
|
||||
}
|
||||
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
|
||||
&& let Some(v) = text(tag.get("variant"))
|
||||
{
|
||||
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Chicken
|
||||
&& let Some(t) = number(tag.get("EggLayTime"))
|
||||
{
|
||||
m.extra.egg_time = t as i32;
|
||||
}
|
||||
if let Some(v) = text(tag.get("sound_variant")) {
|
||||
let v = v.trim_start_matches("minecraft:");
|
||||
let sounds = crate::mobs::finalize::sound_variants(m.species());
|
||||
m.extra.sound_variant = sounds.iter().position(|n| *n == v).map(|i| i as u8);
|
||||
}
|
||||
match m.species() {
|
||||
crate::mobs::Species::Fox => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
|
||||
}
|
||||
crate::mobs::Species::Rabbit => {
|
||||
// Rabbit.Variant.byId: an unknown id is brown.
|
||||
m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) {
|
||||
v @ (0..=5 | 99) => v as u8,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
crate::mobs::Species::Goat => {
|
||||
m.extra.screaming = flag(tag, "IsScreamingGoat");
|
||||
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
|
||||
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Cat {
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
m.extra.tame = m.extra.owner.is_some();
|
||||
m.extra.sitting = flag(tag, "Sitting");
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
if let Some(Tag::String(v)) = tag.get("variant") {
|
||||
let v = v.to_string().unwrap_or_default();
|
||||
let v = v.trim_start_matches("minecraft:");
|
||||
m.extra.variant = crate::mobs::tame::CAT_VARIANTS
|
||||
.iter()
|
||||
.position(|n| *n == v)
|
||||
.unwrap_or(0) as u8;
|
||||
}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Wolf {
|
||||
let health = m.health;
|
||||
@@ -959,14 +1096,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.sitting = flag(tag, "Sitting");
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
if let Some(Tag::String(v)) = tag.get("variant") {
|
||||
let v = v.to_string().unwrap_or_default();
|
||||
let v = v.trim_start_matches("minecraft:");
|
||||
m.extra.variant = crate::mobs::tame::WOLF_VARIANTS
|
||||
.iter()
|
||||
.position(|(n, _)| *n == v)
|
||||
.unwrap_or(0) as u8;
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
@@ -2567,6 +2696,150 @@ mod sheep_nbt_tests {
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn farm_animals_keep_their_variant_and_sound_variant() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
types["minecraft:pig"].clone(),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(
|
||||
text(tag.get("variant")).as_deref(),
|
||||
Some("minecraft:temperate")
|
||||
);
|
||||
assert_eq!(
|
||||
text(tag.get("sound_variant")).as_deref(),
|
||||
Some("minecraft:classic")
|
||||
);
|
||||
// Spawned in a warm biome with the mini sounds.
|
||||
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
|
||||
assert_eq!(
|
||||
text(tag.get("sound_variant")).as_deref(),
|
||||
Some("minecraft:mini")
|
||||
);
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2)));
|
||||
let mut hen = Mob::new(
|
||||
3,
|
||||
types["minecraft:chicken"].clone(),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
|
||||
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
|
||||
let back = mob_from_nbt(&tag, &types, 4).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
|
||||
// The sound variants are indices into the registries finalizeSpawn draws from.
|
||||
use crate::mobs::{Species, finalize};
|
||||
let registries = finalize::SpawnRegistries::load(&root);
|
||||
for (species, registry) in [
|
||||
(Species::Pig, ®istries.pig_sound),
|
||||
(Species::Cow, ®istries.cow_sound),
|
||||
(Species::Chicken, ®istries.chicken_sound),
|
||||
(Species::Cat, ®istries.cat_sound),
|
||||
(Species::Wolf, ®istries.wolf_sound),
|
||||
] {
|
||||
assert_eq!(
|
||||
finalize::sound_variants(species),
|
||||
registry.names,
|
||||
"{species:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baby_villager_keeps_its_age() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let saved = Tag::compound([
|
||||
("id", string("minecraft:villager")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Age", Tag::Int(-21359)),
|
||||
]);
|
||||
let m = mob_from_nbt(&saved, &types, 1).unwrap();
|
||||
assert_eq!(m.extra.age, -21359);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(number(tag.get("Age")), Some(-21359));
|
||||
assert_eq!(number(tag.get("AgeLocked")), Some(0));
|
||||
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_rabbits_and_goats_keep_their_kind() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
let round_trip = |m: &Mob| {
|
||||
let tag = mob_to_nbt(m, [1, 2, 3, 4], None);
|
||||
(mob_from_nbt(&tag, &types, 2).unwrap(), tag)
|
||||
};
|
||||
let mut fox = new("minecraft:fox");
|
||||
fox.extra.variant = 1;
|
||||
let (back, tag) = round_trip(&fox);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
|
||||
assert_eq!(number(tag.get("Sleeping")), Some(0));
|
||||
assert_eq!(back.extra.variant, 1);
|
||||
let mut rabbit = new("minecraft:rabbit");
|
||||
rabbit.extra.variant = 4;
|
||||
let (back, tag) = round_trip(&rabbit);
|
||||
assert_eq!(number(tag.get("RabbitType")), Some(4));
|
||||
assert_eq!(back.extra.variant, 4);
|
||||
let mut goat = new("minecraft:goat");
|
||||
(goat.extra.screaming, goat.extra.lost_horns) = (true, [false, true]);
|
||||
let (back, tag) = round_trip(&goat);
|
||||
assert_eq!(number(tag.get("HasRightHorn")), Some(0));
|
||||
assert_eq!(
|
||||
(back.extra.screaming, back.extra.lost_horns),
|
||||
(true, [false, true])
|
||||
);
|
||||
// A goat saved without horn flags has both, as vanilla reads it.
|
||||
let bare = Tag::compound([
|
||||
("id", string("minecraft:goat")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
]);
|
||||
assert_eq!(
|
||||
mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns,
|
||||
[false; 2]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -48,6 +48,8 @@ pub fn flames(
|
||||
color: 0xffff_ffff,
|
||||
light: 0xf000 | (u32::from(scene_light >> 4) * 16),
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
remaining -= 0.45;
|
||||
|
||||
@@ -133,6 +133,7 @@ mod persist;
|
||||
mod perspective;
|
||||
mod pickups;
|
||||
mod piglins;
|
||||
mod piston_push;
|
||||
mod player_equip;
|
||||
mod postprocess;
|
||||
mod potions;
|
||||
@@ -157,6 +158,7 @@ mod scoreboard;
|
||||
mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_links;
|
||||
mod server_mods;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
@@ -508,6 +510,12 @@ enum Screen {
|
||||
Friends,
|
||||
/// CommandBlockEditScreen (game/command_blocks.rs).
|
||||
CommandBlock,
|
||||
/// ServerLinksDialogScreen: a server's links (game/server_links.rs).
|
||||
ServerLinks,
|
||||
/// ConfirmLinkScreen for a server link (game/server_links.rs).
|
||||
ConfirmLink,
|
||||
/// DialogScreen.WarningScreen: the note about custom screens.
|
||||
DialogWarning,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods
|
||||
@@ -1562,6 +1570,9 @@ struct Game<'a> {
|
||||
/// Player skin downloads, and which skin each atlas skin slot holds
|
||||
/// (least recently shown first).
|
||||
skin_downloads: RefCell<horizon_client::skins::Downloader>,
|
||||
/// `ProfileResolver.Cached`: `Dynamic` profiles (name- or id-only heads
|
||||
/// and mannequins) resolve through the services in the background.
|
||||
skin_profiles: RefCell<horizon_client::skins::Resolver>,
|
||||
skin_slots: Vec<(String, usize)>,
|
||||
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
|
||||
trim_slots: Vec<(String, usize)>,
|
||||
@@ -1675,6 +1686,7 @@ struct Game<'a> {
|
||||
guest_use: Option<GuestUse>,
|
||||
/// Level events of this tick for the guests, and the guest acting now.
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>,
|
||||
/// Block events (position, b0, b1, state) and entity events (id, event,
|
||||
/// position) for the guests.
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
@@ -1698,6 +1710,9 @@ struct Game<'a> {
|
||||
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
|
||||
/// hub's item registry, which the guest's turn does not hold.
|
||||
guest_cooldowns: Vec<(Arc<str>, i32)>,
|
||||
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
|
||||
/// sent with their entity id, which the guest's turn does not hold.
|
||||
guest_entity_events: Vec<i8>,
|
||||
/// `broadcastToAdmins` lines a guest's command queued, sent once it
|
||||
/// returns: the line, whether operators get it and whether the console
|
||||
/// logs it.
|
||||
@@ -1732,6 +1747,8 @@ struct Game<'a> {
|
||||
restore_player_state: bool,
|
||||
stats_ui: stats_screen::StatsUi,
|
||||
adv_ui: advancements_screen::AdvUi,
|
||||
/// The Server Links screens' state (game/server_links.rs).
|
||||
links_ui: server_links::LinksUi,
|
||||
camera_type: perspective::CameraType,
|
||||
fangs: fangs::FangState,
|
||||
/// The local player as a model (third person, the inventory).
|
||||
@@ -2914,20 +2931,29 @@ impl Game<'_> {
|
||||
b.run_block_events(level, ticks);
|
||||
b.tick_block_entities(level, ticks);
|
||||
});
|
||||
// `PistonMovingBlockEntity.tick` pushes what its block just ran into,
|
||||
// with the progress it is about to take.
|
||||
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
|
||||
self.push_piston_entities(pushes);
|
||||
self.process_level_events()?;
|
||||
self.sync_moving_pistons();
|
||||
self.process_level_events()
|
||||
Ok(())
|
||||
}
|
||||
/// Copies the moving-piston block entities into the world, where the
|
||||
/// collision queries and the renderer read them.
|
||||
fn sync_moving_pistons(&mut self) {
|
||||
let pistons = &mut self.world.moving_pistons;
|
||||
pistons.clear();
|
||||
pistons.extend(
|
||||
self.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state())),
|
||||
);
|
||||
let previous = std::mem::take(&mut self.world.moving_pistons);
|
||||
let current: Vec<_> = self
|
||||
.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state()))
|
||||
.collect();
|
||||
self.world.moving_piston_fallback = previous
|
||||
.into_iter()
|
||||
.filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos))
|
||||
.collect();
|
||||
self.world.moving_pistons = current;
|
||||
}
|
||||
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
|
||||
/// the side effects of each (a shrieker summoning a warden, drops, game
|
||||
@@ -3019,6 +3045,11 @@ impl Game<'_> {
|
||||
pos,
|
||||
data,
|
||||
} => self.dragon_egg_particles([pos.x, pos.y, pos.z], data),
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2016,
|
||||
pos,
|
||||
data,
|
||||
} => self.shulker_teleport_particles([pos.x, pos.y, pos.z], data),
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2017,
|
||||
pos,
|
||||
@@ -4212,6 +4243,7 @@ impl Game<'_> {
|
||||
if &*stack.item == "minecraft:spyglass" {
|
||||
// SpyglassItem.USE_DURATION, then finishUsingItem.
|
||||
if ticks + 1 >= 1200 {
|
||||
self.send_use_item_complete();
|
||||
self.stop_spyglass();
|
||||
self.using = None;
|
||||
} else {
|
||||
@@ -4233,6 +4265,7 @@ impl Game<'_> {
|
||||
}
|
||||
if ticks >= duration {
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
self.finish_consuming(&info, consumable, &stack);
|
||||
@@ -4940,8 +4973,10 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
if running {
|
||||
horizon_client::spans::time("entity_portals", || self.handle_entity_portals());
|
||||
horizon_client::spans::time("tick_items", || self.tick_items());
|
||||
horizon_client::spans::time("entity_portals", || {
|
||||
self.handle_entity_portals();
|
||||
self.handle_end_gateway_portals();
|
||||
});
|
||||
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
|
||||
}
|
||||
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
|
||||
@@ -4961,6 +4996,11 @@ impl Game<'_> {
|
||||
horizon_client::spans::time("tick_portal", || self.tick_nether_portal())?;
|
||||
if running {
|
||||
horizon_client::spans::time("tick_end", || self.tick_end())?;
|
||||
horizon_client::spans::time("end_gateways", || {
|
||||
self.find_end_gateway_portals();
|
||||
self.resolve_end_gateway_arrivals()
|
||||
})?;
|
||||
self.keep_gateway_destinations();
|
||||
}
|
||||
if running {
|
||||
horizon_client::spans::time("tick_falling", || self.tick_falling())?;
|
||||
@@ -5113,6 +5153,9 @@ impl Game<'_> {
|
||||
| Screen::Advancements
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::ServerLinks
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
| Screen::CommandBlock
|
||||
) || self.book.is_some()
|
||||
|| self.win_screen_pauses())
|
||||
@@ -5134,10 +5177,13 @@ impl Game<'_> {
|
||||
if capture && !self.focused {
|
||||
return;
|
||||
}
|
||||
if capture {
|
||||
// The inventory's screens are behind us: no drag survives them.
|
||||
self.reset_inventory_clicks();
|
||||
} else {
|
||||
// The container screen's press state belongs to the screen: a screen
|
||||
// that takes the pointer over starts with its own state, as
|
||||
// `AbstractContainerScreen` does, so a press whose release never
|
||||
// arrived (the pointer left the window) cannot wedge the next
|
||||
// screen's quick move and clicks.
|
||||
self.reset_inventory_clicks();
|
||||
if !capture {
|
||||
self.stop_mining();
|
||||
}
|
||||
// The real pointer takes over from an agent's.
|
||||
@@ -5596,6 +5642,9 @@ impl Game<'_> {
|
||||
Screen::Advancements => self.draw_advancements(&mut batch),
|
||||
Screen::WorldOptions => self.draw_world_options(&mut batch),
|
||||
Screen::Friends => self.draw_friends_screen(&mut batch),
|
||||
Screen::ServerLinks => self.draw_server_links(&mut batch),
|
||||
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
|
||||
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
|
||||
Screen::CommandBlock => self.draw_command_block(&mut batch),
|
||||
Screen::Inventory => {
|
||||
self.draw_inventory(&mut batch);
|
||||
@@ -5665,6 +5714,7 @@ impl Game<'_> {
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::EndGateway(_)
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Campfire(_),
|
||||
@@ -5937,6 +5987,7 @@ impl Game<'_> {
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::EndGateway(_)
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Campfire(_),
|
||||
@@ -5983,12 +6034,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen; Horizon keeps them on the game, so they are
|
||||
/// dropped when a screen closes. A press whose release never reaches the
|
||||
/// screen (the pointer left the window, or the screen closed first) would
|
||||
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
|
||||
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
|
||||
/// changes hands: a screen that opens or closes starts with its own
|
||||
/// state, and a press whose release never reached the screen -- the
|
||||
/// pointer left the window, or the screen closed first -- would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks = Default::default();
|
||||
self.inv_clicks.cancel_drag();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
@@ -7623,6 +7676,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8258,6 +8313,8 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -9799,7 +9856,7 @@ impl Game<'_> {
|
||||
velocity,
|
||||
on_ground,
|
||||
);
|
||||
self.creatures.projectiles.push(arrow);
|
||||
self.add_projectile(arrow);
|
||||
self.player_projectile_shot();
|
||||
let pitch = 1.0 / (self.world.random.next_float() * 0.4 + 1.2) + power * 0.5;
|
||||
self.audio
|
||||
@@ -10843,6 +10900,21 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
// A server pack's reload covers the frame with its progress
|
||||
// (`LoadingOverlay`) until `tick_server_packs` applies it.
|
||||
if let Some(reload) = &self.server_packs.reload {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
horizon_client::level_loading::draw_progress(
|
||||
&mut hud,
|
||||
&self.gui.atlas,
|
||||
reload.progress.line(),
|
||||
reload.progress.fraction(),
|
||||
);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -11163,6 +11235,8 @@ impl Game<'_> {
|
||||
debug_lines_on_top: &debug_lines_on_top,
|
||||
debug_crosshair: &debug_crosshair,
|
||||
hud: &hud,
|
||||
glint_alpha: self.settings.file.double("glintStrength") as f32,
|
||||
glint_ticks: self.glint_ticks(),
|
||||
clouds: (!title
|
||||
&& self.dim == Dim::Overworld
|
||||
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
|
||||
@@ -11390,6 +11464,10 @@ impl Game<'_> {
|
||||
Screen::GameModeSwitcher => self.close_gamemode_switcher(false),
|
||||
Screen::Dead => {}
|
||||
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
|
||||
Screen::ServerLinks => self.screen = Screen::Paused,
|
||||
// ConfirmScreen.keyPressed: Escape answers no; both return to
|
||||
// the Server Links screen they came from.
|
||||
Screen::ConfirmLink | Screen::DialogWarning => self.screen = Screen::ServerLinks,
|
||||
Screen::Advancements => self.close_advancements(),
|
||||
Screen::WorldOptions => self.world_options_escape(),
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
@@ -11458,8 +11536,10 @@ impl Game<'_> {
|
||||
// key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND
|
||||
// (the server swaps the hands and sends both slots back; spectators can't).
|
||||
KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => {
|
||||
if !self.spectator() {
|
||||
self.remote_swap_hands();
|
||||
if !self.spectator()
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
}
|
||||
KeyCode::KeyF if self.screen == Screen::Playing => {
|
||||
@@ -11630,6 +11710,7 @@ impl Game<'_> {
|
||||
self.tick_pregen();
|
||||
let (uploads, unloaded) =
|
||||
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
|
||||
self.spawn_fresh_chunks();
|
||||
if let Some(lod) = &mut self.lod {
|
||||
self.lod_observe
|
||||
.extend(uploads.iter().map(|(key, _)| (key.0, key.2)));
|
||||
@@ -11903,6 +11984,19 @@ impl ApplicationHandler for Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The pack reload's progress overlay takes no input
|
||||
// (`LoadingOverlay`): `start_pack_reload` released what was held.
|
||||
if self.server_packs.reload.is_some()
|
||||
&& !matches!(
|
||||
&event,
|
||||
WindowEvent::CloseRequested
|
||||
| WindowEvent::Focused(_)
|
||||
| WindowEvent::Resized(_)
|
||||
| WindowEvent::RedrawRequested
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.pending_save.is_some() {
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
@@ -12070,6 +12164,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::WorldOptions && d != 0.0 {
|
||||
self.world_options_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::ServerLinks && d != 0.0 {
|
||||
self.server_links_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Advancements {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
@@ -12261,6 +12358,15 @@ impl ApplicationHandler for Game<'_> {
|
||||
Screen::WorldOptions if pressed && button == MouseButton::Left => {
|
||||
self.world_options_click()
|
||||
}
|
||||
Screen::ServerLinks if pressed && button == MouseButton::Left => {
|
||||
self.server_links_click()?
|
||||
}
|
||||
Screen::ConfirmLink if pressed && button == MouseButton::Left => {
|
||||
self.confirm_link_click()
|
||||
}
|
||||
Screen::DialogWarning if pressed && button == MouseButton::Left => {
|
||||
self.dialog_warning_click()?
|
||||
}
|
||||
Screen::WorldOptions if button == MouseButton::Left => {
|
||||
self.world_options_release()
|
||||
}
|
||||
@@ -13042,6 +13148,7 @@ impl<'a> Game<'a> {
|
||||
.unwrap_or_default()
|
||||
.join("assets/skins"),
|
||||
)),
|
||||
skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()),
|
||||
skin_slots: Vec::new(),
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
@@ -13108,6 +13215,7 @@ impl<'a> Game<'a> {
|
||||
forced_hit: None,
|
||||
guest_use: None,
|
||||
lan_events: Vec::new(),
|
||||
lan_cushion_breaks: Vec::new(),
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
lan_critical_hits: Vec::new(),
|
||||
@@ -13116,6 +13224,7 @@ impl<'a> Game<'a> {
|
||||
lan_acting: None,
|
||||
guest_packets: Vec::new(),
|
||||
guest_cooldowns: Vec::new(),
|
||||
guest_entity_events: Vec::new(),
|
||||
lan_acting_name: None,
|
||||
lan_acting_uuid: None,
|
||||
chat_capture: None,
|
||||
@@ -13142,6 +13251,7 @@ impl<'a> Game<'a> {
|
||||
restore_player_state: true,
|
||||
stats_ui: Default::default(),
|
||||
adv_ui: Default::default(),
|
||||
links_ui: Default::default(),
|
||||
camera_type: Default::default(),
|
||||
fangs: Default::default(),
|
||||
player_model: None,
|
||||
@@ -13384,7 +13494,6 @@ const NUMERIC_KEYS: [KeyCode; 11] = [
|
||||
KeyCode::Numpad9,
|
||||
];
|
||||
|
||||
const STAGES: u32 = 10;
|
||||
const STAGE_LINES: [&str; 10] = [
|
||||
"Loading menu",
|
||||
"Loading assets",
|
||||
@@ -13400,19 +13509,28 @@ const STAGE_LINES: [&str; 10] = [
|
||||
|
||||
/// The background loader's checkpoint, read by the loading screen each frame.
|
||||
#[derive(Clone)]
|
||||
struct Progress(Arc<AtomicU32>);
|
||||
struct Progress {
|
||||
value: Arc<AtomicU32>,
|
||||
lines: &'static [&'static str],
|
||||
}
|
||||
impl Progress {
|
||||
fn new() -> Self {
|
||||
Self(Arc::new(AtomicU32::new(0)))
|
||||
Self::with(&STAGE_LINES)
|
||||
}
|
||||
fn with(lines: &'static [&'static str]) -> Self {
|
||||
Self {
|
||||
value: Arc::new(AtomicU32::new(0)),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
fn set(&self, stage: u32) {
|
||||
self.0.store(stage, Ordering::Relaxed);
|
||||
self.value.store(stage, Ordering::Relaxed);
|
||||
}
|
||||
fn fraction(&self) -> f32 {
|
||||
self.0.load(Ordering::Relaxed) as f32 / (STAGES - 1) as f32
|
||||
self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32
|
||||
}
|
||||
fn line(&self) -> &'static str {
|
||||
STAGE_LINES[(self.0.load(Ordering::Relaxed) as usize).min(STAGE_LINES.len() - 1)]
|
||||
self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13772,16 +13890,22 @@ fn build_renderer(
|
||||
&assets.sprite_mips,
|
||||
&assets.animations,
|
||||
(&atlas.pixels, atlas.width, atlas.height),
|
||||
(&atlas.glint.0, atlas.glint.1, atlas.glint.2),
|
||||
(
|
||||
&atlas.glint_armor.0,
|
||||
atlas.glint_armor.1,
|
||||
atlas.glint_armor.2,
|
||||
),
|
||||
(&pack.forcefield.2, pack.forcefield.0),
|
||||
&gui.panorama,
|
||||
prepared,
|
||||
previous,
|
||||
api,
|
||||
))?;
|
||||
renderer.hud.fire_animations = atlas
|
||||
.fire_animations
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), usize::MAX))
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
|
||||
.collect();
|
||||
Ok(renderer)
|
||||
}
|
||||
@@ -13798,6 +13922,7 @@ fn loading_renderer(
|
||||
) -> Result<Renderer> {
|
||||
static FORCEFIELD: [u8; 4] = [0; 4];
|
||||
static EMPTY_HUD: [u8; 4] = [0, 0, 0, 255];
|
||||
static EMPTY_GLINT: [u8; 4] = [0; 4];
|
||||
let empty_panorama = horizon_client::gfx::panorama::Faces {
|
||||
size: 1,
|
||||
pixels: [0u8; 4].repeat(6),
|
||||
@@ -13808,6 +13933,10 @@ fn loading_renderer(
|
||||
Some(atlas) => (atlas.pixels.as_slice(), atlas.width, atlas.height),
|
||||
None => (EMPTY_HUD.as_slice(), 1, 1),
|
||||
};
|
||||
let (glint_pixels, glint_width, glint_height) = match atlas {
|
||||
Some(atlas) => (atlas.glint.0.as_slice(), atlas.glint.1, atlas.glint.2),
|
||||
None => (EMPTY_GLINT.as_slice(), 1, 1),
|
||||
};
|
||||
// A glyph rasterized into an earlier texture is not in these pixels; the
|
||||
// new texture gets it again when the character is drawn (see
|
||||
// `build_renderer`).
|
||||
@@ -13824,6 +13953,8 @@ fn loading_renderer(
|
||||
&[],
|
||||
&[],
|
||||
(pixels, width, height),
|
||||
(glint_pixels, glint_width, glint_height),
|
||||
(glint_pixels, glint_width, glint_height),
|
||||
(&FORCEFIELD[..], 1),
|
||||
panorama,
|
||||
None,
|
||||
@@ -13831,10 +13962,10 @@ fn loading_renderer(
|
||||
api,
|
||||
))?;
|
||||
if let Some(atlas) = atlas {
|
||||
renderer.hud.fire_animations = atlas
|
||||
.fire_animations
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), usize::MAX))
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
|
||||
.collect();
|
||||
}
|
||||
Ok(renderer)
|
||||
@@ -13870,6 +14001,8 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud,
|
||||
glint_alpha: 0.75,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
entity_spans: &[],
|
||||
|
||||
@@ -419,6 +419,9 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
@@ -561,10 +564,26 @@ impl Game<'_> {
|
||||
"hardcore": options.hardcore,
|
||||
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
|
||||
"allow_commands": options.commands(*creative),
|
||||
"superflat": options.flat,
|
||||
"world_type": options.world_type.id(),
|
||||
"generate_structures": options.structures,
|
||||
"bonus_chest": options.bonus_chest(),
|
||||
}),
|
||||
Mode::CreateFlat {
|
||||
name,
|
||||
seed,
|
||||
options,
|
||||
flat,
|
||||
..
|
||||
} => json!({
|
||||
"mode": "customize-flat",
|
||||
"fields": { "name": name, "seed": seed },
|
||||
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
|
||||
"biome": flat.settings().biome,
|
||||
"layers": flat.settings().layers.iter().map(|layer| json!({
|
||||
"block": layer.block,
|
||||
"height": layer.height,
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
Mode::Edit {
|
||||
world,
|
||||
name,
|
||||
@@ -707,9 +726,12 @@ impl Game<'_> {
|
||||
// the recipe buttons of the page, page arrows, filter and tabs.
|
||||
let book = self.recipe_book_targets();
|
||||
if !book.is_empty() {
|
||||
let (open, filtering) = self.recipe_book_flags().unwrap_or_default();
|
||||
v["recipe_book"] = json!({
|
||||
"targets": book.iter().map(|t| t.rect).collect::<Vec<_>>(),
|
||||
"known": self.recipe_book_known(),
|
||||
"open": open,
|
||||
"filtering": filtering,
|
||||
});
|
||||
}
|
||||
v["slot_ids"] = json!(
|
||||
|
||||
@@ -711,6 +711,20 @@ impl Game<'_> {
|
||||
let color = undyed.map_or(0xFFFF_FFFF, |c| {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
|
||||
// when `stack.hasFoil()`, as on a living entity.
|
||||
let item_glint = self
|
||||
.res
|
||||
.items()
|
||||
.enchantments
|
||||
.props
|
||||
.get(&stack.item)
|
||||
.is_some_and(|p| p.glint);
|
||||
let overlay = if stack.has_foil(item_glint) {
|
||||
horizon_client::mobs::render::ARMOR_GLINT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let layer_poses = Poses::new(part);
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
@@ -719,7 +733,7 @@ impl Game<'_> {
|
||||
uv,
|
||||
color,
|
||||
packed,
|
||||
0,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
@@ -734,7 +748,7 @@ impl Game<'_> {
|
||||
trim,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
}
|
||||
@@ -877,6 +891,7 @@ impl Game<'_> {
|
||||
lights: Some(horizon_client::block_entity_render::diffuse_lights(
|
||||
self.dim == super::Dim::Nether,
|
||||
)),
|
||||
glint: self.glint(),
|
||||
};
|
||||
let profile = stack
|
||||
.extra
|
||||
|
||||
@@ -725,21 +725,38 @@ impl Game<'_> {
|
||||
/// the player head at `pos`. The skin download is started here; while it
|
||||
/// is in flight (or when it cannot be got) the profile's default skin
|
||||
/// stands in. A head with no profile answers None: `SKIN_BY_TYPE`.
|
||||
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
|
||||
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<String> {
|
||||
let Some(BlockEntity::Skull(skull)) =
|
||||
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let skin = skull.skin.as_ref()?;
|
||||
if let Some((url, location)) = &skin.download {
|
||||
self.skin_downloads
|
||||
.borrow_mut()
|
||||
.state(skins::Kind::Skin, url);
|
||||
if self.gui.atlas.sprites.contains_key(location) {
|
||||
// The texture a packed or resolved `textures` property fetches: the
|
||||
// download of a static profile, or the one the services answer for a
|
||||
// `Dynamic` (name- or id-only) profile.
|
||||
let download = skin.download.clone().or_else(|| {
|
||||
let key = skin.dynamic.as_ref()?;
|
||||
let skins::Resolve::Done(Some(profile)) = self.skin_profiles.borrow_mut().state(key)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let (value, signature) = profile.textures()?;
|
||||
let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?;
|
||||
let location = skins::skin_location(&skins::texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
if let Some((url, location)) = download {
|
||||
let mut downloads = self.skin_downloads.borrow_mut();
|
||||
if self.gui.atlas.sprites.contains_key(&location) {
|
||||
let _ = downloads.state(skins::Kind::Skin, &url);
|
||||
return Some(location);
|
||||
}
|
||||
// Its slot was evicted: offer the image again.
|
||||
if downloads.state(skins::Kind::Skin, &url) == skins::Download::Ready {
|
||||
downloads.again(&location);
|
||||
}
|
||||
}
|
||||
Some(skin.fallback)
|
||||
Some(skin.fallback.to_owned())
|
||||
}
|
||||
}
|
||||
@@ -862,7 +862,7 @@ impl Game<'_> {
|
||||
let a = alpha as f32;
|
||||
let range = super::entity_culling::default_range(
|
||||
glam::DVec3::new(WIDTH, HEIGHT, WIDTH),
|
||||
self.settings.entity_distance,
|
||||
self.view_scale(),
|
||||
);
|
||||
for b in &self.boats {
|
||||
let entity_start = out.len();
|
||||
|
||||
@@ -1009,9 +1009,12 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let indicator = |batch: &mut HudBatch, page: usize, count: usize| {
|
||||
let text = tr("book.pageIndicator", "Page %s of %s")
|
||||
.replacen("%s", &(page + 1).to_string(), 1)
|
||||
.replacen("%s", &count.max(1).to_string(), 1);
|
||||
// BookViewScreen.getPageNumberMessage: 26.3's en_us template is
|
||||
// positional ("Page %1$s of %2$s").
|
||||
let text = horizon_protocol::text::format_java(
|
||||
&tr("book.pageIndicator", "Page %1$s of %2$s"),
|
||||
&[(page + 1).to_string(), count.max(1).to_string()],
|
||||
);
|
||||
let tw = HudBatch::text_width(atlas, &text);
|
||||
batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false);
|
||||
};
|
||||
|
||||
@@ -42,9 +42,10 @@ pub(super) enum Target {
|
||||
Guest(u32),
|
||||
Mob(u32),
|
||||
Item(u32),
|
||||
Orb(usize),
|
||||
/// By index in `creatures.projectiles`.
|
||||
Projectile(usize),
|
||||
/// An experience orb, by its entity id.
|
||||
Orb(u32),
|
||||
/// A projectile, by its entity id.
|
||||
Projectile(u32),
|
||||
Stand(u32),
|
||||
Frame(u32),
|
||||
Painting(u32),
|
||||
@@ -53,6 +54,10 @@ pub(super) enum Target {
|
||||
/// An entity of a type Horizon does not simulate (markers, displays,
|
||||
/// interactions...): kept as its saved data at a position.
|
||||
Other(u32),
|
||||
/// An End crystal, by its entity id (`minecraft:end_crystal`).
|
||||
Crystal(u32),
|
||||
/// The Ender Dragon, by its entity id (`minecraft:ender_dragon`).
|
||||
Dragon(u32),
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate: its compound lives in the
|
||||
/// entity memory (`persist.raw`), its position and rotation here.
|
||||
@@ -340,14 +345,16 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| [i.position.x, i.position.y, i.position.z]),
|
||||
Target::Orb(i) => self
|
||||
Target::Orb(id) => self
|
||||
.orbs
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Projectile(i) => self
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(|a| [a.position.x, a.position.y, a.position.z]),
|
||||
Target::Stand(id) => self
|
||||
.armor_stands
|
||||
@@ -385,6 +392,16 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Crystal(id) => self
|
||||
.crystals
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| [c.position.x, c.position.y, c.position.z]),
|
||||
Target::Dragon(id) => self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.filter(|d| d.id == id)
|
||||
.map(|d| [d.position.x, d.position.y, d.position.z]),
|
||||
}
|
||||
}
|
||||
pub(super) fn target_type(&self, t: Target) -> String {
|
||||
@@ -399,10 +416,11 @@ impl Game<'_> {
|
||||
}),
|
||||
Target::Item(_) => "minecraft:item".into(),
|
||||
Target::Orb(_) => "minecraft:experience_orb".into(),
|
||||
Target::Projectile(i) => self
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.map_or_else(String::new, |p| p.kind.id().into()),
|
||||
Target::Stand(_) => "minecraft:armor_stand".into(),
|
||||
Target::Frame(id) => {
|
||||
@@ -428,6 +446,8 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map_or_else(String::new, |o| o.kind.clone()),
|
||||
Target::Crystal(_) => "minecraft:end_crystal".into(),
|
||||
Target::Dragon(_) => "minecraft:ender_dragon".into(),
|
||||
}
|
||||
}
|
||||
/// The name of the player the game acts for: a guest's own while their
|
||||
@@ -512,50 +532,72 @@ impl Game<'_> {
|
||||
s.pos[2].unwrap_or(p[2]),
|
||||
]
|
||||
};
|
||||
let mut candidates: Vec<Target> = Vec::new();
|
||||
let mut source_candidates: Vec<Target> = Vec::new();
|
||||
match (s.kind, self.command_source) {
|
||||
// @s: the executing entity (none for a server source).
|
||||
('s', Some(source)) => candidates.extend(source.entity),
|
||||
('s', None) => candidates.extend(self.source_entity()),
|
||||
('s', Some(source)) => source_candidates.extend(source.entity),
|
||||
('s', None) => source_candidates.extend(self.source_entity()),
|
||||
// A guest in place is among the players, in join order.
|
||||
_ if self.player.is_some() && self.lan_acting.is_none() => {
|
||||
candidates.push(Target::Player);
|
||||
source_candidates.push(Target::Player);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if s.kind != 's' {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
candidates.extend(self.player_candidates(s));
|
||||
candidates.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.death_time == 0)
|
||||
.map(|m| Target::Mob(m.id)),
|
||||
);
|
||||
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
|
||||
candidates.extend((0..self.orbs.len()).map(Target::Orb));
|
||||
candidates.extend(
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|(_, p)| {
|
||||
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|(i, _)| Target::Projectile(i)),
|
||||
);
|
||||
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
|
||||
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
|
||||
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
|
||||
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
|
||||
source_candidates.extend(self.player_candidates(s));
|
||||
}
|
||||
let source_dim = self.dim;
|
||||
// A selector with a spatial constraint reaches the level the command
|
||||
// runs in only; an unbounded one reaches every loaded level
|
||||
// (EntitySelector.findEntities).
|
||||
let dims = if s.world_limited() {
|
||||
vec![source_dim]
|
||||
} else {
|
||||
self.loaded_dims()
|
||||
};
|
||||
let mut out: Vec<(Target, f64)> = Vec::new();
|
||||
for dim in dims {
|
||||
let back = self.enter_command_level(dim);
|
||||
let mut candidates = Vec::new();
|
||||
if dim == source_dim {
|
||||
candidates.append(&mut source_candidates);
|
||||
}
|
||||
if s.kind != 's' {
|
||||
candidates.extend(self.level_candidates());
|
||||
}
|
||||
out.extend(self.select_in_level(candidates, s, origin));
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
}
|
||||
match s.sort {
|
||||
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
|
||||
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
|
||||
Sort::Random => {
|
||||
for i in (1..out.len()).rev() {
|
||||
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
out.swap(i, j);
|
||||
}
|
||||
}
|
||||
Sort::Arbitrary => {}
|
||||
}
|
||||
out.into_iter()
|
||||
.take(s.limit.max(1))
|
||||
.map(|(t, _)| t)
|
||||
.collect()
|
||||
}
|
||||
/// The candidates of the level in place that pass the selector
|
||||
/// (EntitySelector.getPredicate).
|
||||
fn select_in_level(
|
||||
&mut self,
|
||||
candidates: Vec<Target>,
|
||||
s: &Selector,
|
||||
origin: [f64; 3],
|
||||
) -> Vec<(Target, f64)> {
|
||||
let level = self.experience.level;
|
||||
let creative = self.creative;
|
||||
let passing = self.selector_extra_filter(&candidates, s);
|
||||
let mut out: Vec<(Target, f64)> = candidates
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
let p = self.target_position(t)?;
|
||||
@@ -627,23 +669,102 @@ impl Game<'_> {
|
||||
}
|
||||
Some((t, d2))
|
||||
})
|
||||
.collect();
|
||||
match s.sort {
|
||||
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
|
||||
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
|
||||
Sort::Random => {
|
||||
for i in (1..out.len()).rev() {
|
||||
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
out.swap(i, j);
|
||||
}
|
||||
}
|
||||
Sort::Arbitrary => {}
|
||||
}
|
||||
out.into_iter()
|
||||
.take(s.limit.max(1))
|
||||
.map(|(t, _)| t)
|
||||
.collect()
|
||||
}
|
||||
/// The entities of the level in place a selector can find.
|
||||
fn level_candidates(&self) -> Vec<Target> {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
let mut candidates = Vec::new();
|
||||
candidates.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.death_time == 0)
|
||||
.map(|m| Target::Mob(m.id)),
|
||||
);
|
||||
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
|
||||
candidates.extend(self.orbs.iter().map(|o| Target::Orb(o.id)));
|
||||
candidates.extend(
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|p| {
|
||||
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|p| Target::Projectile(p.id)),
|
||||
);
|
||||
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
|
||||
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
|
||||
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
|
||||
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
|
||||
// The End crystals and the dragon (EndCrystal and EnderDragon
|
||||
// are entities of their own).
|
||||
candidates.extend(self.crystals.iter().map(|c| Target::Crystal(c.id)));
|
||||
if let Some(d) = &self.dragon {
|
||||
candidates.push(Target::Dragon(d.id));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
/// The loaded levels, in the order the server holds them: the
|
||||
/// overworld, the nether, the end (MinecraftServer.getAllLevels).
|
||||
fn loaded_dims(&self) -> Vec<Dim> {
|
||||
let mut dims = vec![self.dim];
|
||||
dims.extend(self.levels.parked_dims());
|
||||
dims.sort_by_key(|d| match d {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => 1,
|
||||
Dim::End => 2,
|
||||
});
|
||||
dims
|
||||
}
|
||||
/// Whether the level in place holds the entity a target names.
|
||||
fn level_holds(&self, t: Target) -> bool {
|
||||
match t {
|
||||
// A player is in no level of its own.
|
||||
Target::Player | Target::Guest(_) => true,
|
||||
Target::Mob(id) => self.mobs.iter().any(|m| m.id == id),
|
||||
Target::Item(id) => self.dropped.iter().any(|i| i.id == id),
|
||||
Target::Orb(id) => self.orbs.iter().any(|o| o.id == id),
|
||||
Target::Projectile(id) => self.creatures.projectiles.iter().any(|p| p.id == id),
|
||||
Target::Stand(id) => self.armor_stands.iter().any(|a| a.id == id),
|
||||
Target::Frame(id) => self.item_frames.iter().any(|f| f.id == id),
|
||||
Target::Painting(id) => self.paintings.iter().any(|p| p.id == id),
|
||||
Target::Boat(id) => self.boats.iter().any(|b| b.id == id),
|
||||
Target::Cart(id) => self.carts.iter().any(|c| c.id == id),
|
||||
Target::Other(id) => self.others.iter().any(|o| o.id == id),
|
||||
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
|
||||
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
|
||||
}
|
||||
}
|
||||
/// Run `f` with the level that holds the entity a target names in place
|
||||
/// (the entity ids are unique across the loaded levels) and put the
|
||||
/// level back afterwards. `f` does not run when no level holds the
|
||||
/// entity.
|
||||
pub(in crate::game) fn in_target_level<R>(
|
||||
&mut self,
|
||||
t: Target,
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
if self.level_holds(t) {
|
||||
return Some(f(self));
|
||||
}
|
||||
for dim in self.levels.parked_dims() {
|
||||
let back = self.enter_command_level(dim);
|
||||
if self.level_holds(t) {
|
||||
let r = f(self);
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn entity_tag_matches(&self, tag: &str, ty: &str) -> bool {
|
||||
let path = self.options.resources.join(format!(
|
||||
"data/minecraft/tags/entity_type/{}.json",
|
||||
@@ -1103,6 +1224,10 @@ impl Game<'_> {
|
||||
Ok(targets.len() as i32)
|
||||
}
|
||||
fn teleport_to(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
|
||||
// An entity in another level is teleported in the level that holds it.
|
||||
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
|
||||
}
|
||||
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
|
||||
let v = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
match t {
|
||||
Target::Guest(_) => {
|
||||
@@ -1151,14 +1276,14 @@ impl Game<'_> {
|
||||
i.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Orb(i) => {
|
||||
if let Some(o) = self.orbs.get_mut(i) {
|
||||
Target::Orb(id) => {
|
||||
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
|
||||
o.position = v;
|
||||
o.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Projectile(i) => {
|
||||
if let Some(a) = self.creatures.projectiles.get_mut(i) {
|
||||
Target::Projectile(id) => {
|
||||
if let Some(a) = self.creatures.projectiles.iter_mut().find(|a| a.id == id) {
|
||||
a.position = v;
|
||||
a.previous = v;
|
||||
}
|
||||
@@ -1198,6 +1323,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
|
||||
c.position = v;
|
||||
}
|
||||
}
|
||||
Target::Dragon(id) => {
|
||||
if let Some(d) = self.dragon.as_mut().filter(|d| d.id == id) {
|
||||
d.position = v;
|
||||
d.previous = v;
|
||||
}
|
||||
}
|
||||
// Wall-mounted entities do not move.
|
||||
Target::Frame(_) | Target::Painting(_) => {}
|
||||
}
|
||||
@@ -1557,6 +1693,10 @@ impl Game<'_> {
|
||||
Ok(duration.unwrap_or(-1))
|
||||
}
|
||||
fn kill_target(&mut self, t: Target) {
|
||||
// An entity in another level is killed in the level that holds it.
|
||||
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
|
||||
}
|
||||
fn kill_target_in_level(&mut self, t: Target) {
|
||||
match t {
|
||||
Target::Guest(_) => {
|
||||
self.for_player(t, |g| g.kill_target(Target::Player));
|
||||
@@ -1576,16 +1716,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Target::Item(id) => self.dropped.retain(|i| i.id != id),
|
||||
Target::Orb(i) => {
|
||||
if i < self.orbs.len() {
|
||||
self.orbs[i].age = u32::MAX / 2;
|
||||
Target::Orb(id) => {
|
||||
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
|
||||
o.age = u32::MAX / 2;
|
||||
}
|
||||
}
|
||||
// cmd_kill goes from the highest index down.
|
||||
Target::Projectile(i) => {
|
||||
if i < self.creatures.projectiles.len() {
|
||||
self.creatures.projectiles.remove(i);
|
||||
}
|
||||
Target::Projectile(id) => {
|
||||
self.creatures.projectiles.retain(|p| p.id != id);
|
||||
}
|
||||
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
|
||||
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
|
||||
@@ -1593,7 +1730,11 @@ impl Game<'_> {
|
||||
Target::Boat(id) => self.boats.retain(|b| b.id != id),
|
||||
Target::Cart(id) => self.carts.retain(|c| c.id != id),
|
||||
Target::Other(id) => self.others.retain(|o| o.id != id),
|
||||
Target::Crystal(id) => self.kill_crystal(id),
|
||||
Target::Dragon(id) => self.kill_dragon(id),
|
||||
}
|
||||
// The orbs marked for removal are gone (Entity.kill removes them).
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
}
|
||||
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
@@ -1601,19 +1742,18 @@ impl Game<'_> {
|
||||
} else {
|
||||
vec![self.implied_player()]
|
||||
};
|
||||
let names: Vec<Text> = targets.iter().map(|&t| self.target_name(t)).collect();
|
||||
// Remove indexed entities from the back so earlier indices stay valid.
|
||||
let mut ordered = targets.clone();
|
||||
ordered.sort_by_key(|t| {
|
||||
std::cmp::Reverse(match t {
|
||||
Target::Orb(i) | Target::Projectile(i) => *i,
|
||||
_ => 0,
|
||||
})
|
||||
});
|
||||
for t in ordered {
|
||||
let mut names: Vec<Text> = Vec::new();
|
||||
for &t in &targets {
|
||||
// The name of an entity in another level is read there.
|
||||
let name = match self.in_target_level(t, |g| g.target_name(t)) {
|
||||
Some(name) => name,
|
||||
None => self.target_name(t),
|
||||
};
|
||||
names.push(name);
|
||||
}
|
||||
for &t in &targets {
|
||||
self.kill_target(t);
|
||||
}
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
let message = if targets.len() == 1 {
|
||||
Text::translate("commands.kill.success.single", &[names[0].clone()])
|
||||
} else {
|
||||
@@ -1705,6 +1845,20 @@ impl Game<'_> {
|
||||
self.spawn_bolt(pos, false);
|
||||
name = Text::translate("entity.minecraft.lightning_bolt", &[]);
|
||||
}
|
||||
"end_crystal" => {
|
||||
// EndCrystal's constructor takes an id from the entity
|
||||
// counter, and the crystal is shown with its base.
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(super::dragon::Crystal {
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(pos[0], pos[1], pos[2]),
|
||||
show_bottom: true,
|
||||
time: self.world.random.next_int_bound(100_000).unwrap_or(0),
|
||||
beam: None,
|
||||
invulnerable: false,
|
||||
});
|
||||
name = Text::translate("entity.minecraft.end_crystal", &[]);
|
||||
}
|
||||
k if super::minecarts::CartKind::from_id(&kind).is_some() => {
|
||||
let cart_kind = super::minecarts::CartKind::from_id(&kind)
|
||||
.unwrap_or(super::minecarts::CartKind::Rideable);
|
||||
@@ -1895,8 +2049,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
self.world.block_entities.insert(key, new);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
true
|
||||
}
|
||||
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
|
||||
|
||||
@@ -31,8 +31,13 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
|
||||
impl Game<'_> {
|
||||
// ---- entity data ---------------------------------------------------
|
||||
|
||||
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved.
|
||||
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved. An entity
|
||||
/// in another level is read in the level that holds it.
|
||||
pub(in crate::game) fn entity_nbt(&mut self, t: Target) -> Option<Tag> {
|
||||
self.in_target_level(t, |g| g.entity_nbt_in_level(t))
|
||||
.flatten()
|
||||
}
|
||||
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
|
||||
let player_uuid = self.level.player_uuid;
|
||||
Some(match t {
|
||||
Target::Guest(_) => {
|
||||
@@ -113,9 +118,11 @@ impl Game<'_> {
|
||||
let o = self.others.iter().find(|o| o.id == id)?.clone();
|
||||
self.other_to_nbt(&o, uuid)
|
||||
}
|
||||
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
|
||||
Target::Projectile(i) => {
|
||||
let p = self.creatures.projectiles.get(i)?;
|
||||
Target::Orb(id) => {
|
||||
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid())
|
||||
}
|
||||
Target::Projectile(id) => {
|
||||
let p = self.creatures.projectiles.iter().find(|p| p.id == id)?;
|
||||
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
|
||||
player_uuid
|
||||
} else {
|
||||
@@ -129,6 +136,16 @@ impl Game<'_> {
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
let c = self.crystals.iter().find(|c| c.id == id)?;
|
||||
let (uuid, raw) = self.persist_anon("minecraft:end_crystal", c.position);
|
||||
c.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
|
||||
}
|
||||
Target::Dragon(id) => {
|
||||
let d = self.dragon.as_ref().filter(|d| d.id == id)?;
|
||||
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
|
||||
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
|
||||
@@ -397,6 +414,19 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn cmd_data(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let target = self.data_ref(ctx, 0)?;
|
||||
// An entity in another level is read and written in the level that
|
||||
// holds it.
|
||||
if let DataRef::Entity(t) = &target {
|
||||
let t = *t;
|
||||
let target = target.clone();
|
||||
return match self.in_target_level(t, |g| g.cmd_data_in(id, ctx, target)) {
|
||||
Some(r) => r,
|
||||
None => Err(err("argument.entity.notfound.entity", &[])),
|
||||
};
|
||||
}
|
||||
self.cmd_data_in(id, ctx, target)
|
||||
}
|
||||
fn cmd_data_in(&mut self, id: &str, ctx: &Context, target: DataRef) -> R<i32> {
|
||||
match id {
|
||||
"data.merge" => {
|
||||
let Some(Arg::Str(nbt)) = ctx.get("nbt") else {
|
||||
|
||||
@@ -663,10 +663,11 @@ impl Game<'_> {
|
||||
// TraceableEntity.getOwner: who shot a projectile, who threw an
|
||||
// item, who summoned a vex.
|
||||
Some("origin") => match entity {
|
||||
Target::Projectile(i) => self
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.and_then(|p| match (p.owner?, p.guest) {
|
||||
// A guest's shot: `Who::Player` is them.
|
||||
(horizon_client::mobs::Who::Player, Some(conn))
|
||||
@@ -907,10 +908,12 @@ impl Game<'_> {
|
||||
.coords(c, "pos")
|
||||
.map(|co| co.block(self.source_position(), self.source_rotation()));
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
return Ok(flag(
|
||||
dimension == self.dim
|
||||
&& p.is_some_and(|p| self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4))),
|
||||
));
|
||||
// ExecuteCommand.isChunkLoaded: ENTITY_TICKING, which is past lit.
|
||||
let loaded = |p: [i32; 3]| {
|
||||
let key = (p[0] >> 4, p[2] >> 4);
|
||||
self.world.chunks.contains_key(&key) && self.streaming.is_lit(key.0, key.1)
|
||||
};
|
||||
return Ok(flag(dimension == self.dim && p.is_some_and(loaded)));
|
||||
}
|
||||
if c.has("dimension") {
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
|
||||
@@ -19,7 +19,17 @@ use std::convert::Infallible;
|
||||
|
||||
type ItemConsumable = Consumable<String, ConsumeEffect>;
|
||||
|
||||
/// `EntityEvent.USE_ITEM_COMPLETE`: the server finished the player's use.
|
||||
pub(super) const USE_ITEM_COMPLETE_EVENT: i8 = 9;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ServerPlayer.completeUsingItem`: the player's own client is told
|
||||
/// first, and completes the use itself (eating the food there too).
|
||||
pub(super) fn send_use_item_complete(&mut self) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_entity_events.push(USE_ITEM_COMPLETE_EVENT);
|
||||
}
|
||||
}
|
||||
/// `Consumable.emitParticlesAndSounds(user.getRandom(), user, stack, count)`.
|
||||
pub(super) fn emit_consume(
|
||||
&mut self,
|
||||
|
||||
@@ -878,6 +878,9 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
|
||||
@@ -146,6 +146,8 @@ fn flame_geometry(
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
h -= 0.45;
|
||||
@@ -458,9 +460,9 @@ impl Game<'_> {
|
||||
};
|
||||
spawning::LocalDifficulty::new(base, self.world.day_time, inhabited, moon)
|
||||
}
|
||||
/// Advance every mob one tick, then apply their events.
|
||||
pub(super) fn tick_creatures(&mut self) -> Result<()> {
|
||||
self.attack_ticks = self.attack_ticks.saturating_add(1);
|
||||
/// Generation's animals for the chunks generated since, once lit: chunk
|
||||
/// generation places them whether the level ticks or not.
|
||||
pub(super) fn spawn_fresh_chunks(&mut self) {
|
||||
let fresh: Vec<(i32, i32)> = self
|
||||
.world
|
||||
.chunks
|
||||
@@ -474,6 +476,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.spawn_generation_creatures(key.0, key.1);
|
||||
}
|
||||
}
|
||||
/// Advance every mob one tick, then apply their events.
|
||||
pub(super) fn tick_creatures(&mut self) -> Result<()> {
|
||||
self.attack_ticks = self.attack_ticks.saturating_add(1);
|
||||
self.tick_leads();
|
||||
self.spawn_natural();
|
||||
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
|
||||
@@ -928,6 +934,15 @@ impl Game<'_> {
|
||||
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
}
|
||||
/// Adds a projectile, giving it an id from the entity counter
|
||||
/// (`Entity.id`, the same counter as the rest) when it has none.
|
||||
pub(super) fn add_projectile(&mut self, mut p: horizon_client::projectiles::Projectile) {
|
||||
if p.id == 0 {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
for e in events {
|
||||
match e {
|
||||
@@ -980,6 +995,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::Shift { target, delta } => {
|
||||
let d = Vec3::new(delta[0], delta[1], delta[2]);
|
||||
match target {
|
||||
Who::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = p.position.add_vector(d);
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.position = m.position.add_vector(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::Wololo { mob } => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
// DyeColor.RED.
|
||||
@@ -1119,7 +1149,7 @@ impl Game<'_> {
|
||||
let v = p.velocity;
|
||||
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
self.add_projectile(p);
|
||||
self.projectile_event("minecraft:projectile_shoot", owner, at);
|
||||
}
|
||||
MobEvent::Spawn(mut m) => {
|
||||
@@ -2259,7 +2289,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if keep {
|
||||
self.creatures.projectiles.push(arrow);
|
||||
self.add_projectile(arrow);
|
||||
}
|
||||
}
|
||||
/// `TargetBlock.onProjectileHit`: a target block takes its power from
|
||||
@@ -3309,7 +3339,7 @@ impl Game<'_> {
|
||||
velocity,
|
||||
on_ground,
|
||||
);
|
||||
self.creatures.projectiles.push(proj);
|
||||
self.add_projectile(proj);
|
||||
self.player_projectile_shot();
|
||||
// ItemStack.use's applyAfterUseComponentSideEffects: the pearl's
|
||||
// second is the `use_cooldown` component, as for any thrown item.
|
||||
@@ -3406,7 +3436,7 @@ impl Game<'_> {
|
||||
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = f64::from(self.settings.entity_distance);
|
||||
let scale = self.view_scale();
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
|
||||
@@ -3415,9 +3445,16 @@ impl Game<'_> {
|
||||
}
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
|
||||
// mean edge, scaled by the entity distance option.
|
||||
// mean edge, scaled by the view scale. RemotePlayer's box counts
|
||||
// ten times.
|
||||
let (w, h, _) = mob.dims();
|
||||
let range = (w + h + w) / 3.0 * 64.0 * scale;
|
||||
let edge = (w + h + w) / 3.0
|
||||
* if &*mob.kind.id == "minecraft:player" {
|
||||
10.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let range = edge * 64.0 * scale;
|
||||
if d.length_squared() >= range * range {
|
||||
continue;
|
||||
}
|
||||
@@ -3651,6 +3688,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ impl Game<'_> {
|
||||
a.pierce = pierce;
|
||||
}
|
||||
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
|
||||
self.creatures.projectiles.push(arrow);
|
||||
self.add_projectile(arrow);
|
||||
}
|
||||
self.player_projectile_shot();
|
||||
let pitch = if index == 0 {
|
||||
|
||||
@@ -348,11 +348,15 @@ impl Game<'_> {
|
||||
let Some(state) = self.pack.assets.registry.default_state(&name) else {
|
||||
return;
|
||||
};
|
||||
let at = [
|
||||
c.position.x.floor() as i32,
|
||||
c.position.y.floor() as i32,
|
||||
c.position.z.floor() as i32,
|
||||
];
|
||||
let p = c.position;
|
||||
// Entity.getY(2/3) is relative to the cushion's quarter-block height.
|
||||
let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z];
|
||||
// Guest actions temporarily take self.lan; flush once the hub is back.
|
||||
// Queue before resolving a sprite: a dedicated server has no renderer.
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_cushion_breaks.push((state, particle_at));
|
||||
}
|
||||
let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
|
||||
return;
|
||||
};
|
||||
@@ -361,14 +365,8 @@ impl Game<'_> {
|
||||
sprite,
|
||||
tint,
|
||||
);
|
||||
let p = c.position;
|
||||
self.particles.send_particles(
|
||||
&options,
|
||||
[p.x, p.y + 0.666_666_7, p.z],
|
||||
[0.25, 0.0625, 0.25],
|
||||
0.05,
|
||||
10,
|
||||
);
|
||||
self.particles
|
||||
.send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10);
|
||||
}
|
||||
|
||||
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
|
||||
@@ -455,8 +453,7 @@ impl Game<'_> {
|
||||
glam::DVec3::new(lo.x, lo.y, lo.z),
|
||||
glam::DVec3::new(hi.x, hi.y, hi.z),
|
||||
);
|
||||
let range =
|
||||
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
|
||||
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use horizon_client::{
|
||||
use horizon_protocol::text::translatable;
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
use std::{
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex, OnceLock, mpsc},
|
||||
time::{Duration, Instant},
|
||||
@@ -89,7 +90,9 @@ fn log_from(thread: &str, level: &str, message: &str) {
|
||||
}
|
||||
});
|
||||
// Log4j's %d{HH:mm:ss}: the local time, as in logs/latest.log.
|
||||
println!(
|
||||
// The launcher can close its stdout pipe while the server keeps running.
|
||||
let _ = writeln!(
|
||||
std::io::stdout().lock(),
|
||||
"[{}] [{thread}/{level}]: {message}",
|
||||
chrono::Local::now().format("%H:%M:%S")
|
||||
);
|
||||
@@ -295,14 +298,14 @@ impl Lists {
|
||||
}
|
||||
}
|
||||
|
||||
/// The world presets of 26.3's `worldgen/world_preset`.
|
||||
const WORLD_PRESETS: [&str; 6] = [
|
||||
"minecraft:normal",
|
||||
"minecraft:flat",
|
||||
"minecraft:large_biomes",
|
||||
"minecraft:amplified",
|
||||
"minecraft:single_biome_surface",
|
||||
"minecraft:debug_all_block_states",
|
||||
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
|
||||
/// names, before the presets had their own ids.
|
||||
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
|
||||
("default", horizon_client::save::WorldPreset::Normal),
|
||||
(
|
||||
"largebiomes",
|
||||
horizon_client::save::WorldPreset::LargeBiomes,
|
||||
),
|
||||
];
|
||||
|
||||
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
|
||||
@@ -319,11 +322,18 @@ impl Game<'_> {
|
||||
/// DedicatedServerProperties.createDimensions for a world that doesn't
|
||||
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
|
||||
/// and `generate-structures`, written before the world opens so that
|
||||
/// streaming reads them. Horizon generates the normal and flat presets.
|
||||
/// streaming reads them.
|
||||
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
|
||||
let level_type = &props.level_type;
|
||||
let preset = parse_identifier(level_type)
|
||||
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
|
||||
.as_deref()
|
||||
.and_then(horizon_client::save::WorldPreset::of_id)
|
||||
.or_else(|| {
|
||||
LEGACY_PRESET_NAMES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == level_type)
|
||||
.map(|(_, preset)| *preset)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
@@ -332,18 +342,9 @@ impl Game<'_> {
|
||||
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
|
||||
),
|
||||
);
|
||||
"minecraft:normal".to_owned()
|
||||
horizon_client::save::WorldPreset::Normal
|
||||
});
|
||||
let flat = preset == "minecraft:flat";
|
||||
if !flat && preset != "minecraft:normal" {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
"WARN",
|
||||
&format!(
|
||||
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
|
||||
),
|
||||
);
|
||||
}
|
||||
let flat = preset == horizon_client::save::WorldPreset::Flat;
|
||||
let settings = if flat {
|
||||
let biomes = self.res.biomes();
|
||||
let (settings, errors) = horizon_client::flatgen::settings_from_json(
|
||||
@@ -359,7 +360,7 @@ impl Game<'_> {
|
||||
None
|
||||
};
|
||||
let options = horizon_client::save::WorldGen {
|
||||
flat,
|
||||
preset,
|
||||
structures: props.generate_structures,
|
||||
bonus_chest: false,
|
||||
};
|
||||
@@ -618,6 +619,22 @@ impl Game<'_> {
|
||||
log_ips: props.log_ips,
|
||||
}),
|
||||
resource_pack: props.resource_pack.clone(),
|
||||
// DedicatedServer.serverLinks: `bug-report-link` as the only link
|
||||
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
|
||||
server_links: if props.bug_report_link.is_empty() {
|
||||
Vec::new()
|
||||
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
|
||||
vec![horizon_protocol::packets::configuration::ServerLink {
|
||||
kind: horizon_protocol::packets::configuration::ServerLinkKind::Known(0),
|
||||
link: props.bug_report_link.clone(),
|
||||
}]
|
||||
} else {
|
||||
log(
|
||||
"WARN",
|
||||
&format!("Failed to parse bug link {}", props.bug_report_link),
|
||||
);
|
||||
Vec::new()
|
||||
},
|
||||
};
|
||||
let config = HubConfig {
|
||||
default_game_mode: game_mode,
|
||||
@@ -1311,3 +1328,110 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{capture_log, log, log_from};
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
process::{Command, Stdio},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
|
||||
const WARNING: &str = "Authentication warning";
|
||||
const READY: &str = "horizon-console-log-ready\n";
|
||||
const CHILD_HOME: &str = "HORIZON_TEST_CONSOLE_LOG_HOME";
|
||||
|
||||
fn assert_log_lines(text: &str) {
|
||||
let expected = [
|
||||
format!("] [Server thread/INFO]: {LOGIN}"),
|
||||
format!("] [User Authenticator #1/WARN]: {WARNING}"),
|
||||
];
|
||||
assert_eq!(text.lines().count(), expected.len());
|
||||
for (line, suffix) in text.lines().zip(expected) {
|
||||
assert!(line.starts_with('['));
|
||||
assert!(chrono::NaiveTime::parse_from_str(&line[1..9], "%H:%M:%S").is_ok());
|
||||
assert_eq!(&line[9..], suffix);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_logging_survives_closed_stdout() {
|
||||
if let Some(home) = std::env::var_os(CHILD_HOME) {
|
||||
horizon_client::logging::init(std::path::Path::new(&home), "Server thread");
|
||||
// Let the parent close the real stdout pipe only after libtest's startup output.
|
||||
io::stdout().write_all(READY.as_bytes()).unwrap();
|
||||
io::stdout().flush().unwrap();
|
||||
io::stdin().read_exact(&mut [0]).unwrap();
|
||||
std::thread::Builder::new()
|
||||
.name("Server thread".into())
|
||||
.spawn(|| {
|
||||
let captured = capture_log(|| {
|
||||
log("INFO", LOGIN);
|
||||
log_from("User Authenticator #1", "WARN", WARNING);
|
||||
});
|
||||
assert_eq!(captured, [LOGIN, WARNING]);
|
||||
horizon_client::logging::flush();
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
// Libtest's final status also writes to stdout; only the game logger is under test.
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
for closed_stdout in [false, true] {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let home = std::env::temp_dir().join(format!(
|
||||
"horizon-console-log-test-{}-{unique}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&home).unwrap();
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"game::dedicated::tests::console_logging_survives_closed_stdout",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_HOME, &home)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
assert!(
|
||||
stdout.read_line(&mut line).unwrap() > 0,
|
||||
"child was not ready"
|
||||
);
|
||||
if line.ends_with(READY) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if closed_stdout {
|
||||
drop(stdout); // No reader remains: the next console write gets EPIPE.
|
||||
} else {
|
||||
child.stdout = Some(stdout.into_inner());
|
||||
}
|
||||
child.stdin.take().unwrap().write_all(&[1]).unwrap();
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"closed_stdout={closed_stdout}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if !closed_stdout {
|
||||
assert_log_lines(std::str::from_utf8(&output.stdout).unwrap());
|
||||
}
|
||||
assert_log_lines(&fs::read_to_string(home.join("logs/latest.log")).unwrap());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1034,7 +1034,7 @@ impl Game<'_> {
|
||||
a.pickup = horizon_client::arrows::Pickup::Allowed;
|
||||
}
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
self.creatures.projectiles.push(shot);
|
||||
self.add_projectile(shot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,8 +828,10 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
// EndCrystal's constructor takes an id from the entity counter.
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(Crystal {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
position: at,
|
||||
show_bottom: true,
|
||||
time,
|
||||
@@ -2485,6 +2487,8 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color: c,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2550,6 +2554,8 @@ impl Game<'_> {
|
||||
layer: 6,
|
||||
color: c,
|
||||
light: wrap_light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
(last_sin, last_cos, last_u) = (sin, cos, uu);
|
||||
@@ -2595,6 +2601,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: 240 | 240 << 8,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2835,8 +2843,11 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
// EndCrystal's constructor takes an id from the entity counter, so
|
||||
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(Crystal {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(x + 0.5, y, z + 0.5),
|
||||
show_bottom: false,
|
||||
time,
|
||||
@@ -2853,6 +2864,21 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `EndCrystal.kill` (`/kill`): the crystal is removed, without a blast
|
||||
/// (`Entity.kill` does not hurt it).
|
||||
pub(super) fn kill_crystal(&mut self, id: u32) {
|
||||
if let Some(i) = self.crystals.iter().position(|c| c.id == id) {
|
||||
self.destroy_crystal(i, None, CrystalDamage::Explosion);
|
||||
}
|
||||
}
|
||||
/// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it
|
||||
/// does not hurt it (the dragon flies its death flight when its health
|
||||
/// runs out).
|
||||
pub(super) fn kill_dragon(&mut self, id: u32) {
|
||||
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
|
||||
self.dragon = None;
|
||||
}
|
||||
}
|
||||
/// Any entity touching `room` (`Level.getEntities(null, box)`).
|
||||
fn entity_in(&self, room: Aabb) -> bool {
|
||||
let touches = |lo: Vec3, hi: Vec3| {
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
//! sky. The dragon fight itself lives in game/dragon.rs: the podium is
|
||||
//! inactive until the dragon dies.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::nether_portal::ENTITY_DELAY;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
containers::{BlockEntity, EndGateway},
|
||||
environment::Environment,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
@@ -14,12 +16,20 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// A list of doubles, as an NBT list of them.
|
||||
fn doubles(values: [f64; 3]) -> Tag {
|
||||
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
|
||||
}
|
||||
|
||||
/// ServerLevel.END_SPAWN_POINT.
|
||||
const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
/// EnderDragonFight gateway ring radius and height.
|
||||
const GATEWAY_RADIUS: f64 = 96.0;
|
||||
const GATEWAY_Y: i32 = 75;
|
||||
@@ -27,14 +37,76 @@ const GATEWAY_Y: i32 = 75;
|
||||
const GATEWAY_FILE: &str = "horizon-end-gateways.json";
|
||||
|
||||
/// A gateway teleport waiting for its destination chunks.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
|
||||
Find { gateway: [i32; 3] },
|
||||
/// `exact` is the gateway's own `ExactTeleport`, read while its chunk is loaded.
|
||||
Find { gateway: [i32; 3], exact: bool },
|
||||
/// A known exit (exact, or findExitPosition around it).
|
||||
Known { exit: [i32; 3], exact: bool },
|
||||
}
|
||||
|
||||
/// An entity travelling through an End gateway: its saved compound and the
|
||||
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
|
||||
#[derive(Clone, Debug)]
|
||||
struct GatewayArrival {
|
||||
/// The entity's compound, as it waits for the exit's chunks.
|
||||
tag: Tag,
|
||||
/// The gateway's exit, or the search for one.
|
||||
job: GatewayJob,
|
||||
/// The spot to load before the job is resolved.
|
||||
target: Vec3,
|
||||
}
|
||||
|
||||
impl GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
|
||||
fn for_gateway(
|
||||
gateway: [i32; 3],
|
||||
entity: Option<&BlockEntity>,
|
||||
legacy: Option<([i32; 3], bool)>,
|
||||
) -> Self {
|
||||
let configured = match entity {
|
||||
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
|
||||
_ => None,
|
||||
};
|
||||
match configured.or(legacy) {
|
||||
Some((exit, exact)) => Self::Known { exit, exact },
|
||||
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
|
||||
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => Self::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true,
|
||||
},
|
||||
None => Self::Find {
|
||||
gateway,
|
||||
exact: exact_teleport(entity),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.exactTeleport` of the block entity at a gateway
|
||||
/// (false without one).
|
||||
fn exact_teleport(entity: Option<&BlockEntity>) -> bool {
|
||||
matches!(entity, Some(BlockEntity::EndGateway(g)) if g.exact_teleport)
|
||||
}
|
||||
|
||||
/// Moves an exit that only the side file knew into the gateway's block entity, unless the block
|
||||
/// entity has its own; true when it took the saved one.
|
||||
fn adopt_saved_exit(entity: &mut EndGateway, (exit, exact): ([i32; 3], bool)) -> bool {
|
||||
if entity.exit_portal.is_some() {
|
||||
return false;
|
||||
}
|
||||
entity.exit_portal = Some(exit);
|
||||
entity.exact_teleport = exact;
|
||||
true
|
||||
}
|
||||
|
||||
/// The end of `getPortalPosition`: the exit itself when the teleport is exact,
|
||||
/// else the spot [`Game::resolve_gateway`] finds around it (`findExitPosition`).
|
||||
fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; 3]) -> [i32; 3] {
|
||||
if exact { exit } else { find(exit) }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EndState {
|
||||
/// Gateway position -> (exit, exact teleport); loaded lazily from the save.
|
||||
@@ -42,6 +114,11 @@ pub(super) struct EndState {
|
||||
job: Option<GatewayJob>,
|
||||
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
|
||||
gateway_cooldown: u32,
|
||||
gateways: HashMap<u32, horizon_client::end_gateway::Traveller>,
|
||||
/// The entities that went through a gateway, waiting for its exit.
|
||||
arrivals: Vec<GatewayArrival>,
|
||||
/// The chunks kept loaded for those arrivals.
|
||||
areas: Vec<(i32, i32)>,
|
||||
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
|
||||
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
|
||||
/// Thrown eyes of ender in flight.
|
||||
@@ -247,6 +324,8 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -754,6 +833,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.world
|
||||
.block_entities
|
||||
.get_or_insert_with((origin[0], origin[1], origin[2]), || {
|
||||
BlockEntity::EndGateway(Default::default())
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -775,8 +859,7 @@ impl Game<'_> {
|
||||
self.end.exits.as_mut().expect("loaded exits")
|
||||
}
|
||||
|
||||
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
|
||||
self.gateway_exits().insert(gateway, (exit, exact));
|
||||
fn save_gateway_exits(&mut self) -> Result<()> {
|
||||
let list: Vec<_> = self
|
||||
.gateway_exits()
|
||||
.iter()
|
||||
@@ -788,6 +871,35 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops a gateway's entry from the side file: its block entity is the source of truth.
|
||||
fn forget_gateway_exit(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
if self.gateway_exits().remove(&gateway).is_some() {
|
||||
self.save_gateway_exits()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
|
||||
// TheEndGatewayBlockEntity.setExitPosition (also EndGatewayFeature.knownExit).
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
|
||||
// The gateway's chunk can be unloaded by now (travel waits for the destination): the
|
||||
// exit then waits in the side file until the gateway is used again (`gateway_job`).
|
||||
if self.world.chunks.contains_key(&chunk)
|
||||
&& let BlockEntity::EndGateway(g) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
|
||||
{
|
||||
g.exit_portal = Some(exit);
|
||||
g.exact_teleport = exact;
|
||||
self.world.modified.insert(chunk);
|
||||
return self.forget_gateway_exit(gateway);
|
||||
}
|
||||
self.gateway_exits().insert(gateway, (exit, exact));
|
||||
self.save_gateway_exits()
|
||||
}
|
||||
|
||||
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
|
||||
/// travel within the End, waiting for the destination chunks to load.
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
@@ -807,25 +919,39 @@ impl Game<'_> {
|
||||
/// its exit, or the search for one, and the spot to load before either
|
||||
/// is resolved ([`Game::resolve_gateway`]).
|
||||
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
|
||||
let known = self.gateway_exits().get(&gateway).copied();
|
||||
let job = match known {
|
||||
Some((exit, exact)) => GatewayJob::Known { exit, exact },
|
||||
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
|
||||
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => {
|
||||
GatewayJob::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true,
|
||||
}
|
||||
let mut known = self.gateway_exits().get(&gateway).copied();
|
||||
// Once the gateway's chunk is loaded its block entity is the source of truth: an exit the
|
||||
// side file kept for it moves there, and the entry is dropped, so removing or replacing
|
||||
// the exit (`/data remove block`) is not undone by an old entry.
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
|
||||
if let Some(saved) = known
|
||||
&& self.world.chunks.contains_key(&chunk)
|
||||
&& let BlockEntity::EndGateway(g) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
|
||||
{
|
||||
if adopt_saved_exit(g, saved) {
|
||||
self.world.modified.insert(chunk);
|
||||
}
|
||||
None => GatewayJob::Find { gateway },
|
||||
};
|
||||
known = None;
|
||||
let _ = self.forget_gateway_exit(gateway);
|
||||
}
|
||||
let job = GatewayJob::for_gateway(
|
||||
gateway,
|
||||
self.world
|
||||
.block_entities
|
||||
.get(&(gateway[0], gateway[1], gateway[2])),
|
||||
known,
|
||||
);
|
||||
let destination = match job {
|
||||
GatewayJob::Known { exit, .. } => Vec3::new(
|
||||
f64::from(exit[0]) + 0.5,
|
||||
f64::from(exit[1]),
|
||||
f64::from(exit[2]) + 0.5,
|
||||
),
|
||||
GatewayJob::Find { gateway } => {
|
||||
GatewayJob::Find { gateway, .. } => {
|
||||
let d = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
|
||||
.normalize_or_zero()
|
||||
* 1024.0;
|
||||
@@ -870,24 +996,33 @@ impl Game<'_> {
|
||||
/// The teleport position of a gateway job, creating the exit gateway (and
|
||||
/// an island when the chunk has no landing spot) for new destinations.
|
||||
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
let bottom_center = |p: [i32; 3]| {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
)
|
||||
};
|
||||
let bottom_center = horizon_client::end_gateway::bottom_center;
|
||||
let find_exit = |game: &Self, exit: [i32; 3]| {
|
||||
let p = game.tallest_block([exit[0], exit[1] + 2, exit[2]], 5, false);
|
||||
[p[0], p[1] + 1, p[2]]
|
||||
};
|
||||
match job {
|
||||
GatewayJob::Known { exit, exact } => Ok(bottom_center(if exact {
|
||||
exit
|
||||
} else {
|
||||
find_exit(self, exit)
|
||||
})),
|
||||
GatewayJob::Find { gateway } => {
|
||||
GatewayJob::Known { exit, exact } => Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
}))),
|
||||
GatewayJob::Find { gateway, exact } => {
|
||||
// The player and the entities may resolve the same gateway:
|
||||
// the first one to arrive finds the exit, the others use it
|
||||
// (its block entity, or the side file while its chunk is
|
||||
// unloaded).
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let found = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::EndGateway(g)) => {
|
||||
g.exit_portal.map(|e| (e, g.exact_teleport))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
.or_else(|| self.gateway_exits().get(&gateway).copied());
|
||||
if let Some((exit, exact)) = found {
|
||||
return Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
})));
|
||||
}
|
||||
// findExitPortalXZPosTentative over the loaded chunks.
|
||||
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
|
||||
.normalize_or_zero();
|
||||
@@ -947,8 +1082,11 @@ impl Game<'_> {
|
||||
let exit = [tallest[0], tallest[1] + 10, tallest[2]];
|
||||
self.place_gateway(exit)?;
|
||||
self.set_gateway_exit(exit, gateway, false)?;
|
||||
self.set_gateway_exit(gateway, exit, false)?;
|
||||
Ok(bottom_center(find_exit(self, exit)))
|
||||
// setExitPosition keeps the gateway's own exactTeleport.
|
||||
self.set_gateway_exit(gateway, exit, exact)?;
|
||||
Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1240,8 +1378,282 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: full,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- End gateway travel for the entities ----
|
||||
|
||||
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
|
||||
/// gateway blocks their box swept through. A gateway that is cooling down
|
||||
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
|
||||
pub(super) fn find_end_gateway_portals(&mut self) {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return;
|
||||
}
|
||||
let items: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, e.position, 0.25, 0.25))
|
||||
.collect();
|
||||
let mobs: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| self.mob_can_use_portal(m))
|
||||
.map(|m| (m.id, m.position, m.width(), m.height()))
|
||||
.collect();
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
|
||||
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
|
||||
== "minecraft:end_gateway"
|
||||
});
|
||||
if let Some(pos) = found {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
for (id, pos) in inside {
|
||||
// One entity at a time: the gateway cools down for 40 ticks.
|
||||
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
|
||||
continue;
|
||||
}
|
||||
self.end.gateways.entry(id).or_default().enter(pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal` for the entities in an End gateway: they go
|
||||
/// through to the gateway's exit, where they wait for its chunks.
|
||||
pub(super) fn handle_end_gateway_portals(&mut self) {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return;
|
||||
}
|
||||
let ids: Vec<(u32, bool)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, true))
|
||||
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
|
||||
.collect();
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.end.gateways.retain(|id, _| present.contains(id));
|
||||
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
|
||||
for (id, allowed) in ids {
|
||||
let Some(state) = self.end.gateways.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
// The End gateway has no transition time (Portal.getPortalTransitionTime).
|
||||
if let Some(entry) = state.travel(allowed) {
|
||||
leaving.push((id, entry));
|
||||
}
|
||||
if state.is_done() {
|
||||
self.end.gateways.remove(&id);
|
||||
}
|
||||
}
|
||||
for (id, entry) in leaving {
|
||||
self.send_through_gateway(id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.teleportToPortalDestination` for an entity that goes through an
|
||||
/// End gateway: it is saved and waits for the exit's chunks.
|
||||
fn send_through_gateway(&mut self, id: u32, gateway: BlockPos) {
|
||||
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
|
||||
&self.dropped[i],
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
);
|
||||
self.dropped.remove(i);
|
||||
self.forget_entity(id);
|
||||
tag
|
||||
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
let mut tag = self.mob_tree_nbt(i);
|
||||
// Entity.removeAfterChangingDimensions drops the leash.
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.remove(&"leash".into());
|
||||
}
|
||||
// The mobs riding it travel with it (their tags are in its compound).
|
||||
let mut gone = vec![id];
|
||||
let mut k = 0;
|
||||
while k < gone.len() {
|
||||
let v = gone[k];
|
||||
gone.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(v))
|
||||
.map(|m| m.id),
|
||||
);
|
||||
k += 1;
|
||||
}
|
||||
self.mobs.retain(|m| !gone.contains(&m.id));
|
||||
for g in gone {
|
||||
self.forget_entity(g);
|
||||
}
|
||||
tag
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
|
||||
self.end.arrivals.push(GatewayArrival { tag, job, target });
|
||||
}
|
||||
|
||||
/// The entities that reached a gateway's exit: they land where the
|
||||
/// gateway leads, once its chunks have loaded.
|
||||
pub(super) fn resolve_end_gateway_arrivals(&mut self) -> Result<()> {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return Ok(());
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.end.arrivals.len() {
|
||||
let (cx, cz) = (
|
||||
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
|
||||
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !self.world.chunks.contains_key(&(cx, cz)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let GatewayArrival {
|
||||
job,
|
||||
target,
|
||||
mut tag,
|
||||
} = self.end.arrivals.remove(i);
|
||||
let at = self.resolve_gateway(job, target)?;
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
|
||||
// setPortalCooldown before the teleport.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
}
|
||||
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
|
||||
match self
|
||||
.world
|
||||
.loaded_entities
|
||||
.iter_mut()
|
||||
.find(|(k, _)| *k == key)
|
||||
{
|
||||
Some((_, tags)) => tags.push(tag),
|
||||
None => self.world.loaded_entities.push((key, vec![tag])),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The chunks the entities waiting to arrive need, kept loaded like the
|
||||
/// areas a server keeps around its players.
|
||||
pub(super) fn keep_gateway_destinations(&mut self) {
|
||||
let areas = &self.end.areas;
|
||||
self.streaming
|
||||
.extra
|
||||
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
|
||||
let mut areas = Vec::new();
|
||||
for a in &self.end.arrivals {
|
||||
let c = (
|
||||
(a.target.x.floor() as i32) >> 4,
|
||||
(a.target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !areas.contains(&c) {
|
||||
areas.push(c);
|
||||
self.streaming.extra.push((c, 1));
|
||||
}
|
||||
}
|
||||
self.end.areas = areas;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::containers::EndGateway;
|
||||
|
||||
#[test]
|
||||
fn a_saved_exit_moves_into_a_gateway_without_one() {
|
||||
let saved = ([8, 100, 0], true);
|
||||
let mut bare = EndGateway::default();
|
||||
assert!(adopt_saved_exit(&mut bare, saved));
|
||||
assert_eq!(bare.exit_portal, Some([8, 100, 0]));
|
||||
assert!(bare.exact_teleport);
|
||||
// The block entity's own exit wins over a stale entry.
|
||||
let mut own = EndGateway {
|
||||
exit_portal: Some([1, 2, 3]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!adopt_saved_exit(&mut own, saved));
|
||||
assert_eq!(own.exit_portal, Some([1, 2, 3]));
|
||||
assert!(!own.exact_teleport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_exit_keeps_the_gateways_exact_teleport() {
|
||||
let exact = BlockEntity::EndGateway(EndGateway {
|
||||
exact_teleport: true,
|
||||
..Default::default()
|
||||
});
|
||||
let plain = BlockEntity::EndGateway(EndGateway::default());
|
||||
assert!(exact_teleport(Some(&exact)));
|
||||
assert!(!exact_teleport(Some(&plain)));
|
||||
assert!(!exact_teleport(None));
|
||||
assert!(!exact_teleport(Some(&BlockEntity::DaylightDetector)));
|
||||
let exit = [8, 100, 0];
|
||||
// Exact: the exit itself, nothing is searched for.
|
||||
assert_eq!(exit_target(exit, true, |_| panic!("searched")), exit);
|
||||
assert_eq!(
|
||||
exit_target(exit, false, |e| [e[0], e[1] + 11, e[2]]),
|
||||
[8, 111, 0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_gateway_job_prefers_block_entity_then_legacy_then_heuristic() {
|
||||
let gateway = [702, 100, 0];
|
||||
let exit = [8, 100, 0];
|
||||
for exact in [true, false] {
|
||||
let entity = BlockEntity::EndGateway(EndGateway {
|
||||
exit_portal: Some(exit),
|
||||
exact_teleport: exact,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, Some(&entity), Some((END_SPAWN, !exact))),
|
||||
GatewayJob::Known { exit, exact },
|
||||
);
|
||||
}
|
||||
let empty = BlockEntity::EndGateway(EndGateway::default());
|
||||
for entity in [None, Some(&empty)] {
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, entity, Some((exit, false))),
|
||||
GatewayJob::Known { exit, exact: false }
|
||||
);
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, entity, None),
|
||||
GatewayJob::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true
|
||||
}
|
||||
);
|
||||
let nearby = [96, 75, 0];
|
||||
let exact = BlockEntity::EndGateway(EndGateway {
|
||||
exact_teleport: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(nearby, Some(&exact), None),
|
||||
GatewayJob::Find {
|
||||
gateway: nearby,
|
||||
exact: true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(nearby, entity, None),
|
||||
GatewayJob::Find {
|
||||
gateway: nearby,
|
||||
exact: false
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,12 +60,27 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Entity.setViewScale (LevelExtractor.extractVisibleEntities): the entity
|
||||
/// distance option times the render distance's boost — 1 at 8 chunks, 2.5
|
||||
/// at 20 or more. The render distance is the client setting, capped by
|
||||
/// the server's view distance when connected
|
||||
/// (Options.getEffectiveRenderDistance).
|
||||
pub(super) fn view_scale(&self) -> f64 {
|
||||
let distance = match &self.remote {
|
||||
Some(r) => self.settings.render_distance.min(r.view_distance),
|
||||
None => self.settings.render_distance,
|
||||
};
|
||||
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
|
||||
/// box's mean edge, scaled by the entity distance option.
|
||||
pub(super) fn default_range(size: glam::DVec3, view_scale: f32) -> f64 {
|
||||
/// box's mean edge, scaled by the view scale.
|
||||
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
|
||||
let edge = (size.x + size.y + size.z) / 3.0;
|
||||
let edge = if edge.is_nan() { 1.0 } else { edge };
|
||||
edge * 64.0 * f64::from(view_scale)
|
||||
edge * 64.0 * view_scale
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -643,6 +643,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,8 +311,9 @@ impl Game<'_> {
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
|
||||
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
|
||||
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at 100%.
|
||||
let range = 16.0 * 64.0 * f64::from(self.settings.entity_distance);
|
||||
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
|
||||
// of 1.
|
||||
let range = 16.0 * 64.0 * self.view_scale();
|
||||
for f in &self.item_frames {
|
||||
let entity_start = out.len();
|
||||
let map = f.item.as_ref().and_then(super::maps::map_id);
|
||||
@@ -377,6 +378,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -420,6 +420,8 @@ impl Game<'_> {
|
||||
s.flags.published.store(0, Ordering::Relaxed);
|
||||
}
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
// PlayerList.removeAll: the owner and every guest leave the game.
|
||||
host.award_leave_game_to_all();
|
||||
for g in &host.hub.guests {
|
||||
self.save_guest(&host, g);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ pub(super) struct Host<'a> {
|
||||
banners: &'a horizon_client::banner::Banners,
|
||||
sprites: &'a HashMap<String, Rect>,
|
||||
skins: &'a RefCell<horizon_client::skins::Downloader>,
|
||||
profiles: &'a RefCell<horizon_client::skins::Resolver>,
|
||||
}
|
||||
impl SpecialHost for Host<'_> {
|
||||
fn banner_texture(&self, pattern: &str) -> Option<String> {
|
||||
@@ -144,14 +145,14 @@ impl SpecialHost for Host<'_> {
|
||||
fn player_skin(&self, profile: &Value) -> Option<String> {
|
||||
// SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation
|
||||
// accepts a texture property without a signature, unlike the tab list.
|
||||
let url = horizon_client::skins::profile_skin_url(profile)?;
|
||||
let location =
|
||||
horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?);
|
||||
let _ = self
|
||||
.skins
|
||||
.borrow_mut()
|
||||
.state(horizon_client::skins::Kind::Skin, &url);
|
||||
self.sprites.contains_key(&location).then_some(location)
|
||||
// A `Dynamic` profile (a name or id only) resolves through the
|
||||
// services (`PlayerSkinRenderCache.getOrDefault`).
|
||||
Some(horizon_client::skins::head_skin(
|
||||
profile,
|
||||
&mut self.profiles.borrow_mut(),
|
||||
&mut self.skins.borrow_mut(),
|
||||
|location| self.sprites.contains_key(location),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +195,7 @@ impl Game<'_> {
|
||||
banners: &self.res.stations().banners,
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
skins: &self.skin_downloads,
|
||||
profiles: &self.skin_profiles,
|
||||
}
|
||||
}
|
||||
/// The client level as item models read it: worked out once a frame (a
|
||||
@@ -296,8 +298,32 @@ impl Game<'_> {
|
||||
x,
|
||||
y,
|
||||
self.started.elapsed().as_secs_f32(),
|
||||
self.settings.file.double("glintSpeed") as f32,
|
||||
self.settings.file.double("glintStrength") as f32,
|
||||
);
|
||||
}
|
||||
/// The `GLINT_TEXTURING` inputs the atlas and options provide.
|
||||
pub(super) fn glint(&self) -> Option<item_render::Glint> {
|
||||
self.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get("misc/enchanted_glint_item")
|
||||
.copied()
|
||||
.map(|_| item_render::Glint {
|
||||
speed: self.settings.file.double("glintSpeed") as f32,
|
||||
alpha: self.settings.file.double("glintStrength") as f32,
|
||||
millis: self.started.elapsed().as_secs_f32() * 1000.0,
|
||||
scale: 0.5,
|
||||
})
|
||||
}
|
||||
/// The glint scroll phase shared by world items and armour
|
||||
/// (`item_render::Glint::scroll_ticks`).
|
||||
pub(super) fn glint_ticks(&self) -> f32 {
|
||||
item_render::Glint::scroll_ticks(
|
||||
self.started.elapsed().as_secs_f32() * 1000.0,
|
||||
self.settings.file.double("glintSpeed") as f32,
|
||||
)
|
||||
}
|
||||
/// Submit a resolved item under `pose` (camera-relative) at `light`
|
||||
/// (packed lightmap coordinates).
|
||||
pub(super) fn submit_item_world(
|
||||
@@ -311,6 +337,7 @@ impl Game<'_> {
|
||||
out,
|
||||
light,
|
||||
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
|
||||
glint: self.glint(),
|
||||
};
|
||||
state.submit(
|
||||
&self.pack.item_renderer,
|
||||
@@ -332,6 +359,7 @@ impl Game<'_> {
|
||||
out,
|
||||
light,
|
||||
lights: None,
|
||||
glint: self.glint(),
|
||||
};
|
||||
state.submit(
|
||||
&self.pack.item_renderer,
|
||||
|
||||
@@ -21,6 +21,7 @@ use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::{
|
||||
crypto::offline_uuid,
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
@@ -91,6 +92,33 @@ pub(super) struct LanHost {
|
||||
states: lan::entity::States,
|
||||
}
|
||||
|
||||
impl LanHost {
|
||||
/// `PlayerList.remove`: `player.awardStat(Stats.LEAVE_GAME)` before the
|
||||
/// player is saved. Once per guest, however often they are saved.
|
||||
pub(super) fn award_leave_game(&mut self, conn: u32) {
|
||||
if let Some(play) = self.play.get_mut(&conn)
|
||||
&& !play.left_game
|
||||
{
|
||||
play.left_game = true;
|
||||
play.stats.custom("leave_game", 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerList.removeAll`: every player who joined leaves with the world.
|
||||
pub(super) fn award_leave_game_to_all(&mut self) {
|
||||
let conns: Vec<u32> = self
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
self.award_leave_game(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The player who owns an integrated server's world: a guest whose state is
|
||||
/// the world's own player (`players/data/<singleplayer_uuid>.dat`), as in
|
||||
/// single player.
|
||||
@@ -704,6 +732,7 @@ impl Game<'_> {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
host.award_leave_game_to_all();
|
||||
for g in &host.hub.guests {
|
||||
self.save_guest(&host, g);
|
||||
}
|
||||
@@ -715,6 +744,7 @@ impl Game<'_> {
|
||||
self.audio.capture = None;
|
||||
self.lan_events.clear();
|
||||
self.lan_critical_hits.clear();
|
||||
self.lan_cushion_breaks.clear();
|
||||
self.streaming.extra.clear();
|
||||
if announce {
|
||||
self.system_message(Text::translate(
|
||||
@@ -1114,6 +1144,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.lan_crafting_left(&mut host, &mut guest);
|
||||
if guest.placed {
|
||||
host.award_leave_game(guest.conn);
|
||||
}
|
||||
self.save_guest(&host, &guest);
|
||||
host.models.remove(&guest.conn);
|
||||
host.play.remove(&guest.conn);
|
||||
@@ -1331,6 +1364,33 @@ impl Game<'_> {
|
||||
/// Send the tick's level events, sounds and mob animations.
|
||||
fn lan_flush_effects(&mut self, host: &mut LanHost) {
|
||||
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
|
||||
for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) {
|
||||
if let Some(id) = host
|
||||
.hub
|
||||
.registry_id("minecraft:particle_type", "minecraft:block")
|
||||
{
|
||||
host.hub.broadcast_near(
|
||||
at,
|
||||
32.0,
|
||||
&C::LevelParticles(play::LevelParticles {
|
||||
particle: horizon_protocol::entity_data::Particle {
|
||||
id,
|
||||
data: Some(horizon_protocol::entity_data::ParticleData::State(
|
||||
state.0 as i32,
|
||||
)),
|
||||
},
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: [0.25, 0.0625, 0.25],
|
||||
max_speed: [0.05; 3],
|
||||
count: 10,
|
||||
randomization: 0,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
|
||||
if kind == 2001 {
|
||||
host.broken
|
||||
|
||||
@@ -74,6 +74,9 @@ pub(super) struct GuestPlay {
|
||||
potions: super::potions::BodyPotions,
|
||||
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
|
||||
pub(super) stats: super::stats::Stats,
|
||||
/// `Stats.LEAVE_GAME` is counted: a guest is saved more than once on the
|
||||
/// way out, the statistic is awarded once (`LanHost::award_leave_game`).
|
||||
pub(super) left_game: bool,
|
||||
last_inventory: u64,
|
||||
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
|
||||
pub(super) portal: horizon_client::nether_portal::PortalState,
|
||||
@@ -165,6 +168,8 @@ pub(super) struct GuestSwap {
|
||||
acting: (Option<u32>, Option<String>, Option<u128>),
|
||||
/// The packets queued for that guest.
|
||||
packets: Vec<play::Clientbound>,
|
||||
/// The entity events queued for that guest.
|
||||
entity_events: Vec<i8>,
|
||||
/// The host's count of swings started, kept while the guest acts.
|
||||
swings: u32,
|
||||
/// How many projectiles there were: those added since are the guest's.
|
||||
@@ -389,6 +394,7 @@ impl Game<'_> {
|
||||
self.lan_acting_uuid.replace(g.uuid),
|
||||
);
|
||||
let packets = std::mem::take(&mut self.guest_packets);
|
||||
let entity_events = std::mem::take(&mut self.guest_entity_events);
|
||||
let swings = self.swings_started;
|
||||
let projectiles = self.creatures.projectiles.len();
|
||||
let modifiers = std::mem::replace(
|
||||
@@ -449,6 +455,7 @@ impl Game<'_> {
|
||||
death_cause,
|
||||
acting,
|
||||
packets,
|
||||
entity_events,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
@@ -508,6 +515,7 @@ impl Game<'_> {
|
||||
combat,
|
||||
acting,
|
||||
packets,
|
||||
entity_events,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
@@ -529,6 +537,17 @@ impl Game<'_> {
|
||||
for packet in std::mem::replace(&mut self.guest_packets, packets) {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
// ServerPlayer.connection.send: the guest's own entity events.
|
||||
let entity_id = host.hub.guests[i].entity_id;
|
||||
for event in std::mem::replace(&mut self.guest_entity_events, entity_events) {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::EntityEvent(play::EntityEvent {
|
||||
id: entity_id,
|
||||
event,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// ServerItemCooldowns.onCooldownStarted: the guest's own cooldown.
|
||||
for (group, duration) in std::mem::take(&mut self.guest_cooldowns) {
|
||||
host.hub.send_to(
|
||||
@@ -1916,21 +1935,8 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled, a thrown pearl).
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
// ServerGamePacketListenerImpl.tick's dropSpamThrottler.
|
||||
play.creative_drops = play.creative_drops.saturating_sub(1);
|
||||
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
|
||||
// the guest (a duration of 0 removes it).
|
||||
for group in play.cooldowns.tick() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let digging = play.digging;
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
@@ -1967,6 +1973,24 @@ impl Game<'_> {
|
||||
play.digging = None;
|
||||
}
|
||||
}
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled, a thrown pearl), after
|
||||
// LivingEntity.tick's use: an entry a finished use starts has 20
|
||||
// ticks left when the next tick's packets are handled, as the
|
||||
// guest's own client counts.
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
|
||||
// the guest (a duration of 0 removes it).
|
||||
for group in play.cooldowns.tick() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Player.tick: a different main-hand item resets the attack
|
||||
// strength, and detectEquipmentUpdates gives the guest its modifiers.
|
||||
let g = &host.hub.guests[gi];
|
||||
|
||||
@@ -576,16 +576,18 @@ impl Game<'_> {
|
||||
/// A guest left: what their crafting grids and workstation held goes back into their
|
||||
/// inventory before it is saved (dropped where they stood if full).
|
||||
pub(super) fn lan_crafting_left(&mut self, host: &mut LanHost, g: &mut Guest) {
|
||||
let play = host.play.remove(&g.conn);
|
||||
// The guest's play state stays: their statistics are awarded and saved
|
||||
// after this (`HostEvent::Left`), which removes it.
|
||||
let play = host.play.get_mut(&g.conn);
|
||||
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
|
||||
g.craft_result = None;
|
||||
if let Some(play) = play {
|
||||
if let Some((_, _, menu, _)) = play.crafting.table {
|
||||
if let Some((_, _, menu, _)) = play.crafting.table.take() {
|
||||
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
|
||||
}
|
||||
// A workstation's inputs too.
|
||||
if let Some(station) = &play.station {
|
||||
grid.extend(super::lan_stations::station_inputs(station));
|
||||
if let Some(station) = play.station.take() {
|
||||
grid.extend(super::lan_stations::station_inputs(&station));
|
||||
}
|
||||
}
|
||||
if grid.is_empty() {
|
||||
|
||||
@@ -386,6 +386,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
|
||||
}
|
||||
/// Event 2016: `Shulker.teleportSomewhere`'s particles between the
|
||||
/// shell's old and new cells.
|
||||
pub(super) fn shulker_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [8, 8, 8], self.settings.particles);
|
||||
}
|
||||
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
|
||||
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
|
||||
@@ -120,6 +120,10 @@ impl Levels {
|
||||
.iter_mut()
|
||||
.map(|(d, p)| (*d, &mut p.world.chunks))
|
||||
}
|
||||
/// The levels that are not in place, by dimension.
|
||||
pub(super) fn parked_dims(&self) -> Vec<Dim> {
|
||||
self.parked.keys().copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -1133,6 +1133,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1180,6 +1182,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1251,6 +1255,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ pub(super) struct Minecart {
|
||||
pub yaw: f32,
|
||||
pub previous_yaw: f32,
|
||||
pub flipped: bool,
|
||||
on_ground: bool,
|
||||
pub(super) on_ground: bool,
|
||||
horizontal_collision: bool,
|
||||
fall_distance: f64,
|
||||
pub hurt_time: i32,
|
||||
@@ -90,6 +90,8 @@ pub(super) struct Minecart {
|
||||
/// Chest minecarts: the loot table rolled into `items` on load, saved
|
||||
/// again while the items are still that roll.
|
||||
pub loot: Option<horizon_client::save::RolledLoot>,
|
||||
/// `Entity.firstTick`: not ticked yet (saved as `HasTicked`).
|
||||
pub first_tick: bool,
|
||||
}
|
||||
pub(super) const WIDTH: f64 = 0.98;
|
||||
pub(super) const HEIGHT: f64 = 0.7;
|
||||
@@ -186,6 +188,7 @@ impl Minecart {
|
||||
fuse: -1,
|
||||
enabled: true,
|
||||
loot: None,
|
||||
first_tick: true,
|
||||
}
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
@@ -536,6 +539,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
|
||||
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
|
||||
c.first_tick = false;
|
||||
c.previous = c.position;
|
||||
c.previous_yaw = c.yaw;
|
||||
c.hurt_time = (c.hurt_time - 1).max(0);
|
||||
@@ -1297,6 +1301,7 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1340,9 +1345,8 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's mean
|
||||
// edge, scaled by the entity distance option.
|
||||
let range =
|
||||
(WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * f64::from(self.settings.entity_distance);
|
||||
// edge, scaled by the view scale.
|
||||
let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale();
|
||||
for c in &self.carts {
|
||||
let entity_start = out.len();
|
||||
let Some(model) = self.cart_models.models.get(&c.kind) else {
|
||||
@@ -1509,6 +1513,10 @@ impl Minecart {
|
||||
map.insert(JavaString::from(k), v);
|
||||
};
|
||||
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
|
||||
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
|
||||
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
|
||||
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
|
||||
put("Fire", Tag::Short(0));
|
||||
match self.kind {
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
put("Items", horizon_client::save::encode_items(&self.items));
|
||||
@@ -1558,6 +1566,7 @@ impl Minecart {
|
||||
_ => 0.0,
|
||||
};
|
||||
c.flipped = int("FlippedRotation").unwrap_or(0) != 0;
|
||||
c.first_tick = int("HasTicked").unwrap_or(0) != 0;
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
@@ -1582,4 +1591,30 @@ mod tests {
|
||||
assert_eq!(display_offset(CartKind::CommandBlock), 6.0);
|
||||
assert_eq!(display_offset(CartKind::Rideable), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cart_saves_as_never_ticked_until_it_ticks() {
|
||||
use horizon_world::nbt::Tag;
|
||||
let generated = Tag::compound([
|
||||
("id", Tag::String("minecraft:chest_minecart".into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(
|
||||
[0.5f64, 40.5, 0.5]
|
||||
.map(|v| Tag::Double(v.to_bits()))
|
||||
.to_vec(),
|
||||
),
|
||||
),
|
||||
("HasTicked", Tag::Byte(1)),
|
||||
("Fire", Tag::Short(0)),
|
||||
]);
|
||||
let mut cart = Minecart::from_nbt(&generated, 1, CartKind::Chest).unwrap();
|
||||
let saved = cart.to_nbt([0; 4], Some(&generated));
|
||||
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
|
||||
cart.first_tick = false;
|
||||
let saved = cart.to_nbt([0; 4], Some(&generated));
|
||||
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,8 @@ impl Game<'_> {
|
||||
layer: overlay,
|
||||
color,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,19 @@ impl Game<'_> {
|
||||
if !equine::is_equine(species) || !m.alive() || Some(m.id) == self.riding {
|
||||
return false;
|
||||
}
|
||||
// AbstractHorse.mobInteract: a horse a mob rides (`isVehicle`) has no
|
||||
// seat, inventory or feeding to give. Camels seat two and check their
|
||||
// passengers themselves.
|
||||
let id = m.id;
|
||||
if !matches!(species, Species::Camel | Species::CamelHusk)
|
||||
&& self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|p| p.extra.mount == Some(id) && !p.removed && p.alive())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let m = &self.mobs[index];
|
||||
// AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory.
|
||||
if !m.is_baby()
|
||||
&& m.extra.tame
|
||||
|
||||
@@ -14,7 +14,9 @@ impl Game<'_> {
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let partial = alpha as f32;
|
||||
for &(p, piston) in &self.world.moving_pistons {
|
||||
let mut states = self.world.moving_pistons.clone();
|
||||
states.extend(self.world.moving_piston_fallback.iter().copied());
|
||||
for (p, piston) in states {
|
||||
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -377,6 +377,7 @@ impl Game<'_> {
|
||||
layer,
|
||||
color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! it reads, the spawning chunks and caps of `ServerChunkCache.tickChunks`,
|
||||
//! and the mobs it adds (finalized with the game's `finalizeSpawn`).
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::vanillagen::VanillaTerrain;
|
||||
use horizon_client::{
|
||||
biome::{BiomeInfo, SpawnEntry},
|
||||
blocks::BlockFacts,
|
||||
@@ -19,9 +20,12 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource, WorldgenRandom},
|
||||
random::{LegacyRandom, RandomSource, WorldgenRandom, XoroshiroRandom},
|
||||
};
|
||||
use horizon_world::{
|
||||
nbt::{Compound, Tag},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A player in the level in place, as spawning and despawning see it.
|
||||
@@ -148,6 +152,16 @@ impl GameLevel<'_, '_> {
|
||||
let chunk = self.game.world.chunks.get(&(x >> 4, z >> 4))?;
|
||||
Some(self.game.res.biomes().get(chunk.biome(x, y, z)))
|
||||
}
|
||||
/// The block whose noise cell `BiomeManager.getBiome` reads.
|
||||
fn zoomed(&self, x: i32, y: i32, z: i32) -> [i32; 3] {
|
||||
let zoom = self.game.streaming.biome_zoom;
|
||||
let [qx, qy, qz] = zoom.quart_at_block(horizon_core::position::BlockPos::new(x, y, z));
|
||||
[qx << 2, qy << 2, qz << 2]
|
||||
}
|
||||
fn zoomed_biome(&self, x: i32, y: i32, z: i32) -> Option<&BiomeInfo> {
|
||||
let [x, y, z] = self.zoomed(x, y, z);
|
||||
self.biome(x, y, z)
|
||||
}
|
||||
}
|
||||
impl SpawnLevel for GameLevel<'_, '_> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
@@ -162,7 +176,9 @@ impl SpawnLevel for GameLevel<'_, '_> {
|
||||
fn block_tag(&self, tag: &str, block: &str) -> bool {
|
||||
self.w.block_tag(tag, block)
|
||||
}
|
||||
// Level.getBiome: the zoomed biome.
|
||||
fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool {
|
||||
let [x, y, z] = self.zoomed(x, y, z);
|
||||
self.w.biome_tag(tag, x, y, z)
|
||||
}
|
||||
fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 {
|
||||
@@ -228,11 +244,13 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
Category::from_name(&t.category),
|
||||
))
|
||||
}
|
||||
// The `natural_mob_spawns` attribute reads the zoomed biome
|
||||
// (fullResolutionBiomes), the spawn probability the noise cell.
|
||||
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>> {
|
||||
self.biome(x, y, z).map(|b| &b.spawns)
|
||||
self.zoomed_biome(x, y, z).map(|b| &b.spawns)
|
||||
}
|
||||
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
|
||||
self.biome(x, y, z)?.spawn_costs.get(entity).copied()
|
||||
self.zoomed_biome(x, y, z)?.spawn_costs.get(entity).copied()
|
||||
}
|
||||
fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 {
|
||||
self.biome(x, y, z).map_or(0.1, |b| b.creature_probability)
|
||||
@@ -553,37 +571,68 @@ impl<'a> Game<'a> {
|
||||
}
|
||||
/// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a
|
||||
/// freshly generated chunk, from the biome at its corner column's top,
|
||||
/// with the chunk's decoration-seeded random (mobs are not generated in
|
||||
/// with the chunk's decoration-seeded random, finalized with the
|
||||
/// region's random as generation adds them (mobs are not generated in
|
||||
/// the End).
|
||||
pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) {
|
||||
if !self.world.chunks.contains_key(&(cx, cz)) || self.dim == Dim::End {
|
||||
return;
|
||||
}
|
||||
let Some(terrain) = self.end.locator() else {
|
||||
return;
|
||||
};
|
||||
let mut region = terrain.region_random(cx, cz);
|
||||
let any = region.as_any();
|
||||
let added = if let Some(r) = any.downcast_mut::<LegacyRandom>() {
|
||||
self.generation_creatures(cx, cz, &terrain, r)
|
||||
} else if let Some(r) = any.downcast_mut::<XoroshiroRandom>() {
|
||||
self.generation_creatures(cx, cz, &terrain, r)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
for nbt in added {
|
||||
self.load_mob(Tag::Compound(nbt), None);
|
||||
}
|
||||
}
|
||||
fn generation_creatures<Q: RandomSource + 'static>(
|
||||
&self,
|
||||
cx: i32,
|
||||
cz: i32,
|
||||
terrain: &VanillaTerrain,
|
||||
region: &mut Q,
|
||||
) -> Vec<Compound> {
|
||||
let mut random = WorldgenRandom::new(LegacyRandom::new(0));
|
||||
random.set_decoration_seed(self.level.seed, cx * 16, cz * 16);
|
||||
// The region's own random: no creature's spawn predicate draws from it.
|
||||
let mut rules = LegacyRandom::new(self.level.seed ^ (i64::from(cx) << 32 | i64::from(cz)));
|
||||
let mut pending = Vec::new();
|
||||
{
|
||||
let level = self.natural_level();
|
||||
let source = [cx * 16, level.max_y(), cz * 16];
|
||||
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
|
||||
let spawned = l.game.finalize_spawned(r, &mut pending);
|
||||
if let Some(s) = &spawned {
|
||||
l.pending.borrow_mut().push(s.bounds);
|
||||
let mut added = Vec::new();
|
||||
let mut group = None;
|
||||
let level = self.natural_level();
|
||||
let source = [cx * 16, level.max_y(), cz * 16];
|
||||
let mut spawn = |l: &GameLevel, r: &SpawnRequest, region: &mut Q| {
|
||||
// Each pack starts without group data.
|
||||
if r.group_index == 0 {
|
||||
group = None;
|
||||
}
|
||||
let [x, y, z] = r.position;
|
||||
let (bx, by, bz) = (x.floor() as i32, y.floor() as i32, z.floor() as i32);
|
||||
let biome = l.zoomed_biome(bx, by, bz).map_or("", |b| b.name.as_str());
|
||||
let at = Vec3::new(x, y, z);
|
||||
match terrain.spawn_generated(r.entity, at, r.yaw, biome, region, &mut group) {
|
||||
Some(nbt) => {
|
||||
added.push(nbt);
|
||||
true
|
||||
}
|
||||
spawned
|
||||
};
|
||||
spawn_selection::spawn_mobs_for_chunk_generation(
|
||||
&level,
|
||||
source,
|
||||
(cx, cz),
|
||||
&mut random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |_| {},
|
||||
);
|
||||
}
|
||||
self.add_spawned(pending, true);
|
||||
None => false,
|
||||
}
|
||||
};
|
||||
spawn_selection::spawn_mobs_for_chunk_generation(
|
||||
&level,
|
||||
source,
|
||||
(cx, cz),
|
||||
&mut random,
|
||||
region,
|
||||
&mut spawn,
|
||||
&mut |_| {},
|
||||
);
|
||||
added
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
//! `Entity.setAsInsidePortal` / `handlePortal` with `PortalProcessor`,
|
||||
//! `NetherPortalBlock.getPortalTransitionTime` and `getPortalDestination`
|
||||
//! (`PortalForcer` through `horizon_world::level::portal_travel`), and
|
||||
//! `LocalPlayer.handlePortalTransitionEffect`.
|
||||
//! `LocalPlayer.handlePortalTransitionEffect`. Items and mobs also go through
|
||||
//! End portals here (`EndPortalBlock.entityInside` / `getPortalDestination`);
|
||||
//! the player's End portals are game/end.rs's.
|
||||
//!
|
||||
//! Only one dimension is loaded at a time. The player's destination is found
|
||||
//! once the other dimension has loaded around it. An item or mob that goes
|
||||
@@ -10,9 +12,9 @@
|
||||
//! portal) until the player is in that dimension and its exit is loaded.
|
||||
use super::{Dim, Game, Screen};
|
||||
use horizon_client::Result;
|
||||
use horizon_client::nether_portal::PortalState;
|
||||
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use horizon_core::{
|
||||
geometry::{Axis, Vec3},
|
||||
geometry::{Aabb, Axis, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
@@ -27,8 +29,6 @@ use std::collections::HashMap;
|
||||
|
||||
/// `Player.getDimensionChangingDelay`.
|
||||
pub(super) const PLAYER_DELAY: i32 = 10;
|
||||
/// `Entity.getDimensionChangingDelay`.
|
||||
const ENTITY_DELAY: i32 = 300;
|
||||
|
||||
/// Where in its portal a traveller stood, and where its exit is to be found.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -40,6 +40,32 @@ pub(super) struct Plan {
|
||||
offset: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
/// Through an End portal: `approximate` is the End's spawn point, or the
|
||||
/// Overworld's shared spawn.
|
||||
end: bool,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// `EndPortalBlock.getPortalDestination` for an entity other than a
|
||||
/// player in `from`: the End's spawn point, or out of the End the
|
||||
/// Overworld's shared spawn `world_spawn`.
|
||||
fn end_portal(from: Dim, world_spawn: BlockPos, width: f32, height: f32) -> Self {
|
||||
let (dim, approximate) = if from == Dim::End {
|
||||
(Dim::Overworld, world_spawn)
|
||||
} else {
|
||||
let [x, y, z] = super::end::END_SPAWN;
|
||||
(Dim::End, BlockPos::new(x, y, z))
|
||||
};
|
||||
Self {
|
||||
dim,
|
||||
approximate,
|
||||
entry_x_axis: true,
|
||||
offset: Vec3::ZERO,
|
||||
width,
|
||||
height,
|
||||
end: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity on its way to a dimension that isn't loaded.
|
||||
@@ -61,7 +87,7 @@ pub(super) struct Portals {
|
||||
/// Items and mobs, by entity id.
|
||||
entities: HashMap<u32, PortalState>,
|
||||
/// Positions before this tick's moves, by entity id.
|
||||
before: HashMap<u32, Vec3>,
|
||||
pub(super) before: HashMap<u32, Vec3>,
|
||||
arrivals: Vec<Arrival>,
|
||||
loaded: bool,
|
||||
dirty: bool,
|
||||
@@ -81,6 +107,89 @@ fn rotate_y(v: Vec3, degrees: f32) -> Vec3 {
|
||||
fn doubles(values: [f64; 3]) -> Tag {
|
||||
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
|
||||
}
|
||||
/// Whether a `width` by `height` box moving from `from` to `to` meets the End
|
||||
/// portal at `pos`: `Entity.collidedWithShapeMovingFrom` against its
|
||||
/// entity-inside shape, y 6/16..12/16 of the block.
|
||||
fn touches_end_portal(from: Vec3, to: Vec3, width: f32, height: f32, pos: BlockPos) -> bool {
|
||||
let half = f64::from(width) / 2.0;
|
||||
let start = Aabb::new(
|
||||
Vec3::new(from.x - half, from.y, from.z - half),
|
||||
Vec3::new(from.x + half, from.y + f64::from(height), from.z + half),
|
||||
);
|
||||
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
|
||||
let shape = Aabb::new(
|
||||
Vec3::new(x, y + 0.375, z),
|
||||
Vec3::new(x + 1.0, y + 0.75, z + 1.0),
|
||||
);
|
||||
start.collided_along_vector(to.subtract(from), &[shape])
|
||||
}
|
||||
/// `PositionMoveRotation.calculateAbsolute` for an End portal's transition
|
||||
/// of an entity facing `[yaw, pitch]`: into the End it faces west and keeps
|
||||
/// its pitch (`Relative.X_ROT`); out of it both turn by the world spawn's
|
||||
/// angles (`Relative.ROTATION`). The pitch stays within ±90.
|
||||
fn end_portal_rotation(into_end: bool, [yaw, pitch]: [f32; 2], spawn: [f32; 2]) -> [f32; 2] {
|
||||
let (yaw, pitch) = if into_end {
|
||||
// Direction.WEST.toYRot().
|
||||
(90.0, pitch)
|
||||
} else {
|
||||
(yaw + spawn[0], pitch + spawn[1])
|
||||
};
|
||||
[yaw, pitch.clamp(-90.0, 90.0)]
|
||||
}
|
||||
/// A waiting entity as `horizon_portal_arrivals.dat` keeps it.
|
||||
fn arrival_tag(a: &Arrival) -> Tag {
|
||||
let p = &a.plan;
|
||||
Tag::compound([
|
||||
("dimension", Tag::String(p.dim.id().into())),
|
||||
("x", Tag::Int(p.approximate.x)),
|
||||
("y", Tag::Int(p.approximate.y)),
|
||||
("z", Tag::Int(p.approximate.z)),
|
||||
("x_axis", Tag::Int(i32::from(p.entry_x_axis))),
|
||||
("right", Tag::Double(p.offset.x.to_bits())),
|
||||
("up", Tag::Double(p.offset.y.to_bits())),
|
||||
("forward", Tag::Double(p.offset.z.to_bits())),
|
||||
("width", Tag::Float(p.width.to_bits())),
|
||||
("height", Tag::Float(p.height.to_bits())),
|
||||
("end", Tag::Int(i32::from(p.end))),
|
||||
("entity", a.tag.clone()),
|
||||
])
|
||||
}
|
||||
/// The waiting entity `arrival_tag` wrote.
|
||||
fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
|
||||
let int = |k: &str| match a.get(k) {
|
||||
Some(Tag::Int(v)) => Some(*v),
|
||||
_ => None,
|
||||
};
|
||||
let float = |k: &str| match a.get(k) {
|
||||
Some(Tag::Float(v)) => Some(f32::from_bits(*v)),
|
||||
_ => None,
|
||||
};
|
||||
let double = |k: &str| match a.get(k) {
|
||||
Some(Tag::Double(v)) => Some(f64::from_bits(*v)),
|
||||
_ => None,
|
||||
};
|
||||
let dim = Dim::from_id(&horizon_client::save::text(a.get("dimension"))?);
|
||||
let (Some(x), Some(y), Some(z), Some(tag)) = (int("x"), int("y"), int("z"), a.get("entity"))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some(Arrival {
|
||||
plan: Plan {
|
||||
dim,
|
||||
approximate: BlockPos::new(x, y, z),
|
||||
entry_x_axis: int("x_axis").unwrap_or(1) != 0,
|
||||
offset: Vec3::new(
|
||||
double("right").unwrap_or(0.5),
|
||||
double("up").unwrap_or(0.0),
|
||||
double("forward").unwrap_or(0.0),
|
||||
),
|
||||
width: float("width").unwrap_or(0.25),
|
||||
height: float("height").unwrap_or(0.25),
|
||||
end: int("end").unwrap_or(0) != 0,
|
||||
},
|
||||
tag: tag.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The world border's extent.
|
||||
@@ -146,8 +255,54 @@ impl Game<'_> {
|
||||
offset,
|
||||
width,
|
||||
height,
|
||||
end: false,
|
||||
}
|
||||
}
|
||||
/// The plan of an item or mob going through the portal block at `entry`:
|
||||
/// an End portal's fixed destination, else the Nether portal's.
|
||||
fn entity_plan(
|
||||
&self,
|
||||
entry: BlockPos,
|
||||
at: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
reset_forward: bool,
|
||||
) -> Plan {
|
||||
if !self.is_end_portal(self.world.block(entry.x, entry.y, entry.z)) {
|
||||
return self.portal_plan(entry, at, width, height, reset_forward);
|
||||
}
|
||||
let spawn = self.level.world_spawn.unwrap_or_else(|| {
|
||||
self.spawn.map_or([0, 64, 0], |s| {
|
||||
[s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32]
|
||||
})
|
||||
});
|
||||
Plan::end_portal(
|
||||
self.dim,
|
||||
BlockPos::new(spawn[0], spawn[1], spawn[2]),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
fn is_end_portal(&self, state: horizon_world::registry::BlockStateId) -> bool {
|
||||
&*self.res.facts().get(state).name == "minecraft:end_portal"
|
||||
}
|
||||
/// Where an item or mob through an End portal lands once `plan`'s level
|
||||
/// has loaded around it: the bottom centre of the End's spawn point,
|
||||
/// above the platform built below it (`EndPlatformFeature.createEndPlatform`);
|
||||
/// out of the End, the top of the shared spawn's column
|
||||
/// (`Entity.adjustSpawnLocation`).
|
||||
fn end_portal_exit(&mut self, plan: &Plan) -> Vec3 {
|
||||
let p = plan.approximate;
|
||||
let centre = Vec3::new(f64::from(p.x) + 0.5, f64::from(p.y), f64::from(p.z) + 0.5);
|
||||
if plan.dim == Dim::End {
|
||||
if let Err(e) = self.end_arrival(centre) {
|
||||
log::error!("End platform: {e}");
|
||||
}
|
||||
return centre;
|
||||
}
|
||||
let y = self.motion_top(p.x, p.z);
|
||||
Vec3::new(centre.x, f64::from(y), centre.z)
|
||||
}
|
||||
/// The nether portal blocks of the chunks within `radius` of `center`
|
||||
/// (the points of interest `PortalForcer` searches): loaded chunks as
|
||||
/// they are, the others as saved; chunks never generated have none.
|
||||
@@ -507,7 +662,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `canUsePortal(false)` of a mob: alive, riding nothing, not asleep,
|
||||
/// not carrying the player, and not a boss that never travels.
|
||||
fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
|
||||
pub(super) fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
|
||||
use horizon_client::mobs::Species;
|
||||
m.alive()
|
||||
&& m.extra.mount.is_none()
|
||||
@@ -520,9 +675,10 @@ impl Game<'_> {
|
||||
|| self.ridden_by_guest(id)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
|
||||
/// and minecarts that moved this tick: the portal blocks their box swept
|
||||
/// through.
|
||||
/// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside`
|
||||
/// for the items, mobs and empty boats and minecarts that moved this
|
||||
/// tick: the portal blocks their box swept through (an End portal's
|
||||
/// transition time is 0 too).
|
||||
pub(super) fn find_entity_portals(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
@@ -570,7 +726,7 @@ impl Game<'_> {
|
||||
.set_inside(pos, ENTITY_DELAY);
|
||||
}
|
||||
}
|
||||
/// The first nether portal block an entity box swept through
|
||||
/// The first nether or End portal block an entity box swept through
|
||||
/// (`Entity.checkInsideBlocks`, whose first portal sets the entry).
|
||||
fn swept_portal(
|
||||
&self,
|
||||
@@ -604,11 +760,9 @@ impl Game<'_> {
|
||||
bounds,
|
||||
1_000_000,
|
||||
|p, _| {
|
||||
if self
|
||||
.res
|
||||
.behaviours()
|
||||
.portal_axis(self.world.block(p.x, p.y, p.z))
|
||||
.is_some()
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.behaviours().portal_axis(state).is_some()
|
||||
|| (self.is_end_portal(state) && touches_end_portal(from, to, width, height, p))
|
||||
{
|
||||
found = Some(p);
|
||||
return false;
|
||||
@@ -618,8 +772,8 @@ impl Game<'_> {
|
||||
);
|
||||
found
|
||||
}
|
||||
/// Whether a section `reach` touches has a nether portal state in its
|
||||
/// palette: without one, no block there is a portal. Palette answers are
|
||||
/// Whether a section `reach` touches has a nether or End portal state in
|
||||
/// its palette: without one, no block there is a portal. Palette answers are
|
||||
/// kept per chunk across ticks (`portal_sections`); a box over many
|
||||
/// sections is not checked.
|
||||
fn may_hold_portal(
|
||||
@@ -656,9 +810,8 @@ impl Game<'_> {
|
||||
(z0..=z1).any(|cz| {
|
||||
chunks.get(&(cx, cz)).is_some_and(|chunk| {
|
||||
sections.get((cx, cz), chunk, |v| {
|
||||
behaviours
|
||||
.portal_axis(horizon_world::registry::BlockStateId(v))
|
||||
.is_some()
|
||||
let state = horizon_world::registry::BlockStateId(v);
|
||||
behaviours.portal_axis(state).is_some() || self.is_end_portal(state)
|
||||
}) & y_mask
|
||||
!= 0
|
||||
})
|
||||
@@ -670,7 +823,7 @@ impl Game<'_> {
|
||||
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
|
||||
if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
|
||||
let e = &self.dropped[i];
|
||||
let plan = self.portal_plan(entry, e.position, 0.25, 0.25, false);
|
||||
let plan = self.entity_plan(entry, e.position, 0.25, 0.25, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -686,7 +839,7 @@ impl Game<'_> {
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
let m = &self.mobs[i];
|
||||
let plan = self.portal_plan(entry, m.position, m.width(), m.height(), true);
|
||||
let plan = self.entity_plan(entry, m.position, m.width(), m.height(), true);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -716,7 +869,7 @@ impl Game<'_> {
|
||||
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
let b = &self.boats[i];
|
||||
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
|
||||
let plan = self.portal_plan(entry, b.position, width, height, false);
|
||||
let plan = self.entity_plan(entry, b.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -741,7 +894,7 @@ impl Game<'_> {
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
let plan = self.portal_plan(entry, c.position, width, height, false);
|
||||
let plan = self.entity_plan(entry, c.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -754,8 +907,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
/// their exit portal (found or built), position, turned yaw and motion,
|
||||
/// and portal cooldown; they join the world with the chunk entities.
|
||||
/// their exit portal (found or built) or End destination, position,
|
||||
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
|
||||
pub(super) fn resolve_arrivals(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
@@ -772,21 +925,28 @@ impl Game<'_> {
|
||||
}
|
||||
let Arrival { plan, mut tag } = self.portals.arrivals.remove(i);
|
||||
self.portals.dirty = true;
|
||||
let Some(exit) = self.nether_exit_portal(&plan) else {
|
||||
continue;
|
||||
};
|
||||
let (at, rotation) = self.arrival(&plan, exit);
|
||||
let motion = rotate_y(horizon_client::entity_nbt::motion(&tag), -rotation);
|
||||
let motion = horizon_client::entity_nbt::motion(&tag);
|
||||
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
|
||||
let (at, motion, [yaw, pitch]) = if plan.end {
|
||||
// Relative.DELTA: the motion is kept.
|
||||
let at = self.end_portal_exit(&plan);
|
||||
let into_end = plan.dim == Dim::End;
|
||||
let turn =
|
||||
end_portal_rotation(into_end, [yaw, pitch], self.level.world_spawn_angle);
|
||||
(at, motion, turn)
|
||||
} else {
|
||||
let Some(exit) = self.nether_exit_portal(&plan) else {
|
||||
continue;
|
||||
};
|
||||
let (at, rotation) = self.arrival(&plan, exit);
|
||||
(at, rotate_y(motion, -rotation), [yaw + rotation, pitch])
|
||||
};
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
c.insert("Motion".into(), doubles([motion.x, motion.y, motion.z]));
|
||||
c.insert(
|
||||
"Rotation".into(),
|
||||
Tag::List(vec![
|
||||
Tag::Float((yaw + rotation).to_bits()),
|
||||
Tag::Float(pitch.to_bits()),
|
||||
]),
|
||||
Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(pitch.to_bits())]),
|
||||
);
|
||||
// setPortalCooldown before the teleport, copied by restoreFrom.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
@@ -823,75 +983,117 @@ impl Game<'_> {
|
||||
let Some(Tag::List(list)) = root.get("arrivals") else {
|
||||
return;
|
||||
};
|
||||
for a in list {
|
||||
let int = |k: &str| match a.get(k) {
|
||||
Some(Tag::Int(v)) => Some(*v),
|
||||
_ => None,
|
||||
};
|
||||
let float = |k: &str| match a.get(k) {
|
||||
Some(Tag::Float(v)) => Some(f32::from_bits(*v)),
|
||||
_ => None,
|
||||
};
|
||||
let double = |k: &str| match a.get(k) {
|
||||
Some(Tag::Double(v)) => Some(f64::from_bits(*v)),
|
||||
_ => None,
|
||||
};
|
||||
let dim = match horizon_client::save::text(a.get("dimension")).as_deref() {
|
||||
Some("minecraft:the_nether") => Dim::Nether,
|
||||
Some(_) => Dim::Overworld,
|
||||
None => continue,
|
||||
};
|
||||
let (Some(x), Some(y), Some(z), Some(tag)) =
|
||||
(int("x"), int("y"), int("z"), a.get("entity"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
self.portals.arrivals.push(Arrival {
|
||||
plan: Plan {
|
||||
dim,
|
||||
approximate: BlockPos::new(x, y, z),
|
||||
entry_x_axis: int("x_axis").unwrap_or(1) != 0,
|
||||
offset: Vec3::new(
|
||||
double("right").unwrap_or(0.5),
|
||||
double("up").unwrap_or(0.0),
|
||||
double("forward").unwrap_or(0.0),
|
||||
),
|
||||
width: float("width").unwrap_or(0.25),
|
||||
height: float("height").unwrap_or(0.25),
|
||||
},
|
||||
tag: tag.clone(),
|
||||
});
|
||||
}
|
||||
self.portals
|
||||
.arrivals
|
||||
.extend(list.iter().filter_map(arrival_from_tag));
|
||||
}
|
||||
/// Write the waiting entities when they changed.
|
||||
pub(super) fn save_portal_arrivals(&mut self) {
|
||||
if !self.portals.dirty || self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let list = self
|
||||
.portals
|
||||
.arrivals
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let p = &a.plan;
|
||||
Tag::compound([
|
||||
("dimension", Tag::String(p.dim.id().into())),
|
||||
("x", Tag::Int(p.approximate.x)),
|
||||
("y", Tag::Int(p.approximate.y)),
|
||||
("z", Tag::Int(p.approximate.z)),
|
||||
("x_axis", Tag::Int(i32::from(p.entry_x_axis))),
|
||||
("right", Tag::Double(p.offset.x.to_bits())),
|
||||
("up", Tag::Double(p.offset.y.to_bits())),
|
||||
("forward", Tag::Double(p.offset.z.to_bits())),
|
||||
("width", Tag::Float(p.width.to_bits())),
|
||||
("height", Tag::Float(p.height.to_bits())),
|
||||
("entity", a.tag.clone()),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
let list = self.portals.arrivals.iter().map(arrival_tag).collect();
|
||||
let root = Tag::compound([("arrivals", Tag::List(list))]);
|
||||
if horizon_client::save::write_dat(&self.arrivals_path(), root).is_ok() {
|
||||
self.portals.dirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn end_portals_lead_to_the_end_spawn_and_back_to_the_world_spawn() {
|
||||
let spawn = BlockPos::new(-7, 70, 12);
|
||||
for from in [Dim::Overworld, Dim::Nether] {
|
||||
let plan = Plan::end_portal(from, spawn, 0.9, 1.4);
|
||||
assert_eq!(plan.dim, Dim::End);
|
||||
assert_eq!(plan.approximate, BlockPos::new(100, 50, 0));
|
||||
assert!(plan.end);
|
||||
}
|
||||
let plan = Plan::end_portal(Dim::End, spawn, 0.25, 0.25);
|
||||
assert_eq!(plan.dim, Dim::Overworld);
|
||||
assert_eq!(plan.approximate, spawn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_end_portal_shape_takes_falling_items_and_standing_mobs() {
|
||||
let portal = BlockPos::new(0, 100, 3);
|
||||
let at = |y| Vec3::new(0.5, y, 3.5);
|
||||
// A dropped item falling through the slab, or in it.
|
||||
assert!(touches_end_portal(
|
||||
at(100.6),
|
||||
at(100.56),
|
||||
0.25,
|
||||
0.25,
|
||||
portal
|
||||
));
|
||||
// One tick's fall across the whole slab, in it at neither end.
|
||||
assert!(touches_end_portal(at(101.0), at(99.9), 0.25, 0.25, portal));
|
||||
// Resting below the slab (on the floor) or above it.
|
||||
assert!(!touches_end_portal(
|
||||
at(100.0),
|
||||
at(100.0),
|
||||
0.25,
|
||||
0.25,
|
||||
portal
|
||||
));
|
||||
assert!(!touches_end_portal(
|
||||
at(100.8),
|
||||
at(100.8),
|
||||
0.25,
|
||||
0.25,
|
||||
portal
|
||||
));
|
||||
// A cow standing in the portal block.
|
||||
assert!(touches_end_portal(at(100.0), at(100.0), 0.9, 1.4, portal));
|
||||
// Beside it.
|
||||
let beside = Vec3::new(2.0, 100.0, 3.5);
|
||||
assert!(!touches_end_portal(beside, beside, 0.9, 1.4, portal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_portals_turn_entities_like_their_teleport_transition() {
|
||||
// Into the End: facing west, pitch kept.
|
||||
assert_eq!(
|
||||
end_portal_rotation(true, [-30.0, 20.0], [45.0, 10.0]),
|
||||
[90.0, 20.0]
|
||||
);
|
||||
// Out of it: turned by the world spawn's yaw and pitch, pitch clamped.
|
||||
assert_eq!(
|
||||
end_portal_rotation(false, [-30.0, 20.0], [45.0, 10.0]),
|
||||
[15.0, 30.0]
|
||||
);
|
||||
assert_eq!(
|
||||
end_portal_rotation(false, [0.0, 80.0], [0.0, 30.0]),
|
||||
[0.0, 90.0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_arrivals_keep_their_end_destination_when_saved() {
|
||||
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
|
||||
for (from, dim) in [(Dim::Overworld, Dim::End), (Dim::End, Dim::Overworld)] {
|
||||
let arrival = Arrival {
|
||||
plan: Plan::end_portal(from, BlockPos::new(3, 64, -5), 0.9, 1.4),
|
||||
tag: tag.clone(),
|
||||
};
|
||||
let back = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
|
||||
assert_eq!(back.plan.dim, dim);
|
||||
assert_eq!(back.plan.approximate, arrival.plan.approximate);
|
||||
assert!(back.plan.end);
|
||||
assert_eq!(back.tag, tag);
|
||||
}
|
||||
// Arrivals saved before End portals were Nether portal plans.
|
||||
let mut old = arrival_tag(&Arrival {
|
||||
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.25, 0.25),
|
||||
tag,
|
||||
});
|
||||
if let Tag::Compound(c) = &mut old {
|
||||
c.remove(&"end".into());
|
||||
}
|
||||
assert!(!arrival_from_tag(&old).unwrap().plan.end);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
//! Resource pack selection bound to the game: the pack screen lists, and a
|
||||
//! full resource reload after a change, while playing.
|
||||
use super::Game;
|
||||
use super::{
|
||||
Game, PackResources, Resources, UiResources, build_renderer, prepare_renderer_textures,
|
||||
};
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
gfx::PreparedTextures,
|
||||
hud::HudAtlas,
|
||||
lang,
|
||||
pack_screen::{self, Compat, PackInfo, Source as PackSource},
|
||||
packs::{self, Pack, Position},
|
||||
streaming::WorkerContext,
|
||||
text::{Style, Text},
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// Where overlay roots are built (beside options.txt).
|
||||
pub fn cache_dir() -> PathBuf {
|
||||
@@ -34,6 +43,10 @@ pub fn open_folder(dir: &Path) {
|
||||
log::warn!("Horizon: could not open {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
/// `Util.getPlatform().openUri`: the system browser on the link.
|
||||
pub fn open_uri(uri: &str) {
|
||||
open_folder(Path::new(uri));
|
||||
}
|
||||
/// The resource root to load from, given the vanilla root, the selection and
|
||||
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
|
||||
pub fn startup_root(
|
||||
@@ -94,7 +107,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
|
||||
infos
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn vanilla_root(&self) -> PathBuf {
|
||||
pub(super) fn vanilla_root(&self) -> PathBuf {
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.options.resources.clone()
|
||||
} else {
|
||||
@@ -134,35 +147,36 @@ impl Game<'_> {
|
||||
self.ui.options.resource_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
/// Reload from the selected packs and the server's.
|
||||
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
|
||||
/// The CPU half of the reload, safe on a worker (`PackReload` runs it):
|
||||
/// `apply_staged_pack` finishes the GPU half and the swap.
|
||||
pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result<StagedPack> {
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let server = &self.server_packs.active;
|
||||
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
|
||||
let root =
|
||||
super::mods::content_root(&root, &self.settings.mods.enabled, &self.options.dev_mods);
|
||||
self.reload_resources(&root)
|
||||
stage_pack(
|
||||
&vanilla,
|
||||
&self.settings.resource_packs,
|
||||
&self.server_packs.active,
|
||||
&self.settings.mods.enabled,
|
||||
&self.options.dev_mods,
|
||||
&self.pack.assets,
|
||||
&self.res,
|
||||
&self.pack.context,
|
||||
self.settings.force_unicode,
|
||||
self.settings.japanese_glyphs,
|
||||
&super::Progress::new(),
|
||||
)
|
||||
}
|
||||
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
|
||||
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
|
||||
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
|
||||
atlas.font.uniform = self.settings.force_unicode;
|
||||
atlas.font.japanese = self.settings.japanese_glyphs;
|
||||
let gui = super::UiResources {
|
||||
atlas,
|
||||
panorama: std::sync::Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
|
||||
};
|
||||
let pack = super::PackResources::load(
|
||||
/// The render-thread half: the renderer on the new pack's textures, then
|
||||
/// the swap (`ReloadInstance`'s application listeners).
|
||||
pub(super) fn apply_staged_pack(&mut self, staged: StagedPack) -> horizon_client::Result<()> {
|
||||
let StagedPack {
|
||||
root,
|
||||
self.pack.assets.clone(),
|
||||
render,
|
||||
self.res.facts(),
|
||||
self.pack.context.biomes.clone(),
|
||||
self.pack.context.light.clone(),
|
||||
)?;
|
||||
gui,
|
||||
pack,
|
||||
textures,
|
||||
} = staged;
|
||||
// Keep the working GPU resources, and hand its window surface over.
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.release_surface();
|
||||
@@ -170,7 +184,17 @@ impl Game<'_> {
|
||||
let renderer = match self
|
||||
.window
|
||||
.clone()
|
||||
.map(|window| self.renderer_for(window, &pack, &gui))
|
||||
.map(|window| {
|
||||
build_renderer(
|
||||
window,
|
||||
self.options.offscreen,
|
||||
&pack,
|
||||
&gui,
|
||||
self.renderer.as_ref(),
|
||||
Some(textures),
|
||||
self.settings.horizon.api,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
{
|
||||
Ok(renderer) => renderer,
|
||||
@@ -186,18 +210,18 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
// Everything loaded: switch over.
|
||||
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root);
|
||||
self.armor_stand_assets.reload_trims(root);
|
||||
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(&root);
|
||||
self.armor_stand_assets.reload_trims(&root);
|
||||
self.random_tick_sections.clear();
|
||||
self.portals.sections.clear();
|
||||
self.options.resources = root.to_path_buf();
|
||||
self.options.resources = root.clone();
|
||||
let context = pack.context.clone();
|
||||
self.pack = pack;
|
||||
self.gui = gui;
|
||||
// The atlas's sprites moved: the particle engine re-reads every rect
|
||||
// and the packs' sprite sets, or every particle draws a stale region.
|
||||
self.particles
|
||||
.reload(root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
|
||||
.reload(&root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
// Villager hats come from the pack's texture metadata (shared data, swapped in place).
|
||||
*self
|
||||
@@ -206,11 +230,11 @@ impl Game<'_> {
|
||||
.villager_hats
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
horizon_client::mobs::villager_hats::VillagerHats::load(root);
|
||||
horizon_client::ui::load_metadata(root);
|
||||
lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code()));
|
||||
horizon_client::mobs::villager_hats::VillagerHats::load(&root);
|
||||
horizon_client::ui::load_metadata(&root);
|
||||
lang::set(lang::Language::load(&[&root], &lang::code()));
|
||||
self.refresh_remote_language();
|
||||
self.audio.reload(root);
|
||||
self.audio.reload(&root);
|
||||
self.clear_lod();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.pending_uploads.clear();
|
||||
@@ -225,4 +249,66 @@ impl Game<'_> {
|
||||
self.creative_screen.forget_items();
|
||||
Ok(())
|
||||
}
|
||||
/// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then
|
||||
/// applied (`PackReload` runs the same two phases across frames).
|
||||
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
|
||||
let staged = self.stage_selected_packs()?;
|
||||
self.apply_staged_pack(staged)
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a
|
||||
/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay
|
||||
/// root, the models and sprites, the menu bundle, the pack's tables and its
|
||||
/// CPU mip chains.
|
||||
pub(super) fn stage_pack(
|
||||
vanilla_root: &Path,
|
||||
selected: &[String],
|
||||
server: &[Pack],
|
||||
mods_enabled: &BTreeSet<String>,
|
||||
dev_mods: &[PathBuf],
|
||||
assets: &Arc<Assets>,
|
||||
res: &Resources,
|
||||
context: &WorkerContext,
|
||||
force_unicode: bool,
|
||||
japanese: bool,
|
||||
progress: &super::Progress,
|
||||
) -> horizon_client::Result<StagedPack> {
|
||||
let root = startup_root(vanilla_root, selected, server)?;
|
||||
let root = super::mods::content_root(&root, mods_enabled, dev_mods);
|
||||
progress.set(1);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let mut atlas = HudAtlas::build_over(&root, vanilla_root)?;
|
||||
atlas.font.uniform = force_unicode;
|
||||
atlas.font.japanese = japanese;
|
||||
let gui = UiResources {
|
||||
atlas,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(&root)),
|
||||
};
|
||||
progress.set(2);
|
||||
let pack = PackResources::load(
|
||||
&root,
|
||||
assets.clone(),
|
||||
render,
|
||||
res.facts(),
|
||||
context.biomes.clone(),
|
||||
context.light.clone(),
|
||||
)?;
|
||||
let textures = prepare_renderer_textures(&pack);
|
||||
progress.set(3);
|
||||
Ok(StagedPack {
|
||||
root,
|
||||
gui,
|
||||
pack,
|
||||
textures,
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything a pack reload builds on the CPU (`apply_staged_pack` finishes
|
||||
/// it on the render thread).
|
||||
pub(super) struct StagedPack {
|
||||
pub root: PathBuf,
|
||||
pub gui: UiResources,
|
||||
pub pack: PackResources,
|
||||
pub textures: PreparedTextures,
|
||||
}
|
||||
@@ -267,8 +267,7 @@ impl Game<'_> {
|
||||
// measured from the box's centre (HangingEntity's position).
|
||||
let (lo, hi) = p.bounds();
|
||||
let (lo, hi) = (glam::DVec3::from(lo), glam::DVec3::from(hi));
|
||||
let range =
|
||||
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
|
||||
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
@@ -308,6 +307,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -193,12 +193,12 @@ impl Game<'_> {
|
||||
if let Some(a) = p.arrow.as_mut() {
|
||||
a.last_state = in_block.and_then(|t| self.state_from_nbt(&t));
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
self.add_projectile(p);
|
||||
}
|
||||
}
|
||||
"minecraft:fireball" | "minecraft:small_fireball" => {
|
||||
if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
|
||||
self.creatures.projectiles.push(p);
|
||||
self.add_projectile(p);
|
||||
}
|
||||
}
|
||||
"minecraft:falling_block" => {
|
||||
@@ -290,14 +290,22 @@ impl Game<'_> {
|
||||
"minecraft:ender_dragon" => {
|
||||
let seed = self.world.random.next_long();
|
||||
if self.dragon.is_none()
|
||||
&& let Some(d) = super::dragon::Dragon::from_nbt(&tag, seed)
|
||||
&& let Some(mut d) = super::dragon::Dragon::from_nbt(&tag, seed)
|
||||
{
|
||||
// EnderDragon's constructor takes its id and its
|
||||
// parts' from the entity counter.
|
||||
d.id = self.next_entity + 1;
|
||||
self.next_entity += 9;
|
||||
self.dragon = Some(d);
|
||||
}
|
||||
}
|
||||
"minecraft:end_crystal" => {
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
if let Some(c) = super::dragon::Crystal::from_nbt(&tag, time) {
|
||||
if let Some(mut c) = super::dragon::Crystal::from_nbt(&tag, time) {
|
||||
// EndCrystal's constructor takes an id from the
|
||||
// entity counter.
|
||||
self.next_entity += 1;
|
||||
c.id = self.next_entity;
|
||||
self.crystals.push(c);
|
||||
}
|
||||
}
|
||||
@@ -533,6 +541,14 @@ impl Game<'_> {
|
||||
self.persist.uuids.remove(&id);
|
||||
self.persist.raw.remove(&id);
|
||||
}
|
||||
/// The compound an entity without a Horizon entity ID was loaded with
|
||||
/// (the crystals and the dragon), found again by type and position.
|
||||
pub(super) fn persist_anon(&self, kind: &str, p: Vec3) -> (Option<[i32; 4]>, Option<&Tag>) {
|
||||
self.persist
|
||||
.anon
|
||||
.get(&anon_key(kind, p))
|
||||
.map_or((None, None), |(_, uuid, raw)| (*uuid, Some(raw)))
|
||||
}
|
||||
/// The compound an entity was loaded with (and commands have edited).
|
||||
pub(super) fn persist_raw(&self, id: u32) -> Option<&Tag> {
|
||||
self.persist.raw.get(&id)
|
||||
|
||||
@@ -710,6 +710,8 @@ mod preview_tests {
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
})
|
||||
};
|
||||
// A shield and arm can intersect: neither average depth orders both ends.
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
//! `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` for
|
||||
//! the entities the game moves itself: the players and the mobs.
|
||||
//!
|
||||
//! The geometry is in [`horizon_world::piston_push`]; this file supplies the
|
||||
//! bodies and applies the movement the way `Entity.move(MoverType.PISTON, …)`
|
||||
//! does: limited to 0.51 blocks per tick per axis (`Entity.limitPistonMovement`),
|
||||
//! then run through the block collision scan, so a pushed entity stops at a
|
||||
//! wall instead of passing through it.
|
||||
//!
|
||||
use super::Game;
|
||||
use horizon_client::collisions::{EntityCollisions, SceneCollisions, with_piston_noclip};
|
||||
use horizon_core::geometry::{Aabb, Direction, Vec3, border::BorderGeometry};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{
|
||||
block_collision_scan::BlockCollisionWorld, level::pistons::MovingPiston,
|
||||
piston_movement::PistonMovement, piston_push, world_movement::EntityMovementWorld,
|
||||
};
|
||||
|
||||
/// What one piston asks for this tick: where it is, its state, and the progress
|
||||
/// `moveCollidedEntities` runs at (before the tick stores it).
|
||||
pub(super) type Push = (BlockPos, MovingPiston, f32);
|
||||
|
||||
/// Which entity was pushed, so its storage can be reached after the world
|
||||
/// borrow ends.
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
|
||||
enum Target {
|
||||
Player,
|
||||
Guest(u32),
|
||||
Mob(usize),
|
||||
Item(usize),
|
||||
Boat(usize),
|
||||
Cart(usize),
|
||||
ArmorStand(usize),
|
||||
}
|
||||
|
||||
/// `Entity.getStepHeight` of each body, read before the push.
|
||||
struct StepHeights {
|
||||
player: f32,
|
||||
guests: std::collections::HashMap<u32, f32>,
|
||||
}
|
||||
|
||||
/// What the push needs from one entity.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Body {
|
||||
bounds: Aabb,
|
||||
position: Vec3,
|
||||
on_ground: bool,
|
||||
step_height: f32,
|
||||
/// Standing on the piston's own block (`Entity.isSupportedBy`).
|
||||
supported: bool,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Push whatever this tick's moving pistons ran into, in the order the
|
||||
/// pistons ticked: vanilla pushes from each block entity's own tick.
|
||||
pub(super) fn push_piston_entities(&mut self, pushes: Vec<Push>) {
|
||||
let mut accumulators = std::collections::HashMap::new();
|
||||
for push in pushes {
|
||||
self.push_for_piston(push, &mut accumulators);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_for_piston(
|
||||
&mut self,
|
||||
(pos, piston, new_progress): Push,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
) {
|
||||
let delta_progress = f64::from(new_progress - piston.progress);
|
||||
if delta_progress <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let Some(bindings) = self.collision.pistons() else {
|
||||
return;
|
||||
};
|
||||
let Ok(state) = bindings.collision_related_state(piston.shape_state()) else {
|
||||
return;
|
||||
};
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let Ok(shape) = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [pos.x, pos.y, pos.z])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if shape.is_empty() {
|
||||
return;
|
||||
}
|
||||
let movement = piston.movement_direction();
|
||||
let boxes: Vec<Aabb> = shape.boxes().to_vec();
|
||||
let facts = self.res.facts();
|
||||
let slime = &*facts.get(piston.moved_state).name == "minecraft:slime_block";
|
||||
let honey = &*facts.get(piston.moved_state).name == "minecraft:honey_block";
|
||||
// `moveStuckEntities` asks the moved state's own top for its box.
|
||||
let sticky_top = if honey {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
self.collision
|
||||
.collision_shape(&mut scene, piston.moved_state, [pos.x, pos.y, pos.z])
|
||||
.ok()
|
||||
.and_then(|s| s.bounds())
|
||||
.map_or(0.0, |b| b.max_position().y)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let steps = self.push_step_heights();
|
||||
let search = shape.bounds().map(|bounds| {
|
||||
piston_push::movement_search_area(
|
||||
piston_push::move_by_position_and_progress(pos, bounds, piston.shape_state()),
|
||||
movement,
|
||||
delta_progress,
|
||||
)
|
||||
});
|
||||
let targets = self.push_targets();
|
||||
for &target in &targets {
|
||||
let Some(body) = self.push_body(target, pos, &steps) else {
|
||||
continue;
|
||||
};
|
||||
let amount = piston_push::collided_amount(
|
||||
&boxes,
|
||||
pos,
|
||||
piston.shape_state(),
|
||||
new_progress,
|
||||
movement,
|
||||
body.bounds,
|
||||
);
|
||||
if slime && search.is_some_and(|area| area.intersects(body.bounds)) {
|
||||
self.bounce(target, movement);
|
||||
}
|
||||
if amount > 0.0 {
|
||||
let step = piston_push::step_vector(movement);
|
||||
let before = body.bounds;
|
||||
self.move_pushed(
|
||||
target,
|
||||
before,
|
||||
[body.position.x, body.position.z],
|
||||
body.step_height,
|
||||
body.on_ground,
|
||||
step.scale(amount),
|
||||
movement,
|
||||
accumulators,
|
||||
);
|
||||
// `fixEntityWithinPistonBase`: a retracting source piston drags
|
||||
// an entity that is still inside its own block.
|
||||
if !piston.extending
|
||||
&& piston.source
|
||||
&& let Some(after) = self.push_body(target, pos, &steps)
|
||||
&& let Some(fix) = piston_push::fix_within_piston_base(
|
||||
pos,
|
||||
after.bounds,
|
||||
movement,
|
||||
delta_progress,
|
||||
)
|
||||
{
|
||||
let back = piston_push::step_vector(movement.opposite());
|
||||
self.move_pushed(
|
||||
target,
|
||||
after.bounds,
|
||||
[after.position.x, after.position.z],
|
||||
after.step_height,
|
||||
after.on_ground,
|
||||
back.scale(fix),
|
||||
movement,
|
||||
accumulators,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Vanilla selects honey passengers after all collided entities have moved.
|
||||
if honey && movement.normal()[1] == 0 {
|
||||
let area = piston_push::sticky_area(pos, piston.shape_state(), sticky_top);
|
||||
for target in targets {
|
||||
let Some(body) = self.push_body(target, pos, &steps) else {
|
||||
continue;
|
||||
};
|
||||
if piston_push::sticky_entity_selected(
|
||||
area,
|
||||
body.bounds,
|
||||
horizon_world::level::data::PushReaction::PushPull,
|
||||
body.on_ground,
|
||||
body.supported,
|
||||
body.position,
|
||||
) {
|
||||
self.move_pushed(
|
||||
target,
|
||||
body.bounds,
|
||||
[body.position.x, body.position.z],
|
||||
body.step_height,
|
||||
body.on_ground,
|
||||
piston_push::step_vector(movement).scale(delta_progress),
|
||||
movement,
|
||||
accumulators,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.getStepHeight` for each body, read once: the attribute lookup
|
||||
/// needs the mutable body, and the push only needs the number.
|
||||
fn push_step_heights(&mut self) -> StepHeights {
|
||||
let player = self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:step_height").ok())
|
||||
.map_or(0.6, |v| v as f32);
|
||||
let mut guests = std::collections::HashMap::new();
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
for g in &mut host.hub.guests {
|
||||
let step = g
|
||||
.motion
|
||||
.as_mut()
|
||||
.and_then(|m| m.attribute("minecraft:step_height").ok())
|
||||
.map_or(0.6, |v| v as f32);
|
||||
guests.insert(g.conn, step);
|
||||
}
|
||||
}
|
||||
StepHeights { player, guests }
|
||||
}
|
||||
|
||||
/// The entities a moving block can run into: the window's player, the LAN
|
||||
/// guests in the level, then the mobs.
|
||||
fn push_targets(&self) -> Vec<Target> {
|
||||
let mut targets = Vec::new();
|
||||
if self.player.is_some() {
|
||||
targets.push(Target::Player);
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
targets.extend(
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| Target::Guest(g.conn)),
|
||||
);
|
||||
}
|
||||
targets.extend((0..self.mobs.len()).map(Target::Mob));
|
||||
targets.extend((0..self.dropped.len()).map(Target::Item));
|
||||
targets.extend((0..self.boats.len()).map(Target::Boat));
|
||||
targets.extend((0..self.carts.len()).map(Target::Cart));
|
||||
targets.extend((0..self.armor_stands.len()).map(Target::ArmorStand));
|
||||
targets
|
||||
}
|
||||
|
||||
/// What the push needs from one entity, or `None` when it is not pushed:
|
||||
/// removed, dead, a spectator, or an armor-stand marker.
|
||||
fn push_body(&self, target: Target, pos: BlockPos, steps: &StepHeights) -> Option<Body> {
|
||||
let at = [pos.x, pos.y, pos.z];
|
||||
match target {
|
||||
Target::Player => {
|
||||
let p = self.player.as_ref()?;
|
||||
if p.spectator {
|
||||
return None;
|
||||
}
|
||||
Some(Body {
|
||||
bounds: p.bounds(),
|
||||
position: p.position,
|
||||
on_ground: p.ground.on_ground,
|
||||
step_height: steps.player,
|
||||
supported: p.ground.supporting_block == Some(pos),
|
||||
})
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
let host = self.lan.as_ref()?;
|
||||
let g = host.hub.guests.iter().find(|g| g.conn == conn)?;
|
||||
if g.dead || g.game_mode == 3 {
|
||||
return None;
|
||||
}
|
||||
let p = g.position;
|
||||
Some(Body {
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
|
||||
Vec3::new(p[0] + 0.3, p[1] + 1.8, p[2] + 0.3),
|
||||
),
|
||||
position: Vec3::new(p[0], p[1], p[2]),
|
||||
on_ground: g.on_ground,
|
||||
step_height: steps.guests.get(&conn).copied().unwrap_or(0.6),
|
||||
supported: g
|
||||
.motion
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.ground.supporting_block == Some(pos)),
|
||||
})
|
||||
}
|
||||
Target::Mob(i) => {
|
||||
let m = &self.mobs[i];
|
||||
if m.removed || !m.alive() {
|
||||
return None;
|
||||
}
|
||||
Some(Body {
|
||||
bounds: m.bounds(),
|
||||
position: m.position,
|
||||
on_ground: m.on_ground,
|
||||
step_height: m.kind.step_height as f32,
|
||||
supported: m.supporting == Some(at),
|
||||
})
|
||||
}
|
||||
Target::Item(i) => {
|
||||
let entity = &self.dropped[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Boat(i) => {
|
||||
let entity = &self.boats[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Cart(i) => {
|
||||
let entity = &self.carts[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::ArmorStand(i) => {
|
||||
let entity = &self.armor_stands[i];
|
||||
if entity.marker {
|
||||
return None;
|
||||
}
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.move(MoverType.PISTON, movement)` for one pushed entity: the
|
||||
/// per-tick piston limit, then the block and world border collision scan.
|
||||
fn move_pushed(
|
||||
&mut self,
|
||||
target: Target,
|
||||
before: Aabb,
|
||||
at: [f64; 2],
|
||||
step_height: f32,
|
||||
on_ground: bool,
|
||||
movement: Vec3,
|
||||
piston_direction: Direction,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
) {
|
||||
let Some(limited) = self.limit_piston(target, movement, accumulators) else {
|
||||
return;
|
||||
};
|
||||
// `limitPistonMovement` returns zero once the tick's budget is spent.
|
||||
if limited == Vec3::ZERO {
|
||||
return;
|
||||
}
|
||||
let resolved = with_piston_noclip(piston_direction, || {
|
||||
pushed_movement(
|
||||
SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
},
|
||||
self.border.geometry(),
|
||||
at,
|
||||
before,
|
||||
limited,
|
||||
step_height,
|
||||
on_ground,
|
||||
)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
});
|
||||
if resolved.length_squared() <= 1.0e-7
|
||||
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.apply_push(target, resolved);
|
||||
}
|
||||
|
||||
/// `Entity.limitPistonMovement`: each axis may travel at most 0.51 blocks
|
||||
/// per game tick, and a move touches only its first non-zero axis.
|
||||
fn limit_piston(
|
||||
&mut self,
|
||||
target: Target,
|
||||
movement: Vec3,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
) -> Option<Vec3> {
|
||||
let time = self.world.game_time;
|
||||
let limit = |state: &mut PistonMovement| state.limit(movement, || Ok::<i64, ()>(time)).ok();
|
||||
match target {
|
||||
Target::Player => {
|
||||
let p = self.player.as_mut()?;
|
||||
limit(&mut p.piston)
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
let host = self.lan.as_mut()?;
|
||||
let g = host.hub.guests.iter_mut().find(|g| g.conn == conn)?;
|
||||
limit(&mut g.motion.as_deref_mut()?.piston)
|
||||
}
|
||||
Target::Mob(i) => limit(&mut self.mobs[i].piston),
|
||||
_ => limit(accumulators.entry(target).or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_push(&mut self, target: Target, movement: Vec3) {
|
||||
match target {
|
||||
Target::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = p.position.add_vector(movement);
|
||||
}
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_mut()
|
||||
&& let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
{
|
||||
// The body and the hub's copy of its position move together:
|
||||
// `leave_guest` writes the body back over the hub's.
|
||||
for (slot, delta) in g.position.iter_mut().zip(movement.components()) {
|
||||
*slot += delta;
|
||||
}
|
||||
if let Some(m) = g.motion.as_deref_mut() {
|
||||
m.position = m.position.add_vector(movement);
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Mob(i) => {
|
||||
let m = &mut self.mobs[i];
|
||||
m.position = m.position.add_vector(movement);
|
||||
}
|
||||
Target::Item(i) => {
|
||||
self.dropped[i].position = self.dropped[i].position.add_vector(movement)
|
||||
}
|
||||
Target::Boat(i) => self.boats[i].position = self.boats[i].position.add_vector(movement),
|
||||
Target::Cart(i) => self.carts[i].position = self.carts[i].position.add_vector(movement),
|
||||
Target::ArmorStand(i) => {
|
||||
self.armor_stands[i].position = self.armor_stands[i].position.add_vector(movement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `causeBounce`: a slime block sets the velocity along the movement axis.
|
||||
fn bounce(&mut self, target: Target, movement: Direction) {
|
||||
match target {
|
||||
Target::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = piston_push::bounce_velocity(movement, p.velocity);
|
||||
}
|
||||
}
|
||||
Target::Mob(i) => {
|
||||
let m = &mut self.mobs[i];
|
||||
m.velocity = piston_push::bounce_velocity(movement, m.velocity);
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_mut()
|
||||
&& let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
&& let Some(motion) = guest.motion.as_deref_mut()
|
||||
{
|
||||
motion.velocity = piston_push::bounce_velocity(movement, motion.velocity);
|
||||
}
|
||||
}
|
||||
Target::Item(i) => {
|
||||
self.dropped[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.dropped[i].velocity)
|
||||
}
|
||||
Target::Boat(i) => {
|
||||
self.boats[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.boats[i].velocity)
|
||||
}
|
||||
Target::Cart(i) => {
|
||||
self.carts[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.carts[i].velocity)
|
||||
}
|
||||
Target::ArmorStand(i) => {
|
||||
self.armor_stands[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.armor_stands[i].velocity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.move(MoverType.PISTON, movement)` for a pushed body: the movement
|
||||
/// it makes. The world's blocks stop it, and so does the world border:
|
||||
/// `Entity.collectCollidersIgnoringWorldBorder` adds the border's walls when
|
||||
/// the body is inside close to the border, as ordinary movement does.
|
||||
fn pushed_movement<W>(
|
||||
blocks: W,
|
||||
border: &BorderGeometry,
|
||||
at: [f64; 2],
|
||||
before: Aabb,
|
||||
movement: Vec3,
|
||||
step_height: f32,
|
||||
on_ground: bool,
|
||||
) -> horizon_client::Result<Vec3>
|
||||
where
|
||||
W: BlockCollisionWorld<Error = std::io::Error> + EntityMovementWorld<Error = std::io::Error>,
|
||||
{
|
||||
EntityCollisions::with_border(blocks, border, at).resolve(
|
||||
before,
|
||||
movement,
|
||||
step_height,
|
||||
on_ground,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::collisions::EmptyCollisions;
|
||||
|
||||
/// A border 10 wide around the origin: its wall's inner face is at x = 5.
|
||||
fn border() -> BorderGeometry {
|
||||
BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border")
|
||||
}
|
||||
|
||||
/// `Entity.collectCollidersIgnoringWorldBorder`: the border's walls stop a
|
||||
/// pushed body that is inside close to the border.
|
||||
#[test]
|
||||
fn a_pushed_body_stops_at_the_world_border() {
|
||||
// A body 0.6 wide, 0.4 blocks short of the wall, pushed east into it.
|
||||
let before = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6));
|
||||
let border = border();
|
||||
let moved = pushed_movement(
|
||||
EmptyCollisions,
|
||||
&border,
|
||||
[4.3, 0.3],
|
||||
before,
|
||||
Vec3::new(0.51, 0.0, 0.0),
|
||||
0.0,
|
||||
false,
|
||||
)
|
||||
.expect("the push resolves");
|
||||
// The wall stops the body 0.4 blocks short of the whole push.
|
||||
assert!(
|
||||
(moved.x - 0.4).abs() < 1.0e-9,
|
||||
"the body was not stopped by the border: {moved:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Away from the border, the same push moves the whole way.
|
||||
#[test]
|
||||
fn a_pushed_body_moves_whole_away_from_the_border() {
|
||||
let before = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6));
|
||||
let border = border();
|
||||
let moved = pushed_movement(
|
||||
EmptyCollisions,
|
||||
&border,
|
||||
[0.3, 0.3],
|
||||
before,
|
||||
Vec3::new(0.51, 0.0, 0.0),
|
||||
0.0,
|
||||
false,
|
||||
)
|
||||
.expect("the push resolves");
|
||||
assert!(
|
||||
(moved.x - 0.51).abs() < 1.0e-9,
|
||||
"the body did not move the whole push: {moved:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -313,6 +313,7 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
self.post_entity_event("minecraft:drink", horizon_client::mobs::Who::Player);
|
||||
if ominous {
|
||||
self.drink_ominous_bottle(slot, stack);
|
||||
|
||||
@@ -609,6 +609,7 @@ impl Game<'_> {
|
||||
if inside(cursor, bx, by, 20.0, 18.0) {
|
||||
let open = !self.book_open(book_type);
|
||||
self.level.recipe_book.open[book_type.index()] = open;
|
||||
self.send_book_settings(book_type);
|
||||
if !open {
|
||||
self.recipe_ui.overlay = None;
|
||||
}
|
||||
@@ -694,6 +695,7 @@ impl Game<'_> {
|
||||
if inside(cursor, xo + 110.0, yo + 12.0, 26.0, 16.0) {
|
||||
let filtering = !self.book_filtering(book_type);
|
||||
self.level.recipe_book.filtering[book_type.index()] = filtering;
|
||||
self.send_book_settings(book_type);
|
||||
self.recipe_ui.page = 0;
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
@@ -714,6 +716,22 @@ impl Game<'_> {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `RecipeBookComponent.sendUpdateSettings`: the server owns the saved book.
|
||||
fn send_book_settings(&self, book_type: BookType) {
|
||||
self.send(
|
||||
horizon_protocol::packets::play::Serverbound::RecipeBookChangeSettings(
|
||||
horizon_protocol::packets::recipes::RecipeBookChangeSettings {
|
||||
book_type: book_type.index() as i32,
|
||||
open: self.book_open(book_type),
|
||||
filtering: self.book_filtering(book_type),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
pub(super) fn recipe_book_flags(&self) -> Option<(bool, bool)> {
|
||||
let book = self.active_book()?;
|
||||
Some((self.book_open(book), self.book_filtering(book)))
|
||||
}
|
||||
/// How many displays the client's book holds.
|
||||
pub(super) fn recipe_book_known(&self) -> usize {
|
||||
self.recipe_ui.client.displays().len()
|
||||
|
||||
@@ -117,13 +117,17 @@ pub(super) struct Remote {
|
||||
joined: bool,
|
||||
status: String,
|
||||
pub(super) entity_id: i32,
|
||||
/// The item the player's use began with (`LivingEntity.useItem`).
|
||||
pub(super) use_item: Option<Arc<str>>,
|
||||
dimension: DimensionType,
|
||||
/// What chunk packets decode with (the dimension's height and sky, the
|
||||
/// biome registry); replaced whenever one of them changes.
|
||||
pub(super) chunk_format: Arc<ChunkFormat>,
|
||||
/// Decodes chunk packets as they arrive, off the frame.
|
||||
decoder: ChunkDecoder,
|
||||
view_distance: i32,
|
||||
/// Options.serverRenderDistance: the server's view distance from the
|
||||
/// login or the last chunk-cache-radius packet.
|
||||
pub(super) view_distance: i32,
|
||||
sequence: i32,
|
||||
position_sender: horizon_client::player_sync::PositionSender,
|
||||
last_input: u8,
|
||||
@@ -189,6 +193,10 @@ pub(super) struct Remote {
|
||||
destroying: Option<([i32; 3], u8, f32)>,
|
||||
destroy_delay: u32,
|
||||
input: horizon_client::interaction_input::InteractionInput,
|
||||
/// Keys whose presses send a packet (swap hands, drop, a mount's
|
||||
/// inventory): like the click queue, they wait for the tick's
|
||||
/// `handleKeybinds` phase rather than going out between window events.
|
||||
pub(super) pending_keys: Vec<KeyCode>,
|
||||
stop_mining: bool,
|
||||
placing: Option<([i32; 3], u64)>,
|
||||
death_message: Option<String>,
|
||||
@@ -212,6 +220,9 @@ pub(super) struct Remote {
|
||||
/// `ClientboundTransferPacket`'s target: when the connection ends the
|
||||
/// game connects here, the stored cookies carried over (`TransferState`).
|
||||
transfer: Option<String>,
|
||||
/// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server
|
||||
/// Links..." in the pause menu lists (only `http`/`https` URIs kept).
|
||||
pub(super) server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
|
||||
}
|
||||
|
||||
/// The time a frame spends on chunk packets before it leaves the rest of
|
||||
@@ -394,6 +405,9 @@ impl Game<'_> {
|
||||
self.carried = None;
|
||||
self.using = None;
|
||||
self.clicks.miss_time = 0;
|
||||
// A new LocalPlayer has no effects: the server sends what it has
|
||||
// (ClientboundUpdateMobEffectPacket on placement).
|
||||
self.potions = Default::default();
|
||||
self.set_mode_id(0);
|
||||
self.experience = Experience {
|
||||
level: 0,
|
||||
@@ -428,6 +442,7 @@ impl Game<'_> {
|
||||
joined: false,
|
||||
status: "Connecting to the server...".into(),
|
||||
entity_id: 0,
|
||||
use_item: None,
|
||||
dimension: DimensionType::vanilla("minecraft:overworld"),
|
||||
chunk_format: Arc::new(ChunkFormat::new(
|
||||
&DimensionType::vanilla("minecraft:overworld"),
|
||||
@@ -473,6 +488,7 @@ impl Game<'_> {
|
||||
destroying: None,
|
||||
destroy_delay: 0,
|
||||
input: Default::default(),
|
||||
pending_keys: Vec::new(),
|
||||
stop_mining: false,
|
||||
placing: None,
|
||||
death_message: None,
|
||||
@@ -486,6 +502,7 @@ impl Game<'_> {
|
||||
chunk_center: (0, 0),
|
||||
backlog: VecDeque::new(),
|
||||
backlog_since: None,
|
||||
server_links: Vec::new(),
|
||||
transfer: None,
|
||||
});
|
||||
// The level's TickRateManager follows the server's ticking packets.
|
||||
@@ -862,6 +879,11 @@ impl Game<'_> {
|
||||
}
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::ServerLinks(links) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.server_links = super::server_links::trusted(links);
|
||||
}
|
||||
}
|
||||
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
|
||||
Event::ClearDialog => self.clear_dialog(),
|
||||
Event::DecodeError(e) => {
|
||||
@@ -1621,7 +1643,11 @@ impl Game<'_> {
|
||||
}
|
||||
C::Login(p) => {
|
||||
self.level.hardcore = p.hardcore;
|
||||
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
|
||||
// new LocalPlayer (slot 0).
|
||||
self.selected = 0;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.carried = Some(0);
|
||||
r.online_mode = p.online_mode;
|
||||
r.entity_id = p.player_id;
|
||||
r.view_distance = p.chunk_radius;
|
||||
@@ -1639,8 +1665,10 @@ impl Game<'_> {
|
||||
self.send(S::ClientInformation(info));
|
||||
}
|
||||
C::Respawn(p) => {
|
||||
// A new player, without effects: the server sends them again.
|
||||
// A new player, without effects (the server sends them
|
||||
// again) and with no item in use.
|
||||
self.potions.effects.clear();
|
||||
self.using = None;
|
||||
self.enter_remote_dimension(&p.spawn, true);
|
||||
self.screen = Screen::Playing;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -2174,6 +2202,11 @@ impl Game<'_> {
|
||||
self.honey_slide_particles(at);
|
||||
}
|
||||
}
|
||||
if p.event == super::consume::USE_ITEM_COMPLETE_EVENT
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
{
|
||||
self.remote_complete_using();
|
||||
}
|
||||
}
|
||||
C::Animate(p) => self.remote_animate(p.id, p.action),
|
||||
// ClientPacketListener.handleSwingAnimation: the entity swings
|
||||
@@ -2336,10 +2369,11 @@ impl Game<'_> {
|
||||
}
|
||||
self.apply_remote_abilities();
|
||||
}
|
||||
// ClientPacketListener.handleSetHeldSlot: a hotbar slot only, and the
|
||||
// carried index stays, so the next tick answers with SetCarriedItem.
|
||||
C::SetHeldSlot(p) => {
|
||||
self.selected = p.slot.clamp(0, 8) as usize;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.carried = Some(self.selected);
|
||||
if (0..9).contains(&p.slot) {
|
||||
self.selected = p.slot as usize;
|
||||
}
|
||||
}
|
||||
C::ContainerSetContent(p) => {
|
||||
@@ -2835,7 +2869,7 @@ impl Game<'_> {
|
||||
resolved.push((uuid, skin));
|
||||
}
|
||||
}
|
||||
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.show_skin(skin));
|
||||
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin));
|
||||
let Some((level, skins, own)) = self.remote.as_mut().map(|r| {
|
||||
r.skins = resolved.into_iter().collect();
|
||||
(
|
||||
@@ -2874,11 +2908,9 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some())
|
||||
.map(|m| {
|
||||
let t = m
|
||||
.profile
|
||||
.as_ref()
|
||||
.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
|
||||
let textures = t.map_or_else(Default::default, |t| {
|
||||
let profile = m.profile.as_ref();
|
||||
let t = profile.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
|
||||
let textures = if let Some(t) = t {
|
||||
let signature = if t.signature.is_some() {
|
||||
horizon_client::skins::Signature::Signed
|
||||
} else {
|
||||
@@ -2889,7 +2921,30 @@ impl Game<'_> {
|
||||
signature,
|
||||
&horizon_client::skins::TEXTURE_URIS,
|
||||
)
|
||||
});
|
||||
} else {
|
||||
// `ResolvableProfile.resolveProfile`: no `textures` and
|
||||
// only a name or an id resolves through the services.
|
||||
let key = profile.and_then(|p| match (&p.name, p.uuid) {
|
||||
(Some(name), None) => {
|
||||
Some(horizon_client::skins::NameOrId::Name(name.clone()))
|
||||
}
|
||||
(None, Some(id)) => Some(horizon_client::skins::NameOrId::Id(id)),
|
||||
_ => None,
|
||||
});
|
||||
key.and_then(|key| match self.skin_profiles.borrow_mut().state(&key) {
|
||||
horizon_client::skins::Resolve::Done(Some(profile)) => {
|
||||
profile.textures().map(|(value, signature)| {
|
||||
horizon_client::skins::unpack(
|
||||
&value,
|
||||
signature,
|
||||
&horizon_client::skins::TEXTURE_URIS,
|
||||
)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
(m.id, textures)
|
||||
})
|
||||
.collect();
|
||||
@@ -2904,7 +2959,7 @@ impl Game<'_> {
|
||||
resolved.push((id, skin));
|
||||
}
|
||||
for (id, skin) in resolved {
|
||||
let skin = skin.filter(|s| !s.starts_with("skins/") || self.show_skin(s));
|
||||
let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s));
|
||||
if let Some(mob) = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
@@ -2954,6 +3009,10 @@ impl Game<'_> {
|
||||
self.skin_slots.push(e);
|
||||
true
|
||||
}
|
||||
/// `show_skin`, or — its slot evicted — the image queued for upload again.
|
||||
fn again_skin(&mut self, location: &str) -> bool {
|
||||
self.show_skin(location) || self.skin_downloads.borrow_mut().again(location)
|
||||
}
|
||||
/// ChatType decoration: `chat.translation_key` with sender/content parameters.
|
||||
/// ChatType.Bound.decorate: the chat type's translation, with the
|
||||
/// sender, the target and the message as its parameters, in its style.
|
||||
@@ -3170,13 +3229,16 @@ impl Game<'_> {
|
||||
|
||||
fn tick_remote_level(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
// AbstractSignEditScreen.tick: the editor closes once its sign is removed.
|
||||
if let Some(edit) = &self.sign_edit
|
||||
&& !matches!(
|
||||
self.world.block_entities.get(&edit.pos),
|
||||
Some(BlockEntity::Sign(_))
|
||||
)
|
||||
{
|
||||
self.close_sign_editor();
|
||||
}
|
||||
self.track_experience_display();
|
||||
// Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s
|
||||
// for a server's client (multiplayer and the default single player),
|
||||
// so the entries advance here or the slot stays shaded until the
|
||||
// server says the entry ended. `onCooldownEnded` is the plain
|
||||
// ItemCooldowns' no-op on a client.
|
||||
self.cooldowns.tick();
|
||||
// Leaving a slot clears its bundle's selection here and on the server.
|
||||
self.tick_bundle_hover();
|
||||
// Options.broadcastOptions: a changed render distance or Chat option
|
||||
@@ -3212,6 +3274,14 @@ impl Game<'_> {
|
||||
// Window callbacks only queue clicks, so an interaction cannot leak
|
||||
// between CLIENT_TICK_END and a later pong.
|
||||
self.remote_handle_input();
|
||||
// Player.tick's ItemCooldowns.tick, in ClientLevel.tickEntities after
|
||||
// handleKeybinds: this path replaces `tick_world`'s for a server's
|
||||
// client (multiplayer and the default single player), so the entries
|
||||
// advance here or the slot stays shaded until the server says the
|
||||
// entry ended. `onCooldownEnded` is the plain ItemCooldowns' no-op on
|
||||
// a client. Ticking it after the keybinds keeps a held use from
|
||||
// trying again a tick before the server's entry ends.
|
||||
self.cooldowns.tick();
|
||||
// ClientLevel.tick: the border's size change.
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
@@ -3223,6 +3293,8 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.client_tick_block_entities(level, ticks);
|
||||
});
|
||||
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
|
||||
self.push_piston_entities(pushes);
|
||||
self.sync_moving_pistons();
|
||||
self.tick_hand();
|
||||
self.tick_particles();
|
||||
@@ -4348,13 +4420,27 @@ impl Game<'_> {
|
||||
let Some(info) = self.res.items().get(&held.item).cloned() else {
|
||||
return false;
|
||||
};
|
||||
// MultiPlayerGameMode.useItem runs the use here too (inside
|
||||
// startPrediction): the `use_cooldown` component starts its entry, and
|
||||
// the server's ClientboundCooldownPacket confirms it.
|
||||
self.apply_use_cooldown(&held);
|
||||
// Food starts being eaten (Consumable.startConsuming), a book and
|
||||
// quill opens.
|
||||
self.with_hand(hand, |g| g.start_eating(&info));
|
||||
// MultiPlayerGameMode.useItem's prediction: an item cooling down
|
||||
// passes (its packet still goes).
|
||||
let group = horizon_client::cooldowns::group(&held, info.use_cooldown.as_ref());
|
||||
if self.cooldowns.is_on_cooldown(&group) {
|
||||
return false;
|
||||
}
|
||||
// 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
|
||||
// no cooldown or use animation starts.
|
||||
let eating = self.with_hand(hand, |g| g.start_eating(&info));
|
||||
if info.food.is_some() && !eating {
|
||||
return false;
|
||||
}
|
||||
// It runs the use here too: ItemStack.use starts the `use_cooldown`
|
||||
// of an item with no use time, and the server's
|
||||
// ClientboundCooldownPacket confirms it; one held in use starts it at
|
||||
// finishUsingItem (remote_complete_using).
|
||||
if !info.consumable.as_ref().is_some_and(|c| c.ticks() > 0) {
|
||||
self.apply_use_cooldown(&held);
|
||||
}
|
||||
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
|
||||
return true;
|
||||
}
|
||||
@@ -4426,14 +4512,25 @@ impl Game<'_> {
|
||||
self.remote_use_item(Hand::Off);
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
|
||||
/// use (`held_until_released`), and a
|
||||
/// consumable's eating animation and its crumbs. The server eats it and
|
||||
/// sends the stack, the hunger and the sounds.
|
||||
/// use (`held_until_released`), and a consumable's eating animation and
|
||||
/// its crumbs until the server's USE_ITEM_COMPLETE. The server sends the
|
||||
/// stack, its hunger and the sounds.
|
||||
fn remote_tick_using(&mut self) {
|
||||
let Some((slot, ticks)) = self.using else {
|
||||
return;
|
||||
};
|
||||
let stack = self.stack_in(slot).cloned();
|
||||
// The use stops when the hand no longer holds the item it began with
|
||||
// (ItemStack.isSameItem).
|
||||
let item = stack.as_ref().map(|s| Arc::clone(&s.item));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if ticks == 0 {
|
||||
r.use_item = item;
|
||||
} else if r.use_item != item {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
|
||||
// different stack in the hand. The off hand keeps its item.
|
||||
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
|
||||
@@ -4458,20 +4555,51 @@ impl Game<'_> {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item.
|
||||
let duration = consumable.ticks().max(0) as u32;
|
||||
let remaining = duration - ticks.min(duration);
|
||||
if consumable.particles && consumable.should_emit(remaining as i32) {
|
||||
// Consumable.startConsuming eats one with no use time at once, without
|
||||
// a use (nor a USE_ITEM_COMPLETE).
|
||||
if consumable.ticks() <= 0 {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. The
|
||||
// countdown goes on past zero (LivingEntity.updateUsingItem completes
|
||||
// only on the server) until the server's USE_ITEM_COMPLETE.
|
||||
let remaining = consumable.ticks() - ticks as i32;
|
||||
if consumable.particles && consumable.should_emit(remaining) {
|
||||
self.eat_particles(&stack, 5);
|
||||
}
|
||||
if ticks + 1 >= duration {
|
||||
self.using = None;
|
||||
if consumable.particles {
|
||||
self.eat_particles(&stack, 16);
|
||||
}
|
||||
} else {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
self.using = Some((slot, ticks + 1));
|
||||
}
|
||||
/// `Player.handleEntityEvent(9)`: `completeUsingItem` on the client. A consumable's last crumbs, and FoodProperties.onConsume,
|
||||
/// which runs on the client too (Consumable.onConsume calls its
|
||||
/// ConsumableListeners on both sides): the client's FoodData eats at once,
|
||||
/// so a held right click finds `Player.canEat` false instead of starting
|
||||
/// another bite before the server's ClientboundSetHealth arrives.
|
||||
fn remote_complete_using(&mut self) {
|
||||
let Some((slot, _)) = self.using.take() else {
|
||||
return;
|
||||
};
|
||||
let Some(stack) = self.stack_in(slot).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
return;
|
||||
};
|
||||
if info.consumable.as_ref().is_some_and(|c| c.particles) {
|
||||
self.eat_particles(&stack, 16);
|
||||
}
|
||||
if let Some((nutrition, saturation)) = info.food
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
// FoodData.eat(nutrition, saturation), as finish_consuming does;
|
||||
// the next ClientboundSetHealth replaces it.
|
||||
let (_, food, sat) = &mut r.health;
|
||||
*food = (*food + nutrition as i32).clamp(0, 20);
|
||||
*sat = (*sat + saturation).clamp(0.0, *food as f32);
|
||||
self.apply_remote_health();
|
||||
}
|
||||
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
}
|
||||
/// MouseHandler updates KeyMapping; handleKeybinds sends packets on a tick.
|
||||
pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
@@ -4536,6 +4664,31 @@ impl Game<'_> {
|
||||
}
|
||||
let (attack, use_item) = (self.left, self.right);
|
||||
input.tick(&mut RemoteInputWorld(self), attack, use_item);
|
||||
// handleKeybinds' keybind polls: the queued presses act now, in tick
|
||||
// order, so no packet goes out between CLIENT_TICK_END and a pong.
|
||||
let pending = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.map(|r| std::mem::take(&mut r.pending_keys))
|
||||
.unwrap_or_default();
|
||||
for key in pending {
|
||||
match key {
|
||||
KeyCode::KeyF if !self.spectator() => self.remote_swap_hands(),
|
||||
KeyCode::KeyQ if !self.spectator() => {
|
||||
let all = self.keys.contains(&KeyCode::ControlLeft);
|
||||
self.remote_drop(all);
|
||||
}
|
||||
KeyCode::KeyE if self.riding_mount_with_menu() => {
|
||||
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
|
||||
self.send(S::PlayerCommand(play::PlayerCommand {
|
||||
id,
|
||||
action: 5,
|
||||
data: 0,
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if self.clicks.miss_time > 0 {
|
||||
self.clicks.miss_time -= 1;
|
||||
}
|
||||
@@ -4647,22 +4800,8 @@ impl Game<'_> {
|
||||
if self.spectator() {
|
||||
return Ok(true);
|
||||
}
|
||||
let all = self.keys.contains(&KeyCode::ControlLeft);
|
||||
// MultiPlayerGameMode.dropItem: predict removal before sending
|
||||
// the action, then swing only if a nonempty stack was removed.
|
||||
let dropped = horizon_client::items::take_held_drop(
|
||||
&mut self.inventory[self.selected],
|
||||
all,
|
||||
false,
|
||||
);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: if all { 4 } else { 5 },
|
||||
pos: [0; 3],
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
if dropped.is_some() {
|
||||
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -4670,12 +4809,9 @@ impl Game<'_> {
|
||||
// horse or llama, the server opens its inventory
|
||||
// (ServerboundPlayerCommandPacket OPEN_INVENTORY).
|
||||
KeyCode::KeyE if self.riding_mount_with_menu() => {
|
||||
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
|
||||
self.send(S::PlayerCommand(play::PlayerCommand {
|
||||
id,
|
||||
action: 5,
|
||||
data: 0,
|
||||
}));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
KeyCode::KeyE => {
|
||||
@@ -4722,6 +4858,21 @@ impl Game<'_> {
|
||||
}));
|
||||
}
|
||||
}
|
||||
/// MultiPlayerGameMode.dropItem (key.drop): predict the removal, then send
|
||||
/// the action; swing only if a nonempty stack was removed.
|
||||
fn remote_drop(&mut self, all: bool) {
|
||||
let dropped =
|
||||
horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all, false);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: if all { 4 } else { 5 },
|
||||
pos: [0; 3],
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
if dropped.is_some() {
|
||||
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
|
||||
}
|
||||
}
|
||||
/// Minecraft.handleKeybinds, key.swapOffhand: the server swaps the hands
|
||||
/// (ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND at BlockPos.ZERO, facing down) and
|
||||
/// sends both slots back; nothing is predicted.
|
||||
|
||||
@@ -77,6 +77,14 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
|
||||
}
|
||||
p.velocity.y -= 0.05;
|
||||
}
|
||||
/// A server's client ticks a projectile itself: it flies on between the
|
||||
/// tracker's updates (10 to 20 ticks apart) with its own gravity and drag,
|
||||
/// and a block stops it there; only the server decides what it hits.
|
||||
pub(super) fn remote_projectile_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
|
||||
if !matches!(p.tick(w).1, Hit::None) {
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `handleAddEntity` for these types; false when `kind` isn't one.
|
||||
@@ -171,7 +179,7 @@ impl Game<'_> {
|
||||
p.velocity = velocity;
|
||||
p.yaw = yaw;
|
||||
p.pitch = pitch;
|
||||
self.creatures.projectiles.push(p);
|
||||
self.add_projectile(p);
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -347,11 +355,7 @@ impl Game<'_> {
|
||||
p.age += 1;
|
||||
p.position = p.position.add_vector(p.velocity);
|
||||
}
|
||||
_ => {
|
||||
if !matches!(p.tick(&world).1, Hit::None) {
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
_ => remote_projectile_tick(p, &world),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -533,3 +537,68 @@ impl Game<'_> {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::mobs::Who;
|
||||
use horizon_core::geometry::Aabb;
|
||||
|
||||
/// A world with nothing in it: the client tick of a projectile only
|
||||
/// needs the blocks around it.
|
||||
struct Empty;
|
||||
impl ProjectileWorld for Empty {
|
||||
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
None
|
||||
}
|
||||
fn in_water(&self, _: Vec3) -> bool {
|
||||
false
|
||||
}
|
||||
fn targets(&self) -> &[(Who, Aabb)] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// A thrown item, as a server's client tracks it: the spawn position and
|
||||
/// the motion the server gave it.
|
||||
fn thrown(kind: ProjectileKind) -> Projectile {
|
||||
let mut p = Projectile::new(kind, Vec3::new(0.0, 100.0, 0.0), None);
|
||||
p.velocity = Vec3::new(1.5, 0.0, 0.0);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thrown_item_flies_on_between_the_server_updates() {
|
||||
// A thrown egg, an ender pearl and a wind charge (#1762): the server
|
||||
// sends their position every 10 ticks, and the client ticks them
|
||||
// itself in between, as vanilla's no-op interpolation handler does.
|
||||
for kind in [
|
||||
ProjectileKind::Egg,
|
||||
ProjectileKind::EnderPearl,
|
||||
ProjectileKind::WindCharge,
|
||||
] {
|
||||
let mut p = thrown(kind);
|
||||
remote_projectile_tick(&mut p, &Empty);
|
||||
assert!(p.position.x > 1.0, "the projectile did not fly on: {p:?}");
|
||||
// A second tick moves it further: it keeps flying, not a hop.
|
||||
let first = p.position.x;
|
||||
remote_projectile_tick(&mut p, &Empty);
|
||||
assert!(p.position.x > first, "the projectile stopped: {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thrown_item_falls_and_drags_like_a_throwable_projectile() {
|
||||
// ThrowableProjectile.tick: gravity 0.03, then the air drag 0.99.
|
||||
let mut p = thrown(ProjectileKind::Egg);
|
||||
remote_projectile_tick(&mut p, &Empty);
|
||||
assert!((p.velocity.x - 1.5 * 0.99).abs() < 1e-9, "{p:?}");
|
||||
assert!((p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9, "{p:?}");
|
||||
// A wind charge keeps its speed: no gravity and no drag
|
||||
// (AbstractWindCharge: acceleration 0, inertia 1).
|
||||
let mut p = thrown(ProjectileKind::WindCharge);
|
||||
remote_projectile_tick(&mut p, &Empty);
|
||||
assert!((p.velocity.x - 1.5).abs() < 1e-9, "{p:?}");
|
||||
assert!((p.velocity.y - 0.0).abs() < 1e-9, "{p:?}");
|
||||
}
|
||||
}
|
||||
@@ -139,8 +139,10 @@ impl Game<'_> {
|
||||
| Target::Painting(id)
|
||||
| Target::Boat(id)
|
||||
| Target::Cart(id)
|
||||
| Target::Crystal(id)
|
||||
| Target::Dragon(id)
|
||||
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
|
||||
// Orbs and projectiles are addressed by index and have no UUID here.
|
||||
// Orbs and projectiles have no UUID here.
|
||||
Target::Orb(_) | Target::Projectile(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -188,7 +190,11 @@ impl Game<'_> {
|
||||
| Target::Cart(id)
|
||||
| Target::Other(id) => self.persist.raw.get(&id),
|
||||
Target::Player => self.level.player_extra.as_ref(),
|
||||
Target::Guest(_) | Target::Orb(_) | Target::Projectile(_) => None,
|
||||
Target::Guest(_)
|
||||
| Target::Orb(_)
|
||||
| Target::Projectile(_)
|
||||
| Target::Crystal(_)
|
||||
| Target::Dragon(_) => None,
|
||||
};
|
||||
match raw.and_then(|r| r.get("Tags")) {
|
||||
Some(Tag::List(l)) => l
|
||||
|
||||
@@ -19,6 +19,9 @@ impl Screen {
|
||||
| Self::Advancements
|
||||
| Self::WorldOptions
|
||||
| Self::Friends
|
||||
| Self::ServerLinks
|
||||
| Self::ConfirmLink
|
||||
| Self::DialogWarning
|
||||
| Self::Mod => true,
|
||||
Self::Playing
|
||||
| Self::GameModeSwitcher
|
||||
@@ -35,20 +38,35 @@ impl Screen {
|
||||
}
|
||||
|
||||
/// PauseScreen buttons: (x, y, width, label key, action id, active).
|
||||
/// `links`: the `minecraft:server_links` dialog's button, which the
|
||||
/// `pause_screen_additions` tag (empty in vanilla) falls back to when the
|
||||
/// server sent links — a full-width row that pushes the last two rows down.
|
||||
/// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen
|
||||
fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
fn pause_buttons(
|
||||
w: f32,
|
||||
h: f32,
|
||||
remote: bool,
|
||||
links: bool,
|
||||
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
// GridLayout of two 98px columns (padding 4), aligned at (0.5, 0.25).
|
||||
let (grid_w, grid_h) = (212.0, 166.0);
|
||||
let (grid_w, grid_h) = (212.0, 166.0 + if links { 24.0 } else { 0.0 });
|
||||
let x0 = ((w - grid_w) * 0.5).floor();
|
||||
let y0 = ((h - grid_h) * 0.25).floor();
|
||||
vec![
|
||||
let down = if links { 24.0 } else { 0.0 };
|
||||
let mut v = vec![
|
||||
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
|
||||
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
|
||||
(x0 + 110.0, y0 + 74.0, 98.0, "gui.stats", 3, true),
|
||||
(x0 + 4.0, y0 + 122.0, 98.0, "menu.options", 1, true),
|
||||
];
|
||||
if links {
|
||||
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
|
||||
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
|
||||
}
|
||||
v.extend([
|
||||
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
|
||||
(
|
||||
x0 + 110.0,
|
||||
y0 + 122.0,
|
||||
y0 + 122.0 + down,
|
||||
98.0,
|
||||
"options.worldOptions.button",
|
||||
9,
|
||||
@@ -56,7 +74,7 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
|
||||
),
|
||||
(
|
||||
x0 + 4.0,
|
||||
y0 + 146.0,
|
||||
y0 + 146.0 + down,
|
||||
204.0,
|
||||
if remote {
|
||||
"menu.disconnect"
|
||||
@@ -66,16 +84,17 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
|
||||
2,
|
||||
true,
|
||||
),
|
||||
]
|
||||
]);
|
||||
v
|
||||
}
|
||||
|
||||
/// The icon row: report bugs, feedback, friends, player reporting.
|
||||
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
|
||||
/// Play with Friends; the others stay inactive.
|
||||
const FRIENDS_ICON: usize = 2;
|
||||
fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
|
||||
fn pause_icons(w: f32, h: f32, links: bool) -> [(f32, f32, &'static str); 4] {
|
||||
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
|
||||
let y = ((h - 166.0) * 0.25).floor() + 98.0;
|
||||
let y = ((h - 166.0 - if links { 24.0 } else { 0.0 }) * 0.25).floor() + 98.0;
|
||||
[
|
||||
(x0, y, "gui/sprites/pause_menu/bug"),
|
||||
(x0 + 24.0, y, "gui/sprites/pause_menu/social_interactions"),
|
||||
@@ -120,7 +139,9 @@ impl Game<'_> {
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) {
|
||||
let links = !self.server_links().is_empty();
|
||||
let mut tooltip = None;
|
||||
for (x, y, bw, key, id, active) in pause_buttons(w, h, self.external_server(), links) {
|
||||
let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
@@ -133,9 +154,12 @@ impl Game<'_> {
|
||||
active,
|
||||
hover,
|
||||
);
|
||||
// PauseScreen's customAdditions button: CUSTOM_OPTIONS_TOOLTIP.
|
||||
if id == 11 && hover {
|
||||
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
|
||||
}
|
||||
}
|
||||
let mut tooltip = None;
|
||||
for (i, (x, y, sprite)) in pause_icons(w, h).into_iter().enumerate() {
|
||||
for (i, (x, y, sprite)) in pause_icons(w, h, links).into_iter().enumerate() {
|
||||
let active = i == FRIENDS_ICON && friends;
|
||||
let hover = ui::hovered(self.cursor, x, y, 20.0, 20.0);
|
||||
// The icon has no label: name it for controller focus and the
|
||||
@@ -168,7 +192,13 @@ impl Game<'_> {
|
||||
}
|
||||
// SpriteIconButton.withTootip: the label as a tooltip.
|
||||
if let Some(tip) = tooltip {
|
||||
ui::tooltip(batch, atlas, &[tip], self.cursor);
|
||||
let lines: Vec<Text> = atlas
|
||||
.font
|
||||
.split_lines(&tip, batch.width)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.collect();
|
||||
ui::tooltip(batch, atlas, &lines, self.cursor);
|
||||
}
|
||||
}
|
||||
pub(super) fn pause_click(&mut self) -> Result<()> {
|
||||
@@ -180,8 +210,9 @@ impl Game<'_> {
|
||||
let b = HudBatch::new(size);
|
||||
(b.width, b.height)
|
||||
};
|
||||
let friends = pause_icons(w, h)[FRIENDS_ICON];
|
||||
let hit = pause_buttons(w, h, self.external_server())
|
||||
let links = !self.server_links().is_empty();
|
||||
let friends = pause_icons(w, h, links)[FRIENDS_ICON];
|
||||
let hit = pause_buttons(w, h, self.external_server(), links)
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4)
|
||||
@@ -200,6 +231,11 @@ impl Game<'_> {
|
||||
Some(4) => self.open_advancements(true),
|
||||
Some(9) => self.open_world_options(),
|
||||
Some(10) => self.screen = Screen::Friends,
|
||||
// DialogConnectionAccess.openDialog(Dialogs.SERVER_LINKS).
|
||||
Some(11) => {
|
||||
self.links_ui.scroll = 0.0;
|
||||
self.screen = Screen::ServerLinks;
|
||||
}
|
||||
Some(2) => self.close_world()?,
|
||||
_ => {}
|
||||
}
|
||||
@@ -357,7 +393,7 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Screen, pause_buttons};
|
||||
use super::{Screen, pause_buttons, pause_icons};
|
||||
|
||||
#[test]
|
||||
fn menu_blur_matches_vanilla_screen_backgrounds() {
|
||||
@@ -390,11 +426,32 @@ mod tests {
|
||||
#[test]
|
||||
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
|
||||
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
|
||||
let buttons = pause_buttons(427.0, 240.0, remote);
|
||||
let buttons = pause_buttons(427.0, 240.0, remote, false);
|
||||
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
|
||||
assert_eq!(quit.3, label);
|
||||
assert!(quit.5);
|
||||
assert!(buttons.iter().any(|b| b.4 == 9 && b.5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_links_adds_a_full_width_row_above_options() {
|
||||
let with = pause_buttons(427.0, 240.0, true, true);
|
||||
let without = pause_buttons(427.0, 240.0, true, false);
|
||||
let links = with.iter().find(|b| b.4 == 11).unwrap();
|
||||
assert_eq!(links.2, 204.0);
|
||||
assert_eq!(links.3, "menu.server_links");
|
||||
assert!(links.5);
|
||||
// The icon row sits above it; Options and Disconnect move down 24.
|
||||
let icons = pause_icons(427.0, 240.0, true);
|
||||
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
|
||||
assert_eq!(icons[0].1, y0_icons);
|
||||
assert!(links.1 > icons[0].1);
|
||||
for b in &with {
|
||||
if b.4 == 1 || b.4 == 9 || b.4 == 2 {
|
||||
let same = without.iter().find(|o| o.4 == b.4).unwrap();
|
||||
assert_eq!(b.1, same.1 + 24.0 - 6.0, "{}", b.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//! `ServerLinksDialogScreen`: the pause menu's "Server Links..." button opens
|
||||
//! the `minecraft:server_links` dialog (the `pause_screen_additions` tag's
|
||||
//! fallback when a server sent ClientboundServerLinksPacket): a scrollable
|
||||
//! list of the server's links, each opened through `ConfirmLinkScreen`
|
||||
//! (Screen.clickUrlAction), and the `dialog/warning_button`'s
|
||||
//! DialogScreen.WarningScreen ("Note about custom screens").
|
||||
//! https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/dialog/ServerLinksDialogScreen
|
||||
use super::{Game, Screen, packs::open_uri};
|
||||
use horizon_client::{Result, clipboard::Clipboard, hud::HudBatch, text::Text, ui};
|
||||
use horizon_protocol::packets::configuration::{ServerLink, ServerLinkKind};
|
||||
|
||||
/// HeaderAndFooterLayout's header and footer heights; the content wants to
|
||||
/// start 30 pixels under the header (its `CONTENTS_MARGIN_TOP`).
|
||||
const HEADER: f32 = 33.0;
|
||||
const FOOTER: f32 = 33.0;
|
||||
const CONTENT_TOP: f32 = HEADER + 30.0;
|
||||
/// `server_links.json`: `columns` 1, `button_width` 310, `exit_action`'s 200.
|
||||
const LINK_WIDTH: f32 = 310.0;
|
||||
/// packControlsIntoColumns: rowSpacing 2 under the 20-pixel buttons.
|
||||
const ROW: f32 = 22.0;
|
||||
/// ScrollableLayout's BOTH reserve (scrollbarSpacing 4 + SCROLLBAR_WIDTH 6).
|
||||
const SCROLL_RESERVE: f32 = 10.0;
|
||||
/// DialogScreen's `dialog/warning_button` ImageButton.
|
||||
const WARNING_BUTTON: f32 = 20.0;
|
||||
|
||||
/// The ServerLinks screen's state: ScrollableLayout's scroll and the link
|
||||
/// ConfirmLinkScreen is asking about (`Screen::ConfirmLink`).
|
||||
#[derive(Default)]
|
||||
pub(super) struct LinksUi {
|
||||
pub scroll: f32,
|
||||
pub confirm: Option<String>,
|
||||
}
|
||||
|
||||
/// `Util.parseAndValidateUntrustedUri`: an absolute `http`/`https` URI
|
||||
/// (java.net.URI's scheme then the allowlist; whitespace-free).
|
||||
pub(super) fn valid_untrusted_uri(uri: &str) -> bool {
|
||||
let Some(colon) = uri.find(':') else {
|
||||
return false;
|
||||
};
|
||||
let scheme = &uri[..colon];
|
||||
let mut chars = scheme.chars();
|
||||
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
|| !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
!uri.chars().any(|c| c.is_control() || c == ' ')
|
||||
}
|
||||
/// `ClientCommonPacketListenerImpl.handleServerLinks`: keep only the links
|
||||
/// that validate, warning about the dropped ones.
|
||||
pub(super) fn trusted(links: Vec<ServerLink>) -> Vec<ServerLink> {
|
||||
links
|
||||
.into_iter()
|
||||
.filter(|l| {
|
||||
let ok = valid_untrusted_uri(&l.link);
|
||||
if !ok {
|
||||
log::warn!("Horizon: server sent an invalid link: {}", l.link);
|
||||
}
|
||||
ok
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `ServerLinks.Entry.displayName`: `known_server_link.<name>` for a known
|
||||
/// type (ByIdMap.ZERO reads unknown ids as `report_bug`), the component else.
|
||||
fn link_label(link: &ServerLink) -> Text {
|
||||
const KNOWN: [&str; 10] = [
|
||||
"report_bug",
|
||||
"community_guidelines",
|
||||
"support",
|
||||
"status",
|
||||
"feedback",
|
||||
"community",
|
||||
"website",
|
||||
"forums",
|
||||
"news",
|
||||
"announcements",
|
||||
];
|
||||
match &link.kind {
|
||||
ServerLinkKind::Known(id) => Text::translate(
|
||||
&format!(
|
||||
"known_server_link.{}",
|
||||
KNOWN.get(*id as usize).copied().unwrap_or(KNOWN[0])
|
||||
),
|
||||
&[],
|
||||
),
|
||||
ServerLinkKind::Custom(tag) => {
|
||||
horizon_client::scoreboard::comp_text(&horizon_client::scoreboard::tag_to_json(tag))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The scrolled column of link buttons: content height and, for a list that
|
||||
/// fits, the `HeaderAndFooterLayout` contents frame's top.
|
||||
fn list_height(n: usize) -> f32 {
|
||||
n as f32 * ROW - 2.0
|
||||
}
|
||||
fn list_top(h: f32, n: usize) -> f32 {
|
||||
CONTENT_TOP.min(h - FOOTER - list_height(n).min(h - HEADER - FOOTER))
|
||||
}
|
||||
fn scroll_limit(h: f32, n: usize) -> f32 {
|
||||
(list_height(n) - (h - HEADER - FOOTER)).max(0.0)
|
||||
}
|
||||
|
||||
/// `ServerLinksScreen`'s buttons as (x, y) over the scrolled content, and the
|
||||
/// footer `Back` button's corner. `top` includes the scroll.
|
||||
fn link_buttons(w: f32, top: f32, n: usize) -> Vec<(f32, f32)> {
|
||||
let x = ((w - LINK_WIDTH - 2.0 * SCROLL_RESERVE) / 2.0 + SCROLL_RESERVE).floor();
|
||||
(0..n).map(|i| (x, top + i as f32 * ROW)).collect()
|
||||
}
|
||||
/// The warning button beside the title (DialogScreen's title row: 10 pixels
|
||||
/// apart), clamped onto the screen (makeSureWarningButtonIsInBounds).
|
||||
fn warning_button(w: f32, h: f32, title_width: f32) -> (f32, f32) {
|
||||
let x = ((w - title_width - 30.0) / 2.0).floor() + title_width + 10.0;
|
||||
let y = ((HEADER - WARNING_BUTTON) / 2.0).floor();
|
||||
if x < 0.0 || y < 0.0 || x > w - WARNING_BUTTON || y > h - WARNING_BUTTON {
|
||||
(0.0f32.max(w - 40.0), 5.0f32.min(h))
|
||||
} else {
|
||||
(x, y)
|
||||
}
|
||||
}
|
||||
fn back_button(w: f32, h: f32) -> (f32, f32) {
|
||||
(
|
||||
((w - 200.0) / 2.0).floor(),
|
||||
h - FOOTER + ((FOOTER - 20.0) / 2.0).floor(),
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `connection.serverLinks()`: the links the open server sent (empty for
|
||||
/// a world without a connection or one that sent none).
|
||||
pub(super) fn server_links(&self) -> &[ServerLink] {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.map_or(&[], |r| r.server_links.as_slice())
|
||||
}
|
||||
/// DialogScreen over the pause menu: `menu.server_links.title`, the
|
||||
/// scrollable link buttons and `gui.back` in the footer.
|
||||
pub(super) fn draw_server_links(&mut self, batch: &mut HudBatch) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let links = self.server_links().to_vec();
|
||||
self.links_ui.scroll = self
|
||||
.links_ui
|
||||
.scroll
|
||||
.clamp(0.0, scroll_limit(h, links.len()));
|
||||
let top = list_top(h, links.len()) - self.links_ui.scroll.trunc();
|
||||
// The title row: the title, then 10 pixels on the warning button.
|
||||
let title = Text::translate("menu.server_links.title", &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
let tx = ((w - tw - 30.0) / 2.0).floor();
|
||||
batch.text_styled(atlas, &title, tx, 12.0, u32::MAX, true);
|
||||
let (wx, wy) = warning_button(w, h, tw);
|
||||
let wh = ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON);
|
||||
horizon_client::widgets::record(
|
||||
"menu.custom_screen_info.tooltip",
|
||||
[wx, wy, WARNING_BUTTON, WARNING_BUTTON],
|
||||
true,
|
||||
);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
if wh {
|
||||
"gui/sprites/dialog/warning_button_highlighted"
|
||||
} else {
|
||||
"gui/sprites/dialog/warning_button"
|
||||
},
|
||||
wx,
|
||||
wy,
|
||||
WARNING_BUTTON,
|
||||
WARNING_BUTTON,
|
||||
u32::MAX,
|
||||
);
|
||||
let first = batch.vertices.len();
|
||||
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
|
||||
for (link, (x, y)) in links.iter().zip(link_buttons(w, top, links.len())) {
|
||||
let hover = inside && ui::hovered(self.cursor, x, y, LINK_WIDTH, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
LINK_WIDTH,
|
||||
20.0,
|
||||
&link_label(link),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
(w / 2.0).floor() + 155.0 + 4.0,
|
||||
HEADER,
|
||||
h - FOOTER,
|
||||
list_height(links.len()),
|
||||
self.links_ui.scroll.trunc(),
|
||||
);
|
||||
let (bx, by) = back_button(w, h);
|
||||
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
bx,
|
||||
by,
|
||||
200.0,
|
||||
20.0,
|
||||
&Text::translate("gui.back", &[]),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
if wh {
|
||||
ui::tooltip(
|
||||
batch,
|
||||
atlas,
|
||||
&[Text::translate("menu.custom_screen_info.tooltip", &[])],
|
||||
self.cursor,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A click on `Screen::ServerLinks`: a link, the warning button or Back.
|
||||
pub(super) fn server_links_click(&mut self) -> Result<()> {
|
||||
let (w, h) = self.gui_size();
|
||||
let links = self.server_links().to_vec();
|
||||
let top = list_top(h, links.len()) - self.links_ui.scroll.trunc();
|
||||
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
|
||||
if inside
|
||||
&& let Some(i) = link_buttons(w, top, links.len())
|
||||
.iter()
|
||||
.position(|(x, y)| ui::hovered(self.cursor, *x, *y, LINK_WIDTH, 20.0))
|
||||
{
|
||||
self.audio.play_button_click();
|
||||
self.click_link(&links[i].link);
|
||||
return Ok(());
|
||||
}
|
||||
let title = Text::translate("menu.server_links.title", &[]);
|
||||
let tw = self.gui.atlas.font.width_text(&title);
|
||||
let (wx, wy) = warning_button(w, h, tw);
|
||||
if ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON) {
|
||||
self.audio.play_button_click();
|
||||
self.screen = Screen::DialogWarning;
|
||||
return Ok(());
|
||||
}
|
||||
let (bx, by) = back_button(w, h);
|
||||
if ui::hovered(self.cursor, bx, by, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.screen = Screen::Paused;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `Screen.clickUrlAction`: "Web Links" off ignores the click; "Prompt on
|
||||
/// Links" asks with ConfirmLinkScreen, else the browser opens directly.
|
||||
fn click_link(&mut self, url: &str) {
|
||||
if !self.settings.chat_links {
|
||||
return;
|
||||
}
|
||||
if self.settings.chat_links_prompt {
|
||||
self.links_ui.confirm = Some(url.to_owned());
|
||||
self.screen = Screen::ConfirmLink;
|
||||
} else {
|
||||
open_uri(url);
|
||||
}
|
||||
}
|
||||
/// `ScrollableLayout`'s mouse wheel over the links.
|
||||
pub(super) fn server_links_scroll(&mut self, delta: f32) {
|
||||
let (_, h) = self.gui_size();
|
||||
self.links_ui.scroll = (self.links_ui.scroll - delta * 10.0)
|
||||
.clamp(0.0, scroll_limit(h, self.server_links().len()));
|
||||
}
|
||||
/// `ConfirmLinkScreen` (untrusted): "Are you sure you want to open the
|
||||
/// following website?", the link, the red warning, and Yes / Copy Link to
|
||||
/// Clipboard / No.
|
||||
pub(super) fn draw_confirm_link(&mut self, batch: &mut HudBatch) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let url = self.links_ui.confirm.clone().unwrap_or_default();
|
||||
let layout = confirm_link_layout(atlas, (w, h), &url);
|
||||
for (line, x, y) in &layout.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
let labels = ["gui.yes", "gui.copy_link_to_clipboard", "gui.no"];
|
||||
for ((x, y), key) in layout.buttons.iter().zip(labels) {
|
||||
let hover = ui::hovered(self.cursor, *x, *y, 100.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
*x,
|
||||
*y,
|
||||
100.0,
|
||||
20.0,
|
||||
&Text::translate(key, &[]),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// ConfirmLinkScreen's answer: yes opens the browser, the middle button
|
||||
/// copies the link, no closes; all return to Server Links.
|
||||
pub(super) fn confirm_link_click(&mut self) {
|
||||
let (w, h) = self.gui_size();
|
||||
let url = self.links_ui.confirm.clone().unwrap_or_default();
|
||||
let layout = confirm_link_layout(&self.gui.atlas, (w, h), &url);
|
||||
for (i, (x, y)) in layout.buttons.iter().enumerate() {
|
||||
if !ui::hovered(self.cursor, *x, *y, 100.0, 20.0) {
|
||||
continue;
|
||||
}
|
||||
self.audio.play_button_click();
|
||||
match i {
|
||||
0 => open_uri(&url),
|
||||
1 => {
|
||||
self.clipboard.set(&url);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.screen = Screen::ServerLinks;
|
||||
return;
|
||||
}
|
||||
}
|
||||
/// `DialogScreen.WarningScreen`: the custom-screens note, a disconnect
|
||||
/// button ("Custom screen rejected" is the shown reason) and Back.
|
||||
pub(super) fn draw_dialog_warning(&mut self, batch: &mut HudBatch) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let layout = dialog_warning_layout(atlas, (w, h));
|
||||
for (line, x, y) in &layout.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
let quit = if self.external_server() {
|
||||
"menu.disconnect"
|
||||
} else {
|
||||
"menu.returnToMenu"
|
||||
};
|
||||
for ((x, y), key) in layout.buttons.into_iter().zip([quit, "gui.back"]) {
|
||||
let hover = ui::hovered(self.cursor, x, y, 150.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
150.0,
|
||||
20.0,
|
||||
&Text::translate(key, &[]),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// WarningScreen's answer: disconnect, or back to Server Links.
|
||||
pub(super) fn dialog_warning_click(&mut self) -> Result<()> {
|
||||
let (w, h) = self.gui_size();
|
||||
let layout = dialog_warning_layout(&self.gui.atlas, (w, h));
|
||||
for (i, (x, y)) in layout.buttons.iter().enumerate() {
|
||||
if !ui::hovered(self.cursor, *x, *y, 150.0, 20.0) {
|
||||
continue;
|
||||
}
|
||||
self.audio.play_button_click();
|
||||
if i == 0 {
|
||||
// DialogConnectionAccess.disconnect: leave with the custom
|
||||
// screens' reason.
|
||||
let reason = self.translate(
|
||||
"menu.custom_screen_info.disconnect",
|
||||
"Custom screen rejected",
|
||||
);
|
||||
self.leave_server(Some(reason))?;
|
||||
} else {
|
||||
self.screen = Screen::ServerLinks;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A confirm screen's laid-out text (title, message, extra warning lines) and
|
||||
/// button corners; the whole column centred (ConfirmScreen.init).
|
||||
struct ConfirmLayout {
|
||||
lines: Vec<(Text, f32, f32)>,
|
||||
buttons: Vec<(f32, f32)>,
|
||||
}
|
||||
/// `ConfirmScreen.init`: title, message wrapped to `width - 50` (15 rows at
|
||||
/// most), the extra lines, then a button row `spacing` 4 with `paddingTop` 16.
|
||||
fn confirm_layout(
|
||||
font: &horizon_client::font::Font,
|
||||
(w, h): (f32, f32),
|
||||
title: &Text,
|
||||
message: &Text,
|
||||
extra: &[Text],
|
||||
button_widths: &[f32],
|
||||
) -> ConfirmLayout {
|
||||
let message: Vec<Text> = font
|
||||
.split_lines(message, w - 50.0)
|
||||
.into_iter()
|
||||
.take(15)
|
||||
.map(|(line, _)| line)
|
||||
.collect();
|
||||
// LinearLayout vertical spacing 8; the button row's children pad 16 on top.
|
||||
let extra_h = 17.0 * extra.len() as f32;
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let total = 9.0 + 8.0 + mh + extra_h + 8.0 + 16.0 + 20.0;
|
||||
let mut y = ((h - total) / 2.0).trunc();
|
||||
let mut lines = Vec::new();
|
||||
let mut push = |line: &Text, at: f32| {
|
||||
let lw = font.width_text(line);
|
||||
lines.push((line.clone(), ((w - lw) / 2.0).trunc(), at));
|
||||
};
|
||||
push(title, y);
|
||||
y += 17.0;
|
||||
for line in &message {
|
||||
push(line, y);
|
||||
y += 9.0;
|
||||
}
|
||||
for line in extra {
|
||||
y += 8.0;
|
||||
push(line, y);
|
||||
y += 9.0;
|
||||
}
|
||||
let row_w: f32 = button_widths.iter().sum::<f32>() + 4.0 * (button_widths.len() - 1) as f32;
|
||||
let by = y + 8.0 + 16.0;
|
||||
let mut bx = ((w - row_w) / 2.0).trunc();
|
||||
let buttons = button_widths
|
||||
.iter()
|
||||
.map(|bw| {
|
||||
let p = (bx, by);
|
||||
bx += bw + 4.0;
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
ConfirmLayout { lines, buttons }
|
||||
}
|
||||
fn confirm_link_layout(
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
size: (f32, f32),
|
||||
url: &str,
|
||||
) -> ConfirmLayout {
|
||||
// ConfirmLinkScreen (untrusted): `chat.link.confirm`, the link as the
|
||||
// message, `chat.link.warning` in its red (0xFFCCCC).
|
||||
confirm_layout(
|
||||
&atlas.font,
|
||||
size,
|
||||
&Text::translate("chat.link.confirm", &[]),
|
||||
&Text::literal(url.to_owned()),
|
||||
&[Text::translate("chat.link.warning", &[]).with_color(0xFFCCCC)],
|
||||
&[100.0; 3],
|
||||
)
|
||||
}
|
||||
fn dialog_warning_layout(atlas: &horizon_client::hud::HudAtlas, size: (f32, f32)) -> ConfirmLayout {
|
||||
confirm_layout(
|
||||
&atlas.font,
|
||||
size,
|
||||
&Text::translate("menu.custom_screen_info.title", &[]),
|
||||
&Text::translate("menu.custom_screen_info.contents", &[]),
|
||||
&[],
|
||||
&[150.0; 2],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn untrusted_uris_are_http_or_https_with_a_scheme() {
|
||||
for ok in [
|
||||
"https://example.net/x",
|
||||
"http://example.net",
|
||||
"HTTPS://EXAMPLE.NET",
|
||||
] {
|
||||
assert!(valid_untrusted_uri(ok), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"example.net/no-scheme",
|
||||
"ftp://example.net",
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"https://exa mple.net",
|
||||
"://example.net",
|
||||
"",
|
||||
] {
|
||||
assert!(!valid_untrusted_uri(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
|
||||
//! server list's setting, downloads into `downloads/`, and a resource reload
|
||||
//! with the server's packs on top of the player's own.
|
||||
use super::Game;
|
||||
use super::{Game, Progress};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
confirm_screen::Confirm,
|
||||
@@ -82,6 +82,9 @@ pub(super) struct ServerPackState {
|
||||
pub(super) active: Vec<packs::Pack>,
|
||||
/// The download batches' notifiers, oldest first.
|
||||
notifiers: Vec<PackNotifier>,
|
||||
/// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the
|
||||
/// frame draws its progress until `tick_server_packs` applies it.
|
||||
pub(super) reload: Option<PackReload>,
|
||||
}
|
||||
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
|
||||
/// own `SystemToast`.
|
||||
@@ -191,6 +194,21 @@ fn saves() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_default().join("saves")
|
||||
}
|
||||
|
||||
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
|
||||
const RELOAD_LINES: [&str; 4] = [
|
||||
"Preparing resources",
|
||||
"Loading assets",
|
||||
"Loading pack tables",
|
||||
"Applying resources",
|
||||
];
|
||||
/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a
|
||||
/// worker while `progress` feeds the loading screen, then the apply lands
|
||||
/// on the render thread.
|
||||
pub(super) struct PackReload {
|
||||
pub(super) progress: Progress,
|
||||
handle: std::thread::JoinHandle<std::result::Result<super::packs::StagedPack, String>>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ConnectScreen`: the server's setting decides what its packs do
|
||||
/// (`configureForServerControl`); single player's own server takes them
|
||||
@@ -314,6 +332,7 @@ impl Game<'_> {
|
||||
if self.rest_loader.is_some() || self.world_loader.is_some() {
|
||||
return;
|
||||
}
|
||||
self.drive_pack_reload();
|
||||
let finished = self
|
||||
.server_packs
|
||||
.queue
|
||||
@@ -328,13 +347,8 @@ impl Game<'_> {
|
||||
if !tick.download.is_empty() {
|
||||
self.start_pack_downloads(tick.download);
|
||||
}
|
||||
if let Some(mut load) = tick.reload {
|
||||
while let Some(recovery) = {
|
||||
let ok = self.reload_server_packs(&load);
|
||||
self.server_packs.packs.reloaded(ok)
|
||||
} {
|
||||
load = recovery;
|
||||
}
|
||||
if let Some(load) = tick.reload {
|
||||
self.start_pack_reload(&load);
|
||||
}
|
||||
}
|
||||
for (id, status) in self.server_packs.packs.take_replies() {
|
||||
@@ -379,24 +393,90 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `DownloadedPackSource.startReload`: the packs on top of the player's
|
||||
/// own, then `Minecraft.reloadResourcePacks`. False when a pack has no
|
||||
/// readable `pack.mcmeta` or the reload failed.
|
||||
fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool {
|
||||
let packs: Option<Vec<_>> = load
|
||||
/// own, staged on a worker (`Minecraft.reloadResourcePacks` under a
|
||||
/// `LoadingOverlay`); `drive_pack_reload` applies it. A pack with no
|
||||
/// readable `pack.mcmeta` fails the reload at once, as `onFailure`.
|
||||
fn start_pack_reload(&mut self, load: &[(u128, PathBuf)]) {
|
||||
let server: Option<Vec<_>> = load
|
||||
.iter()
|
||||
.map(|(id, path)| packs::server_pack(*id, path))
|
||||
.collect();
|
||||
let Some(packs) = packs else {
|
||||
let Some(server) = server else {
|
||||
log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all");
|
||||
return false;
|
||||
self.finish_pack_reload(false);
|
||||
return;
|
||||
};
|
||||
self.server_packs.active = packs;
|
||||
match self.reload_selected_packs() {
|
||||
self.server_packs.active = server.clone();
|
||||
// The overlay swallows input until the apply (`LoadingOverlay`):
|
||||
// release what is held so nothing keeps acting while it covers the
|
||||
// frame (`KeyMapping.releaseAll` when a screen opens).
|
||||
self.keys.clear();
|
||||
self.physical_keys.clear();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let (selected, mods, dev_mods) = (
|
||||
self.settings.resource_packs.clone(),
|
||||
self.settings.mods.enabled.clone(),
|
||||
self.options.dev_mods.clone(),
|
||||
);
|
||||
let (assets, res, context) = (
|
||||
self.pack.assets.clone(),
|
||||
self.res.clone(),
|
||||
self.pack.context.clone(),
|
||||
);
|
||||
let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs);
|
||||
let progress = Progress::with(&RELOAD_LINES);
|
||||
let worker = progress.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("server-pack-reload".into())
|
||||
.spawn(move || {
|
||||
super::packs::stage_pack(
|
||||
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context,
|
||||
unicode, japanese, &worker,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
match handle {
|
||||
Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }),
|
||||
Err(e) => {
|
||||
log::error!("Horizon: could not start the pack reload worker: {e}");
|
||||
self.finish_pack_reload(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PackReloadConfig.Callbacks`: applies the staged reload once its
|
||||
/// worker is done; on failure the packs active before reload again.
|
||||
fn drive_pack_reload(&mut self) {
|
||||
let Some(reload) = &self.server_packs.reload else {
|
||||
return;
|
||||
};
|
||||
if !reload.handle.is_finished() {
|
||||
return;
|
||||
}
|
||||
let reload = self.server_packs.reload.take().unwrap();
|
||||
let staged = reload
|
||||
.handle
|
||||
.join()
|
||||
.unwrap_or_else(|_| Err("the pack reload worker panicked".into()));
|
||||
let ok = match staged
|
||||
.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: server resource pack reload failed: {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
self.finish_pack_reload(ok);
|
||||
}
|
||||
/// `ServerPacks::reloaded` and the recovery reload it can ask for.
|
||||
fn finish_pack_reload(&mut self, ok: bool) {
|
||||
if let Some(recovery) = self.server_packs.packs.reloaded(ok) {
|
||||
self.start_pack_reload(&recovery);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ pub(super) struct SignEditor {
|
||||
pub line: usize,
|
||||
/// The line being edited (`AbstractSignEditScreen`'s text field).
|
||||
edit: EditBox,
|
||||
/// `AbstractSignEditScreen.messages`: the side's lines as edited, sent
|
||||
/// when the editor closes, even if the sign is gone by then.
|
||||
lines: [String; 4],
|
||||
}
|
||||
|
||||
fn sign_box() -> EditBox {
|
||||
@@ -33,11 +36,19 @@ fn sign_box() -> EditBox {
|
||||
impl Game<'_> {
|
||||
/// `SignBlock.openTextEdit` for the front side after placement.
|
||||
pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) {
|
||||
// Minecraft.setScreen: the editor already open is removed first.
|
||||
self.close_sign_editor();
|
||||
let lines = match self.world.block_entities.get(&pos) {
|
||||
Some(BlockEntity::Sign(sign)) if front => sign.front.lines.clone(),
|
||||
Some(BlockEntity::Sign(sign)) => sign.back.lines.clone(),
|
||||
_ => Default::default(),
|
||||
};
|
||||
self.sign_edit = Some(SignEditor {
|
||||
pos,
|
||||
front,
|
||||
line: 0,
|
||||
edit: sign_box(),
|
||||
lines,
|
||||
});
|
||||
self.sign_load();
|
||||
self.keys.clear();
|
||||
@@ -50,14 +61,11 @@ impl Game<'_> {
|
||||
pub(super) fn close_sign_editor(&mut self) {
|
||||
if let Some(edit) = self.sign_edit.take() {
|
||||
if self.remote.is_some() {
|
||||
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) {
|
||||
let side = if edit.front { &sign.front } else { &sign.back };
|
||||
self.send(S::SignUpdate(play::SignUpdate {
|
||||
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
|
||||
lines: side.lines.clone(),
|
||||
front: edit.front,
|
||||
}));
|
||||
}
|
||||
self.send(S::SignUpdate(play::SignUpdate {
|
||||
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
|
||||
lines: edit.lines,
|
||||
front: edit.front,
|
||||
}));
|
||||
} else {
|
||||
self.mark_block_entity_updated(edit.pos);
|
||||
}
|
||||
@@ -206,7 +214,8 @@ impl Game<'_> {
|
||||
edit.edit.set_value(&text);
|
||||
}
|
||||
}
|
||||
/// The box's text onto the sign, when it still fits the line.
|
||||
/// The box's text onto the editor's lines and the sign
|
||||
/// (`AbstractSignEditScreen.setMessage`), when it still fits the line.
|
||||
fn sign_write(&mut self) {
|
||||
let Some(edit) = self.sign_edit.as_ref() else {
|
||||
return;
|
||||
@@ -215,6 +224,9 @@ impl Game<'_> {
|
||||
if !self.sign_fits(&value) {
|
||||
return;
|
||||
}
|
||||
if let Some(edit) = self.sign_edit.as_mut() {
|
||||
edit.lines[line].clone_from(&value);
|
||||
}
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -164,11 +164,12 @@ impl Game<'_> {
|
||||
} else {
|
||||
[self.yaw, self.pitch]
|
||||
};
|
||||
let scale = self.view_scale();
|
||||
for display in self.text_displays.values_mut() {
|
||||
display.last_progress = display.progress(partial as f32);
|
||||
let d = display.shown.blend(&display.from, display.last_progress);
|
||||
let position = display.previous.lerp(display.position, partial);
|
||||
let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance);
|
||||
let range = f64::from(d.view_range) * 64.0 * scale;
|
||||
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -268,6 +269,8 @@ impl Game<'_> {
|
||||
light,
|
||||
layer: if through { 2 } else { 0 },
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ impl Game<'_> {
|
||||
}
|
||||
let range = super::entity_culling::default_range(
|
||||
glam::DVec3::splat(PrimedTnt::SIZE),
|
||||
self.settings.entity_distance,
|
||||
self.view_scale(),
|
||||
);
|
||||
for t in &self.tnts {
|
||||
// EntityRenderer.shouldRender.
|
||||
@@ -332,6 +332,8 @@ impl Game<'_> {
|
||||
layer: if lit { 5 } else { 0 },
|
||||
color,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +495,7 @@ impl Game<'_> {
|
||||
let range = 10.0
|
||||
* super::entity_culling::default_range(
|
||||
glam::DVec3::splat(2.0 * HALF),
|
||||
self.settings.entity_distance,
|
||||
self.view_scale(),
|
||||
);
|
||||
for t in &self.tridents {
|
||||
// EntityRenderer.shouldRender; ThrownTridentRenderer grows the box by 1.5.
|
||||
|
||||
@@ -68,7 +68,7 @@ impl Game<'_> {
|
||||
velocity,
|
||||
on_ground,
|
||||
);
|
||||
self.creatures.projectiles.push(proj);
|
||||
self.add_projectile(proj);
|
||||
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
|
||||
self.audio
|
||||
.play("minecraft:entity.wind_charge.throw", Some(eye), 0.5, pitch);
|
||||
|
||||
@@ -93,7 +93,12 @@ impl WorldRequest {
|
||||
level.hardcore = o.hardcore;
|
||||
level.difficulty = horizon_client::title::DIFFICULTIES[o.difficulty()].into();
|
||||
level.allow_commands = Some(o.commands(creative));
|
||||
horizon_client::save::write_world_gen_settings(&save_root, level.seed, o.world_gen())?;
|
||||
horizon_client::save::write_world_gen_settings_with(
|
||||
&save_root,
|
||||
level.seed,
|
||||
o.world_gen(),
|
||||
o.flat_settings(),
|
||||
)?;
|
||||
}
|
||||
// Worlds made without the Create New World screen (--world, the
|
||||
// control socket) take its default: commands on for Creative only.
|
||||
@@ -320,10 +325,12 @@ pub(super) fn make_streaming(
|
||||
res.threads(),
|
||||
)
|
||||
} else {
|
||||
// WorldOptions.generateStructures, from the world's settings.
|
||||
let structures =
|
||||
horizon_client::save::WorldGen::read(world_root).is_none_or(|w| w.structures);
|
||||
let inner = Arc::new(VanillaTerrain::for_world(&options.resources.join("worldgen"), pack.assets.registry, res.biomes(), seed, dim.id(), structures).map_err(|e| {
|
||||
// WorldOptions.generateStructures and the world preset, from the
|
||||
// world's settings.
|
||||
let world_gen = horizon_client::save::WorldGen::read(world_root);
|
||||
let structures = world_gen.is_none_or(|w| w.structures);
|
||||
let preset = world_gen.map_or(horizon_client::save::WorldPreset::Normal, |w| w.preset);
|
||||
let inner = Arc::new(VanillaTerrain::for_world(&options.resources.join("worldgen"), pack.assets.registry, res.biomes(), seed, dim.id(), preset.id(), structures).map_err(|e| {
|
||||
format!("{e}\nPrepare the worldgen bundle with the launcher, or: horizon-prepare --prepare")
|
||||
})?);
|
||||
if dim == Dim::Overworld && structures {
|
||||
|
||||
@@ -35,6 +35,8 @@ fn entity_shader() -> String {
|
||||
#[cfg(test)]
|
||||
mod crumbling_tests;
|
||||
mod entity_batch;
|
||||
#[cfg(test)]
|
||||
mod headless_tests;
|
||||
mod weather;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{SHADOW_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY};
|
||||
@@ -172,12 +174,13 @@ struct Globals {
|
||||
inverse_view: [[f32; 4]; 4],
|
||||
sunrise: [f32; 4],
|
||||
sun: [f32; 4],
|
||||
/// x: sky fog end, y: cloud fog end, z: dark disc (camera below the horizon), w: unused.
|
||||
/// x: sky fog end, y: cloud fog end, z: dark disc (camera below the horizon),
|
||||
/// w: the glint strength (`GlintAlpha`).
|
||||
fog_ends: [f32; 4],
|
||||
/// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
|
||||
end_portal_sprites: [[f32; 4]; 2],
|
||||
/// x: vanilla's `GameTime` uniform; y: nether cardinal lighting; z: how many
|
||||
/// `dynamic_lights` are set; w unused.
|
||||
/// `dynamic_lights` are set; w: the glint scroll clock (`Glint::scroll_ticks`).
|
||||
time: [f32; 4],
|
||||
/// Atlas rect of a destroy stage (entity.wgsl takes only its size: every stage's is the same,
|
||||
/// and each crack carries its own stage's origin).
|
||||
@@ -256,6 +259,12 @@ pub struct Frame<'a> {
|
||||
/// drawn after the hand at its projection, without the bob or fog.
|
||||
pub debug_crosshair: &'a [[f32; 8]],
|
||||
pub hud: &'a HudBatch,
|
||||
/// `GlintAlpha` (accessibility Glint Strength); the entity shader uses it
|
||||
/// for held/world items, independent of what the HUD batch last drew.
|
||||
pub glint_alpha: f32,
|
||||
/// The glint scroll clock (`Glint::scroll_ticks`): entity.wgsl's
|
||||
/// `ARMOR_ENTITY_GLINT_TEXTURING` phase.
|
||||
pub glint_ticks: f32,
|
||||
/// CardinalLighting.Type.NETHER, otherwise DEFAULT.
|
||||
pub nether_lighting: bool,
|
||||
/// Camera-relative entity geometry textured from the shared atlas.
|
||||
@@ -331,6 +340,12 @@ struct BorderUniform {
|
||||
color: [f32; 4],
|
||||
offset: [f32; 4],
|
||||
}
|
||||
/// A frame copied back for `Renderer::read_capture`.
|
||||
struct Capture {
|
||||
buffer: wgpu::Buffer,
|
||||
submission: wgpu::SubmissionIndex,
|
||||
mapped: std::sync::mpsc::Receiver<std::result::Result<(), wgpu::BufferAsyncError>>,
|
||||
}
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
@@ -358,7 +373,14 @@ pub struct Renderer {
|
||||
pub last_wait: std::time::Duration,
|
||||
/// CPU time of the last `render`, waits excluded.
|
||||
pub last_encode: std::time::Duration,
|
||||
window: Arc<Window>,
|
||||
/// The window presented to; `None` for a headless renderer (`Renderer::new_headless`).
|
||||
window: Option<Arc<Window>>,
|
||||
/// Frames copied back for `Renderer::read_capture`, oldest first.
|
||||
captures: std::collections::VecDeque<Capture>,
|
||||
/// Readback buffers of the current size, ready for reuse.
|
||||
capture_pool: Vec<wgpu::Buffer>,
|
||||
/// Copy every rendered frame back for `Renderer::read_capture`.
|
||||
pub capture_frames: bool,
|
||||
pub device: wgpu::Device,
|
||||
pub queue: wgpu::Queue,
|
||||
pub config: wgpu::SurfaceConfiguration,
|
||||
@@ -511,10 +533,10 @@ pub struct Renderer {
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// Resolution scale and FXAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is scRGB.
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
hdr: Option<hdr::Hdr>,
|
||||
/// Whether the window's surface can present scRGB.
|
||||
hdr_capable: bool,
|
||||
/// The HDR output the window's surface can present.
|
||||
hdr_output: Option<hdr::Output>,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
@@ -847,7 +869,7 @@ fn surface_format(
|
||||
/// Offscreen rendering needs no surface, so it can use a GPU the window's
|
||||
/// display cannot present from (a headless compositor).
|
||||
async fn open_device(
|
||||
window: &Arc<Window>,
|
||||
window: Option<&Arc<Window>>,
|
||||
offscreen: bool,
|
||||
api: GraphicsApi,
|
||||
layers: u32,
|
||||
@@ -864,16 +886,15 @@ async fn open_device(
|
||||
log::warn!("Horizon renderer: trying {name} ({})", failures.join("; "));
|
||||
}
|
||||
let instance = wgpu::Instance::new(instance_descriptor(backends));
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
match instance.create_surface(window.clone()) {
|
||||
let surface = match window.filter(|_| !offscreen) {
|
||||
None => None,
|
||||
Some(window) => match instance.create_surface(window.clone()) {
|
||||
Ok(surface) => Some(surface),
|
||||
Err(e) => {
|
||||
failures.push(format!("{name} surface: {e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
let adapter = match instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
@@ -1019,6 +1040,90 @@ impl Renderer {
|
||||
sprite_mips: &[crate::mipmap::Settings],
|
||||
animations: &[SpriteAnimation],
|
||||
hud_atlas: (&[u8], u32, u32),
|
||||
glint: (&[u8], u32, u32),
|
||||
glint_armor: (&[u8], u32, u32),
|
||||
forcefield: (&[u8], u32),
|
||||
panorama_faces: &panorama::Faces,
|
||||
prepared: Option<PreparedTextures>,
|
||||
previous: Option<&Self>,
|
||||
api: GraphicsApi,
|
||||
) -> Result<Self> {
|
||||
let size = window.inner_size();
|
||||
Self::open(
|
||||
Some(window),
|
||||
(size.width, size.height),
|
||||
offscreen,
|
||||
sprite_pixels,
|
||||
sprite_size,
|
||||
sprite_minimum_size,
|
||||
layers,
|
||||
sprite_mips,
|
||||
animations,
|
||||
hud_atlas,
|
||||
glint,
|
||||
glint_armor,
|
||||
forcefield,
|
||||
panorama_faces,
|
||||
prepared,
|
||||
previous,
|
||||
api,
|
||||
)
|
||||
.await
|
||||
}
|
||||
/// A renderer with no window: it draws into an offscreen target of this
|
||||
/// size, read back with `capture_frames` and `read_capture`. Needs no
|
||||
/// display server, so it runs on a headless machine.
|
||||
pub async fn new_headless(
|
||||
width: u32,
|
||||
height: u32,
|
||||
sprite_pixels: &[u8],
|
||||
sprite_size: u32,
|
||||
sprite_minimum_size: u32,
|
||||
layers: u32,
|
||||
sprite_mips: &[crate::mipmap::Settings],
|
||||
animations: &[SpriteAnimation],
|
||||
hud_atlas: (&[u8], u32, u32),
|
||||
glint: (&[u8], u32, u32),
|
||||
glint_armor: (&[u8], u32, u32),
|
||||
forcefield: (&[u8], u32),
|
||||
panorama_faces: &panorama::Faces,
|
||||
prepared: Option<PreparedTextures>,
|
||||
api: GraphicsApi,
|
||||
) -> Result<Self> {
|
||||
Self::open(
|
||||
None,
|
||||
(width, height),
|
||||
true,
|
||||
sprite_pixels,
|
||||
sprite_size,
|
||||
sprite_minimum_size,
|
||||
layers,
|
||||
sprite_mips,
|
||||
animations,
|
||||
hud_atlas,
|
||||
glint,
|
||||
glint_armor,
|
||||
forcefield,
|
||||
panorama_faces,
|
||||
prepared,
|
||||
None,
|
||||
api,
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn open(
|
||||
window: Option<Arc<Window>>,
|
||||
size: (u32, u32),
|
||||
offscreen: bool,
|
||||
sprite_pixels: &[u8],
|
||||
sprite_size: u32,
|
||||
sprite_minimum_size: u32,
|
||||
layers: u32,
|
||||
sprite_mips: &[crate::mipmap::Settings],
|
||||
animations: &[SpriteAnimation],
|
||||
hud_atlas: (&[u8], u32, u32),
|
||||
glint: (&[u8], u32, u32),
|
||||
glint_armor: (&[u8], u32, u32),
|
||||
forcefield: (&[u8], u32),
|
||||
panorama_faces: &panorama::Faces,
|
||||
prepared: Option<PreparedTextures>,
|
||||
@@ -1047,18 +1152,19 @@ impl Renderer {
|
||||
describe_adapter(&renderer.adapter);
|
||||
check_layers(&renderer.adapter.limits(), layers)?;
|
||||
Opened {
|
||||
surface: if offscreen {
|
||||
None
|
||||
} else {
|
||||
let spare = renderer
|
||||
.spare_surface
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
Some(match spare {
|
||||
Some(surface) => surface,
|
||||
None => renderer.instance.create_surface(window.clone())?,
|
||||
})
|
||||
surface: match window.as_ref().filter(|_| !offscreen) {
|
||||
None => None,
|
||||
Some(window) => {
|
||||
let spare = renderer
|
||||
.spare_surface
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
Some(match spare {
|
||||
Some(surface) => surface,
|
||||
None => renderer.instance.create_surface(window.clone())?,
|
||||
})
|
||||
}
|
||||
},
|
||||
instance: renderer.instance.clone(),
|
||||
adapter: renderer.adapter.clone(),
|
||||
@@ -1066,12 +1172,12 @@ impl Renderer {
|
||||
queue: renderer.queue.clone(),
|
||||
}
|
||||
}
|
||||
None => open_device(&window, offscreen, api, layers).await?,
|
||||
None => open_device(window.as_ref(), offscreen, api, layers).await?,
|
||||
};
|
||||
let info = adapter.get_info();
|
||||
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
|
||||
let size = window.inner_size();
|
||||
let (config, present_modes, hdr_capable) = if let Some(surface) = &surface {
|
||||
let size = winit::dpi::PhysicalSize::new(size.0, size.1);
|
||||
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
|
||||
let mut config = surface
|
||||
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
|
||||
.ok_or("surface has no supported configuration")?;
|
||||
@@ -1099,7 +1205,7 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
(config, caps.present_modes.clone(), hdr::supported(&caps))
|
||||
(config, caps.present_modes.clone(), hdr::output(&caps))
|
||||
} else {
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
@@ -1113,7 +1219,7 @@ impl Renderer {
|
||||
view_formats: vec![format],
|
||||
color_space: wgpu::SurfaceColorSpace::default(),
|
||||
};
|
||||
(config, Vec::new(), false)
|
||||
(config, Vec::new(), None)
|
||||
};
|
||||
let offscreen = surface
|
||||
.is_none()
|
||||
@@ -1572,7 +1678,68 @@ impl Renderer {
|
||||
atlas_pixels,
|
||||
);
|
||||
let atlas_view = atlas.create_view(&Default::default());
|
||||
let mut hud = HudRenderer::new(&device, config.format.remove_srgb_suffix(), &atlas_view)?;
|
||||
// vanilla's `GlintSampler`: enchanted_glint_item as its own texture,
|
||||
// repeated per pixel (`clamp: false`), linear without mipmaps
|
||||
// (`blur: true`).
|
||||
let (glint_pixels, glint_width, glint_height) = glint;
|
||||
let glint_texture = device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Enchanted glint"),
|
||||
size: wgpu::Extent3d {
|
||||
width: glint_width,
|
||||
height: glint_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,
|
||||
glint_pixels,
|
||||
);
|
||||
let glint_view = glint_texture.create_view(&Default::default());
|
||||
let glint_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Glint"),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
address_mode_v: wgpu::AddressMode::Repeat,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
// ARMOR_ENTITY_GLINT_TEXTURING's sampler is vanilla's GlintSampler: the
|
||||
// same repeat and linear filtering as the item glint.
|
||||
let (glint_armor_pixels, glint_armor_width, glint_armor_height) = glint_armor;
|
||||
let glint_armor_texture = device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Enchanted armor glint"),
|
||||
size: wgpu::Extent3d {
|
||||
width: glint_armor_width,
|
||||
height: glint_armor_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,
|
||||
glint_armor_pixels,
|
||||
);
|
||||
let glint_armor_view = glint_armor_texture.create_view(&Default::default());
|
||||
let mut hud = HudRenderer::new(
|
||||
&device,
|
||||
config.format.remove_srgb_suffix(),
|
||||
&atlas_view,
|
||||
&glint_view,
|
||||
&glint_sampler,
|
||||
)?;
|
||||
hud.atlas_texture = Some(atlas.clone());
|
||||
let entity_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon entities"),
|
||||
@@ -1618,6 +1785,29 @@ impl Renderer {
|
||||
vf,
|
||||
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
),
|
||||
entry(
|
||||
5,
|
||||
vf,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
),
|
||||
entry(
|
||||
6,
|
||||
vf,
|
||||
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
),
|
||||
entry(
|
||||
7,
|
||||
vf,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
let weather = weather::Weather::new(
|
||||
@@ -1654,6 +1844,18 @@ impl Renderer {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&lightmap_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::TextureView(&glint_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::Sampler(&glint_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&glint_armor_view),
|
||||
},
|
||||
],
|
||||
});
|
||||
let hand_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -1680,6 +1882,18 @@ impl Renderer {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&lightmap_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::TextureView(&glint_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::Sampler(&glint_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&glint_armor_view),
|
||||
},
|
||||
],
|
||||
});
|
||||
let entity_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
@@ -1724,7 +1938,7 @@ impl Renderer {
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4];
|
||||
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let entity_vertex = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
@@ -1818,19 +2032,21 @@ impl Renderer {
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.ENTITY_SHADOW: translucent, depth test without
|
||||
// writes. The quads lie on the block tops like vanilla's, but the
|
||||
// terrain under them snaps to the subpixel grid at other vertices, so
|
||||
// at a slant their depths differ by up to 1/256 of a pixel's depth
|
||||
// slope and whole shadows failed the test. The bias covers that and
|
||||
// the rounding, and stays below the 0.001 a model's feet sit higher
|
||||
// (1/27 of a pixel's slope at the 16-block shadow range in 720p).
|
||||
// writes. The quads lie exactly on the block tops like vanilla's, but
|
||||
// terrain and shadows are separate draw paths, so their fp32 depth
|
||||
// interpolations disagree by a few dozen ulps of the fragment depth
|
||||
// and the shadow's rim z-fights with the block top (speckle that
|
||||
// re-rolls as the camera drifts a subpixel). A constant bias of 128
|
||||
// ulps covers that spread at any resolution while staying far below
|
||||
// the 0.001 a model's feet sit higher, so shadows never bleed onto
|
||||
// the feet.
|
||||
let shadow_blob_pipeline = entity_pipeline(
|
||||
"Entity shadows",
|
||||
None,
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
wgpu::DepthBiasState {
|
||||
constant: 16,
|
||||
constant: 128,
|
||||
slope_scale: 0.015,
|
||||
clamp: 0.0,
|
||||
},
|
||||
@@ -1865,7 +2081,8 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let lightning_attributes = wgpu::vertex_attr_array![6=>Float32x3];
|
||||
// Attribute 7: 6 is the entity vertex buffer's glint UV.
|
||||
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let lightning_vertex = [
|
||||
entity_vertex[0].clone(),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
@@ -1953,22 +2170,23 @@ impl Renderer {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &entity_shader,
|
||||
entry_point: Some("fs_celestial"),
|
||||
entry_point: Some("fs_sky_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
// RenderPipelines.CELESTIAL: additive (src alpha, one). The
|
||||
// alpha goes to 0: HDR output (hdr.wgsl) lets the sun and
|
||||
// moon shine; nothing else reads the frame's alpha.
|
||||
// RenderPipelines.CELESTIAL: additive (src alpha, one), with
|
||||
// the colour premultiplied in fs_sky_celestial. Its alpha is
|
||||
// how much a texel adds: HDR output (hdr.wgsl) lets the sun's
|
||||
// and moon's discs shine; nothing else reads the frame's alpha.
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
@@ -2348,6 +2566,9 @@ impl Renderer {
|
||||
last_wait: std::time::Duration::ZERO,
|
||||
last_encode: std::time::Duration::ZERO,
|
||||
window,
|
||||
captures: std::collections::VecDeque::new(),
|
||||
capture_pool: Vec::new(),
|
||||
capture_frames: false,
|
||||
device,
|
||||
queue,
|
||||
config,
|
||||
@@ -2456,7 +2677,7 @@ impl Renderer {
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
world_post,
|
||||
hdr: None,
|
||||
hdr_capable,
|
||||
hdr_output,
|
||||
pack_post: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
@@ -2538,7 +2759,13 @@ impl Renderer {
|
||||
self.surface = None;
|
||||
let surface = match spare {
|
||||
Some(surface) => surface,
|
||||
None => self.instance.create_surface(self.window.clone())?,
|
||||
None => {
|
||||
let window = self
|
||||
.window
|
||||
.clone()
|
||||
.ok_or("a headless renderer has no window")?;
|
||||
self.instance.create_surface(window)?
|
||||
}
|
||||
};
|
||||
Self::configure(
|
||||
&self.device,
|
||||
@@ -2623,6 +2850,8 @@ impl Renderer {
|
||||
}
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
self.capture_pool.clear();
|
||||
self.captures.clear();
|
||||
if let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
&self.device,
|
||||
@@ -2990,24 +3219,24 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
/// scRGB while HDR output is on.
|
||||
/// HDR10 or scRGB while HDR output is on.
|
||||
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
|
||||
let mut config = self.config.clone();
|
||||
if self.hdr.is_some() {
|
||||
config.format = hdr::FORMAT;
|
||||
config.view_formats = vec![hdr::FORMAT];
|
||||
config.color_space = hdr::COLOR_SPACE;
|
||||
if let Some(hdr) = &self.hdr {
|
||||
config.format = hdr.output.format();
|
||||
config.view_formats = vec![hdr.output.format()];
|
||||
config.color_space = hdr.output.color_space();
|
||||
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
|
||||
}
|
||||
config
|
||||
}
|
||||
/// HDR output (Horizon setting): scRGB at `paper_white` nits, the world's
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) {
|
||||
let on = on && self.hdr_capable && self.surface.is_some();
|
||||
if on != self.hdr.is_some() {
|
||||
self.hdr =
|
||||
on.then(|| hdr::Hdr::new(&self.device, self.config.format.remove_srgb_suffix()));
|
||||
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
|
||||
if output.is_some() != self.hdr.is_some() {
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.configure_surface();
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
@@ -3390,7 +3619,7 @@ impl Renderer {
|
||||
if self.animated_textures {
|
||||
self.animate(&mut encoder, frame.ticks);
|
||||
self.hud
|
||||
.animate_fire(&mut self.uploads, &mut encoder, frame.ticks);
|
||||
.animate_sprites(&mut self.uploads, &mut encoder, frame.ticks);
|
||||
}
|
||||
self.update_lightmap(
|
||||
&mut encoder,
|
||||
@@ -3475,7 +3704,7 @@ impl Renderer {
|
||||
frame.fog.map_or(frame.distance.min(512.0), |f| f.sky_end),
|
||||
frame.fog.map_or(2048.0, |f| f.cloud_end),
|
||||
if frame.dark_disc { 1.0 } else { 0.0 },
|
||||
0.0,
|
||||
frame.glint_alpha,
|
||||
],
|
||||
end_portal_sprites: frame.end_portal_sprites,
|
||||
crumbling_sprite: frame.crumbling_sprite,
|
||||
@@ -3483,7 +3712,7 @@ impl Renderer {
|
||||
frame.shader_game_time,
|
||||
f32::from(frame.nether_lighting),
|
||||
frame.dynamic_lights.len().min(crate::dynamic_light::MAX) as f32,
|
||||
0.0,
|
||||
frame.glint_ticks,
|
||||
],
|
||||
outline_params: outline_params(self.config.width, self.config.height),
|
||||
dynamic_lights: {
|
||||
@@ -4924,6 +5153,19 @@ impl Renderer {
|
||||
None
|
||||
}
|
||||
});
|
||||
let capture = (self.capture_frames && self.config.format.block_copy_size(None) == Some(4))
|
||||
.then(|| {
|
||||
let buffer = self.capture_pool.pop().unwrap_or_else(|| {
|
||||
self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon frame capture"),
|
||||
size: u64::from(self.padded_row()) * u64::from(self.config.height),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
});
|
||||
self.copy_frame_into(&mut encoder, &target_texture, &buffer);
|
||||
buffer
|
||||
});
|
||||
self.uploads.finish(&encoder);
|
||||
if has_section_copies {
|
||||
self.section_copy_bytes = 0;
|
||||
@@ -4949,6 +5191,17 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some(buffer) = capture {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
});
|
||||
self.captures.push_back(Capture {
|
||||
buffer,
|
||||
submission: submission.clone(),
|
||||
mapped: receiver,
|
||||
});
|
||||
}
|
||||
if let Some((path, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
@@ -4960,8 +5213,10 @@ impl Renderer {
|
||||
// On Wayland this ties RedrawRequested to the compositor's frame
|
||||
// callbacks, one per refresh. Only VSync (Fifo) waits for the
|
||||
// display: with it off, vanilla's window draws uncapped.
|
||||
if self.config.present_mode == wgpu::PresentMode::Fifo {
|
||||
self.window.pre_present_notify();
|
||||
if self.config.present_mode == wgpu::PresentMode::Fifo
|
||||
&& let Some(window) = &self.window
|
||||
{
|
||||
window.pre_present_notify();
|
||||
}
|
||||
if first_frame {
|
||||
startup_step(format_args!("presenting the first frame"));
|
||||
@@ -5010,10 +5265,67 @@ impl Renderer {
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.copy_frame_into(encoder, texture, &buffer);
|
||||
buffer
|
||||
}
|
||||
/// Frames rendered with `capture_frames` on and not read yet.
|
||||
pub fn pending_captures(&self) -> usize {
|
||||
self.captures.len()
|
||||
}
|
||||
/// The pixel layout `read_capture` writes: `Bgra8Unorm` or `Rgba8Unorm`.
|
||||
pub fn capture_format(&self) -> wgpu::TextureFormat {
|
||||
self.config.format.remove_srgb_suffix()
|
||||
}
|
||||
/// The oldest captured frame, tightly packed (width * 4 bytes a row) in
|
||||
/// `capture_format`'s order, waiting only for that frame's submission:
|
||||
/// later frames keep drawing meanwhile. `None` when nothing is pending.
|
||||
pub fn read_capture(&mut self, out: &mut Vec<u8>) -> Result<Option<[u32; 2]>> {
|
||||
let Some(capture) = self.captures.pop_front() else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.device
|
||||
.poll(wgpu::PollType::Wait {
|
||||
submission_index: Some(capture.submission),
|
||||
timeout: None,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
capture
|
||||
.mapped
|
||||
.recv()
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (width, height, row) = (
|
||||
self.config.width as usize,
|
||||
self.config.height as usize,
|
||||
self.padded_row() as usize,
|
||||
);
|
||||
out.clear();
|
||||
out.reserve(width * height * 4);
|
||||
{
|
||||
let data = capture
|
||||
.buffer
|
||||
.get_mapped_range(..)
|
||||
.map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
out.extend_from_slice(&data[y * row..y * row + width * 4]);
|
||||
}
|
||||
}
|
||||
capture.buffer.unmap();
|
||||
if capture.buffer.size() == u64::from(self.padded_row()) * u64::from(self.config.height) {
|
||||
self.capture_pool.push(capture.buffer);
|
||||
}
|
||||
Ok(Some([width as u32, height as u32]))
|
||||
}
|
||||
fn copy_frame_into(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
buffer: &wgpu::Buffer,
|
||||
) {
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(self.padded_row()),
|
||||
@@ -5026,7 +5338,6 @@ impl Renderer {
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
buffer
|
||||
}
|
||||
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
|
||||
if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
@@ -5222,6 +5533,7 @@ mod tests {
|
||||
color: 0xffff_ffff,
|
||||
light: id,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0, 0.0],
|
||||
}; 4]
|
||||
};
|
||||
let entities: Vec<_> = [
|
||||
|
||||
@@ -72,6 +72,8 @@ fn vertices(uv: [f32; 4]) -> Vec<Vertex> {
|
||||
layer: 2,
|
||||
color: 0xFF28_2828,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,15 @@ fn geometry_matches_pre_residency_vertex_snapshots() {
|
||||
for (uv, expected) in [
|
||||
(
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
"f7ee23457fc557b33ecf09c9afaf858f3d8907e108abf06055f627523f733536",
|
||||
"82a9be3f3ed1f733017df2c81dc6dff163f67bf739c768e3fd0f90f00c7802f6",
|
||||
),
|
||||
(
|
||||
[0.125, 0.25, 0.375, 0.5],
|
||||
"f74c4b8d23ec2b7c11b5752821d0733851bf11358f1b26f9448226ff62a6a318",
|
||||
"05a058bd8e70cff900c8e1c81061892ed27866d86498ebe1e8ae5af4c3b770ef",
|
||||
),
|
||||
(
|
||||
[0.13, 0.27, 0.41, 0.59],
|
||||
"f0aaa39c5b22168bdc7c6c329acb4d3598a2feb61ff7b6f2850b644682749b39",
|
||||
"93064b9fd2e745da46b927b2625edc8a381bb8c6e20b0f0880e816c4c1745ec1",
|
||||
),
|
||||
] {
|
||||
let mut mesh = vertices(uv);
|
||||
@@ -31,10 +31,8 @@ fn geometry_matches_pre_residency_vertex_snapshots() {
|
||||
if n == 0.0 { 0.0 } else { n }
|
||||
});
|
||||
}
|
||||
assert_eq!(
|
||||
format!("{:x}", Sha256::digest(bytemuck::cast_slice(&mesh))),
|
||||
expected
|
||||
);
|
||||
let hash = format!("{:x}", Sha256::digest(bytemuck::cast_slice(&mesh)));
|
||||
assert_eq!(hash, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +93,7 @@ fn separate_draws_match_combined_celestial_pixels() {
|
||||
label: Some("End sky differential shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()),
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4];
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("End sky differential pipeline"),
|
||||
layout: None,
|
||||
@@ -305,6 +303,8 @@ fn separate_draws_match_combined_celestial_pixels() {
|
||||
color: level * 0x01010101,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
})
|
||||
.to_vec()
|
||||
};
|
||||
|
||||
@@ -130,6 +130,8 @@ mod tests {
|
||||
color: index as u32,
|
||||
light: (index * 3) as u32,
|
||||
normal: [1, 2, 3, 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
//! HDR output (Horizon setting, off by default): the frame renders exactly as
|
||||
//! without it, into an 8-bit target in vanilla's gamma space, and one pass
|
||||
//! (`hdr.wgsl`) writes it to an scRGB swapchain at the player's paper white,
|
||||
//! expanding the world's highlights towards their peak brightness. The world
|
||||
//! is copied aside before the hand and HUD so only it is expanded.
|
||||
//! (`hdr.wgsl`) writes it to an HDR10 or scRGB swapchain at the player's paper
|
||||
//! white, expanding the world's highlights towards their peak brightness. The
|
||||
//! world is copied aside before the hand and HUD so only it is expanded.
|
||||
|
||||
/// The swapchain format and colour space HDR output presents in.
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
pub const COLOR_SPACE: wgpu::SurfaceColorSpace = wgpu::SurfaceColorSpace::ExtendedSrgbLinear;
|
||||
/// The swapchain HDR output presents to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Output {
|
||||
/// HDR10: 10-bit BT.2020 with the PQ curve, the display's own signal.
|
||||
Pq,
|
||||
/// scRGB: linear 16-bit float, BT.709 primaries, 1.0 = 80 nits.
|
||||
ScRgb,
|
||||
}
|
||||
|
||||
/// Whether `caps` can present scRGB.
|
||||
pub fn supported(caps: &wgpu::SurfaceCapabilities) -> bool {
|
||||
caps.color_spaces(FORMAT)
|
||||
.contains(wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR)
|
||||
impl Output {
|
||||
pub fn format(self) -> wgpu::TextureFormat {
|
||||
match self {
|
||||
Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm,
|
||||
Self::ScRgb => wgpu::TextureFormat::Rgba16Float,
|
||||
}
|
||||
}
|
||||
pub fn color_space(self) -> wgpu::SurfaceColorSpace {
|
||||
match self {
|
||||
Self::Pq => wgpu::SurfaceColorSpace::Bt2100Pq,
|
||||
Self::ScRgb => wgpu::SurfaceColorSpace::ExtendedSrgbLinear,
|
||||
}
|
||||
}
|
||||
fn space(self) -> wgpu::SurfaceColorSpaces {
|
||||
match self {
|
||||
Self::Pq => wgpu::SurfaceColorSpaces::BT2100_PQ,
|
||||
Self::ScRgb => wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The HDR output `caps` can present: HDR10 where offered, else scRGB.
|
||||
/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one.
|
||||
pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option<Output> {
|
||||
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
|
||||
}
|
||||
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
|
||||
[(Output::Pq, "pq"), (Output::ScRgb, "scrgb")]
|
||||
.into_iter()
|
||||
.filter(|(_, name)| only.is_none_or(|o| o == *name))
|
||||
.map(|(output, _)| output)
|
||||
.find(|o| caps.color_spaces(o.format()).contains(o.space()))
|
||||
}
|
||||
|
||||
struct Targets {
|
||||
@@ -25,6 +58,7 @@ pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
format: wgpu::TextureFormat,
|
||||
targets: Option<Targets>,
|
||||
@@ -36,7 +70,7 @@ pub struct Hdr {
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
|
||||
@@ -90,7 +124,7 @@ impl Hdr {
|
||||
entry_point: Some("fs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: FORMAT,
|
||||
format: output.format(),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -100,7 +134,7 @@ impl Hdr {
|
||||
});
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 16,
|
||||
size: 32,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -108,6 +142,7 @@ impl Hdr {
|
||||
layout,
|
||||
pipeline,
|
||||
params,
|
||||
output,
|
||||
format,
|
||||
targets: None,
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
@@ -198,6 +233,10 @@ impl Hdr {
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
];
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
let view = target.create_view(&Default::default());
|
||||
@@ -220,3 +259,37 @@ impl Hdr {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities {
|
||||
wgpu::SurfaceCapabilities {
|
||||
format_capabilities: offered
|
||||
.iter()
|
||||
.map(|&(format, color_spaces)| wgpu::SurfaceFormatCapabilities {
|
||||
format,
|
||||
color_spaces,
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr10_first_then_scrgb() {
|
||||
let both = caps(&[
|
||||
(F::Bgra8UnormSrgb, S::SRGB),
|
||||
(F::Rgb10a2Unorm, S::BT2100_PQ | S::BT2100_HLG),
|
||||
(F::Rgba16Float, S::EXTENDED_SRGB_LINEAR),
|
||||
]);
|
||||
assert_eq!(pick(&both, None), Some(Output::Pq));
|
||||
assert_eq!(pick(&both, Some("scrgb")), Some(Output::ScRgb));
|
||||
let scrgb = caps(&[(F::Rgba16Float, S::EXTENDED_SRGB_LINEAR)]);
|
||||
assert_eq!(pick(&scrgb, None), Some(Output::ScRgb));
|
||||
assert_eq!(pick(&scrgb, Some("pq")), None);
|
||||
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use super::{Frame, Renderer, lightning, panorama};
|
||||
use crate::{environment::Sky, hud::HudBatch, weather};
|
||||
use glam::{DVec3, Mat4};
|
||||
|
||||
/// A windowless renderer draws into its offscreen target and reads every frame back, oldest
|
||||
/// first, as tightly packed rows. No game files: one white sprite and an empty world, so the
|
||||
/// frame is the sky's clear colour.
|
||||
#[test]
|
||||
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
|
||||
fn headless_frames_are_read_back_in_order() {
|
||||
let white = [255u8; 16 * 16 * 4];
|
||||
let panorama = panorama::Faces {
|
||||
size: 1,
|
||||
pixels: [0u8; 4].repeat(6),
|
||||
};
|
||||
let mut renderer = pollster::block_on(Renderer::new_headless(
|
||||
64,
|
||||
64,
|
||||
&white,
|
||||
16,
|
||||
16,
|
||||
1,
|
||||
&[],
|
||||
&[],
|
||||
(&white, 16, 16),
|
||||
(&[0, 0, 0, 0], 1, 1),
|
||||
(&[0, 0, 0, 0], 1, 1),
|
||||
(&[0, 0, 0, 0], 1),
|
||||
&panorama,
|
||||
None,
|
||||
Default::default(),
|
||||
))
|
||||
.unwrap();
|
||||
renderer.capture_frames = true;
|
||||
let hud = HudBatch::new([64, 64]);
|
||||
let frame = |sky_color: [f32; 3]| Frame {
|
||||
camera: DVec3::new(0.5, 100.0, 0.5),
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
fov_degrees: 70.0,
|
||||
bob: Mat4::IDENTITY,
|
||||
sky: Sky {
|
||||
sky_color,
|
||||
fog_color: sky_color,
|
||||
sky_light_factor: 1.0,
|
||||
sky_light_color: [1.0; 3],
|
||||
..Default::default()
|
||||
},
|
||||
distance: 32.0,
|
||||
detail_distance: 32.0,
|
||||
depth_far: 64.0,
|
||||
underwater: false,
|
||||
cave_culling: false,
|
||||
title: false,
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
dark_disc: false,
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud: &hud,
|
||||
glint_alpha: 1.0,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
entity_spans: &[],
|
||||
weather: &[],
|
||||
weather_time: weather::Time::new(0, 0.0),
|
||||
dynamic_lights: &[],
|
||||
clouds: None,
|
||||
lightning: lightning::Input {
|
||||
bolts: &[],
|
||||
uv: [0.0; 2],
|
||||
},
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
border: None,
|
||||
game_time: 0.0,
|
||||
shader_game_time: 0.0,
|
||||
end_portal_sprites: [[0.0; 4]; 2],
|
||||
crumbling_sprite: [0.0; 4],
|
||||
ticks: 0,
|
||||
darkness: (0.0, 0.0),
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
boss_darken: 0.0,
|
||||
menu_blur: None,
|
||||
};
|
||||
let (red, blue) = ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
|
||||
renderer.render(&frame(red)).unwrap();
|
||||
renderer.render(&frame(blue)).unwrap();
|
||||
assert_eq!(renderer.pending_captures(), 2);
|
||||
// Red and blue sit at different bytes in either layout.
|
||||
let (r, b) = match renderer.capture_format() {
|
||||
wgpu::TextureFormat::Bgra8Unorm => (2, 0),
|
||||
wgpu::TextureFormat::Rgba8Unorm => (0, 2),
|
||||
format => panic!("unexpected capture format {format:?}"),
|
||||
};
|
||||
let mut pixels = Vec::new();
|
||||
for (name, (strong, weak)) in [("red", (r, b)), ("blue", (b, r))] {
|
||||
let size = renderer.read_capture(&mut pixels).unwrap();
|
||||
assert_eq!(size, Some([64, 64]));
|
||||
assert_eq!(pixels.len(), 64 * 64 * 4);
|
||||
let center = &pixels[(32 * 64 + 32) * 4..][..4];
|
||||
assert!(
|
||||
center[strong] > 200 && center[weak] < 50,
|
||||
"{name} frame's centre is {center:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(renderer.pending_captures(), 0);
|
||||
assert_eq!(renderer.read_capture(&mut pixels).unwrap(), None);
|
||||
}
|
||||
@@ -77,6 +77,8 @@ fn geometry(seed: i64, uv: [f32; 2]) -> Vec<Vertex> {
|
||||
layer: 2,
|
||||
color,
|
||||
light: 240 | 240 << 8,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ fn reference(seed: i64, at: [f64; 3], camera: DVec3, uv: [f32; 2]) -> Vec<Vertex
|
||||
layer: 2,
|
||||
color,
|
||||
light: 240 | 240 << 8,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -225,8 +227,9 @@ fn retained_gpu_draw_matches_transient_additive_draw() {
|
||||
label: Some("Lightning differential shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()),
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4];
|
||||
let offsets = wgpu::vertex_attr_array![6=>Float32x3];
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
// Attribute 7: 6 is the entity vertex buffer's glint UV.
|
||||
let offsets = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let vertices = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
|
||||
@@ -135,6 +135,8 @@ impl PackWeather {
|
||||
color: v.color,
|
||||
light: v.light,
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
}
|
||||
}));
|
||||
if self.scratch.len() > self.capacity {
|
||||
|
||||
@@ -255,7 +255,7 @@ pub struct Graphics {
|
||||
pub resolution_scale: u32,
|
||||
/// FXAA over the world.
|
||||
pub fxaa: bool,
|
||||
/// HDR output (scRGB), where the display path offers it.
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits.
|
||||
pub hdr_paper_white: u32,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Horizon's HDR output pass: the frame, drawn as without HDR in vanilla's gamma
|
||||
// space, decoded to linear light at the paper white and written to the scRGB
|
||||
// swapchain (1.0 = 80 nits). Bright pixels of light sources that still show the
|
||||
// space, decoded to linear light at the paper white and written to the swapchain:
|
||||
// HDR10 (BT.2020, PQ) or scRGB (1.0 = 80 nits). Bright pixels of light sources that still show the
|
||||
// world as it was before the hand and HUD are expanded towards the peak.
|
||||
@group(0) @binding(0) var frame: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world: texture_2d<f32>;
|
||||
@@ -14,6 +14,8 @@ struct Params {
|
||||
world: f32,
|
||||
// 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1).
|
||||
debug: f32,
|
||||
// 1 for an HDR10 swapchain, 0 for scRGB.
|
||||
pq: f32,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
|
||||
@@ -30,14 +32,28 @@ const STRENGTH: f32 = 0.7;
|
||||
|
||||
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
|
||||
// across its channels, as the vignette and other screen-wide tints darken it.
|
||||
// The hand, HUD and text replace pixels instead, so they never pass.
|
||||
// The hand, HUD and text replace pixels instead, so they never pass. The
|
||||
// channels are held to one factor, the ratio of their sums, so a channel at 0
|
||||
// stays 0; a step of 8-bit rounding is allowed on each.
|
||||
fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
if all(c == w) {
|
||||
return true;
|
||||
}
|
||||
let k = c / max(w, vec3(1.0 / 255.0));
|
||||
let mean = (k.r + k.g + k.b) / 3.0;
|
||||
return mean <= 1.01 && mean > 0.5 && all(abs(k - vec3(mean)) < vec3(0.03));
|
||||
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
|
||||
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
|
||||
}
|
||||
|
||||
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
|
||||
const BT709_TO_BT2020 = mat3x3<f32>(
|
||||
vec3(0.6274, 0.0691, 0.0164),
|
||||
vec3(0.3293, 0.9195, 0.0880),
|
||||
vec3(0.0433, 0.0114, 0.8956),
|
||||
);
|
||||
|
||||
// SMPTE ST 2084: nits (up to 10000) to the PQ signal.
|
||||
fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
let y = pow(clamp(nits / 10000.0, vec3(0.0), vec3(1.0)), vec3(0.1593017578125));
|
||||
return pow((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3(78.84375));
|
||||
}
|
||||
|
||||
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
@@ -54,7 +70,10 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine.
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
|
||||
let y = 0.5 * (dot(light, vec3(0.2126, 0.7152, 0.0722)) + max(light.r, max(light.g, light.b)));
|
||||
// Brightness of the world pixel, before the vignette darkens the frame
|
||||
// toward its edges: a light source shines the same anywhere on screen.
|
||||
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
|
||||
let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b)));
|
||||
let lift = smoothstep(LIFT_START, LIFT_FULL, y);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
@@ -65,5 +84,9 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
return vec4(light * params.paper, 1.0);
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(BT709_TO_BT2020 * scrgb * 80.0), 1.0);
|
||||
}
|
||||
return vec4(scrgb, 1.0);
|
||||
}
|
||||
@@ -860,6 +860,31 @@ mod tests {
|
||||
assert!(!third.hurt && !third.knockback);
|
||||
}
|
||||
|
||||
/// #2141: an arrow that takes the last of the player's health must kill
|
||||
/// them: `LivingEntity.hurt` leaves the health at zero and `Player.die`
|
||||
/// follows.
|
||||
#[test]
|
||||
fn an_arrow_kills_a_player_at_one_health() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let resources = crate::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let mut player =
|
||||
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
|
||||
.unwrap();
|
||||
player.set_health(1.0, false).unwrap();
|
||||
let (health, rules) = (&resources.health, super::HealthRules::default());
|
||||
let id = health.damage.id("minecraft:arrow").unwrap();
|
||||
let hurt = player.hurt(health, rules, id, 5.0).unwrap();
|
||||
assert!(hurt, "the arrow must hurt");
|
||||
assert_eq!(player.health.health(), 0.0, "the health must be spent");
|
||||
assert!(player.health.dead_or_dying(), "the player must be dead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hurt_sounds_follow_damage_effects() {
|
||||
// DamageEffects: HURT, THORNS, DROWNING, BURNING, POKING, FREEZING.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! textures, and a batch of textured quads in GUI pixels (vanilla's automatic
|
||||
//! GUI scale is applied when drawing).
|
||||
use crate::Result;
|
||||
use horizon_assets::animation::AnimationMetadata;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
@@ -64,9 +65,17 @@ pub struct HudAtlas {
|
||||
/// Sprites whose `.png.mcmeta` sets `blur` or `clamp`; the others
|
||||
/// sample as vanilla's defaults (nearest, repeat).
|
||||
pub flags: HashMap<String, TextureFlags>,
|
||||
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation)>,
|
||||
/// Every animated block or item sprite the atlas packs: its atlas rect
|
||||
/// and the mcmeta-driven frame list, re-uploaded into the rect as the
|
||||
/// frames turn (vanilla's stitched atlas animates all of these).
|
||||
pub sprite_animations: Vec<(Rect, crate::sprites::SpriteAnimation)>,
|
||||
/// The default font (bitmap sheets in this atlas, unifont rasterized on demand).
|
||||
pub font: crate::font::Font,
|
||||
/// `misc/enchanted_glint_item` as its own image: vanilla's `GlintSampler`
|
||||
/// repeats it per pixel, which one sprite inside this atlas cannot.
|
||||
pub glint: (Vec<u8>, u32, u32),
|
||||
/// `misc/enchanted_glint_armor`, the armour layers' `GlintSampler`.
|
||||
pub glint_armor: (Vec<u8>, u32, u32),
|
||||
white: Rect,
|
||||
}
|
||||
/// Atlas rows reserved for glyphs rasterized at run time.
|
||||
@@ -142,6 +151,21 @@ fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
}
|
||||
/// A packed atlas image: sprite name, size and RGBA pixels.
|
||||
type PackedImage = (String, u32, u32, Vec<u8>);
|
||||
/// A frame of an animated strip at `x, y`, nearest-resampled to the longer
|
||||
/// side of the frame: array layers are square, as `Sprites::load` keeps them
|
||||
/// for the world's sprite arrays.
|
||||
fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Vec<u8> {
|
||||
let side = w.max(h);
|
||||
let mut out = Vec::with_capacity((side * side * 4) as usize);
|
||||
for row in 0..side {
|
||||
let source_row = y + row * h / side;
|
||||
for column in 0..side {
|
||||
let at = ((source_row * stride + x + column * w / side) * 4) as usize;
|
||||
out.extend_from_slice(&pixels[at..at + 4]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `image` inside a one-texel border copied from its opposite edges, the
|
||||
/// texels repeat addressing reads past each edge.
|
||||
fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
@@ -373,7 +397,7 @@ impl HudAtlas {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
});
|
||||
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>);
|
||||
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
|
||||
let decode_one = |path: &PathBuf| -> Option<Decoded> {
|
||||
let name = path
|
||||
.strip_prefix(&textures)
|
||||
@@ -381,12 +405,35 @@ impl HudAtlas {
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let (w, mut h, pixels) = decode(path).ok()?;
|
||||
// Animated strips keep their first frame.
|
||||
let (mut w, mut h, pixels) = decode(path).ok()?;
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let animation = mcmeta
|
||||
.as_deref()
|
||||
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
|
||||
.and_then(|meta| {
|
||||
meta.get("animation")
|
||||
.and_then(|section| AnimationMetadata::from_json(§ion.to_string()).ok())
|
||||
});
|
||||
// An animated strip keeps its first frame: the region the mcmeta's
|
||||
// frame size picks (`AnimationMetadataSection.frameSize`), which is
|
||||
// the top-left square unless it names other dimensions or the
|
||||
// frames run along a row.
|
||||
let frames = name.starts_with("block/") || name.starts_with("item/");
|
||||
let pixels = if frames && h > w {
|
||||
h = w;
|
||||
pixels[..(w * w * 4) as usize].to_vec()
|
||||
let pixels = if frames {
|
||||
let stride = w;
|
||||
let [fw, fh] = animation.as_ref().map_or([w.min(h) as i32; 2], |meta| {
|
||||
meta.frame_size(w as i32, h as i32)
|
||||
});
|
||||
match (u32::try_from(fw), u32::try_from(fh)) {
|
||||
(Ok(fw), Ok(fh))
|
||||
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
|
||||
{
|
||||
let side = fw.max(fh);
|
||||
(w, h) = (side, side);
|
||||
strip_frame(&pixels, stride, 0, 0, fw, fh)
|
||||
}
|
||||
_ => pixels,
|
||||
}
|
||||
} else {
|
||||
pixels
|
||||
};
|
||||
@@ -394,16 +441,19 @@ impl HudAtlas {
|
||||
let gui_size = default_textures
|
||||
.as_ref()
|
||||
.and_then(|d| png_size(&d.join(format!("{name}.png"))))
|
||||
.map(|(dw, dh)| (dw, if frames { dh.min(dw) } else { dh }))
|
||||
.map(|(dw, dh)| {
|
||||
let side = dw.min(dh);
|
||||
if frames { (side, side) } else { (dw, dh) }
|
||||
})
|
||||
.filter(|&size| size != (w, h));
|
||||
let flags = fs::read_to_string(path.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|json| TextureFlags::from_mcmeta(&json));
|
||||
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));
|
||||
(gw <= 1024 && (gh <= 512 || name.starts_with("font/"))).then_some((
|
||||
(name, w, h, pixels),
|
||||
flags,
|
||||
gui_size,
|
||||
animated,
|
||||
))
|
||||
};
|
||||
let threads = std::thread::available_parallelism().map_or(2, |n| n.get());
|
||||
@@ -422,14 +472,18 @@ impl HudAtlas {
|
||||
});
|
||||
let mut flags = HashMap::new();
|
||||
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) in decoded {
|
||||
for (image, image_flags, gui_size, is_animated) in decoded {
|
||||
if let Some(f) = image_flags {
|
||||
flags.insert(image.0.clone(), f);
|
||||
}
|
||||
if let Some(size) = gui_size {
|
||||
gui_sizes.insert(image.0.clone(), size);
|
||||
}
|
||||
if is_animated {
|
||||
animated.push(image.0.clone());
|
||||
}
|
||||
images.push(image);
|
||||
}
|
||||
if full {
|
||||
@@ -495,37 +549,36 @@ impl HudAtlas {
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut fire_sprites = crate::sprites::Sprites::default();
|
||||
let mut fire_animations = Vec::new();
|
||||
for name in [
|
||||
"block/fire_0",
|
||||
"block/fire_1",
|
||||
"block/soul_fire_0",
|
||||
"block/soul_fire_1",
|
||||
] {
|
||||
if let Some(rect) = sprites.get(name).copied() {
|
||||
let layer = fire_sprites.insert(root, &format!("minecraft:{name}"))?;
|
||||
if let Some(animation) = fire_sprites
|
||||
.animations
|
||||
.iter()
|
||||
.find(|a| a.layer == layer)
|
||||
.cloned()
|
||||
{
|
||||
let (tw, th) = rect.texels((width, height));
|
||||
let image = animation.resized_pixels(animation.frame(0), tw, th);
|
||||
let x = (rect.u0 * width as f32).round() as usize;
|
||||
let y = (rect.v0 * height as f32).round() as usize;
|
||||
let row_bytes = tw as usize * 4;
|
||||
for row in 0..th as usize {
|
||||
let dst = ((y + row) * width as usize + x) * 4;
|
||||
let src = row * row_bytes;
|
||||
pixels[dst..dst + row_bytes].copy_from_slice(&image[src..src + row_bytes]);
|
||||
}
|
||||
fire_animations.push((rect, animation));
|
||||
}
|
||||
// Every animated sprite animates: the packed strip is the raw first
|
||||
// frame, while the mcmeta's frame list may start anywhere, so each
|
||||
// animation's frame(0) overwrites its atlas rect before drawing.
|
||||
let mut loaded = crate::sprites::Sprites::default();
|
||||
let mut sprite_animations = Vec::new();
|
||||
animated.sort();
|
||||
for name in animated {
|
||||
let Some(rect) = sprites.get(&name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let layer = loaded.insert(root, &format!("minecraft:{name}"))?;
|
||||
let Some(animation) = loaded.animations.iter().find(|a| a.layer == layer).cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let (tw, th) = rect.texels((width, height));
|
||||
let image = animation.resized_pixels(animation.frame(0), tw, th);
|
||||
let x = (rect.u0 * width as f32).round() as usize;
|
||||
let y = (rect.v0 * height as f32).round() as usize;
|
||||
let row_bytes = tw as usize * 4;
|
||||
for row in 0..th as usize {
|
||||
let dst = ((y + row) * width as usize + x) * 4;
|
||||
let src = row * row_bytes;
|
||||
pixels[dst..dst + row_bytes].copy_from_slice(&image[src..src + row_bytes]);
|
||||
}
|
||||
sprite_animations.push((rect, animation));
|
||||
}
|
||||
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");
|
||||
let font = crate::font::Font::load(
|
||||
root,
|
||||
&sprites,
|
||||
@@ -541,11 +594,23 @@ impl HudAtlas {
|
||||
sprites,
|
||||
translucent,
|
||||
flags,
|
||||
fire_animations,
|
||||
sprite_animations,
|
||||
glint,
|
||||
glint_armor,
|
||||
font,
|
||||
white,
|
||||
})
|
||||
}
|
||||
/// `misc/<name>.png` on its own, or a transparent texel if the resource
|
||||
/// pack lacks it.
|
||||
fn glint_texture(root: &Path, defaults: &Path, name: &str) -> (Vec<u8>, u32, u32) {
|
||||
let relative = format!("assets/minecraft/textures/misc/{name}.png");
|
||||
let mut path = root.join(&relative);
|
||||
if !path.exists() && defaults != root {
|
||||
path = defaults.join(&relative);
|
||||
}
|
||||
decode(&path).map_or((vec![0; 4], 1, 1), |(w, h, pixels)| (pixels, w, h))
|
||||
}
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
@@ -554,6 +619,9 @@ pub struct HudVertex {
|
||||
pub uv: [f32; 2],
|
||||
pub color: u32,
|
||||
pub depth: f32,
|
||||
/// The `GLINT_TEXTURING` UV into the repeating glint texture, unused
|
||||
/// unless `uv.x >= 2`.
|
||||
pub glint_uv: [f32; 2],
|
||||
}
|
||||
/// The GUI Scale option (0 = auto).
|
||||
pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
@@ -569,6 +637,8 @@ pub struct HudBatch {
|
||||
pub blends: Vec<(std::ops::Range<u32>, Blend)>,
|
||||
/// The textures and time of the `Blend::EndPortal` runs.
|
||||
pub end_portal: EndPortalFill,
|
||||
/// `options.glintStrength` for the glint pass (`GlintAlpha`).
|
||||
pub glint_alpha: f32,
|
||||
/// Screen's first vertex: the preceding HUD is part of its blurred background.
|
||||
pub blur_at: u32,
|
||||
}
|
||||
@@ -620,6 +690,7 @@ impl HudBatch {
|
||||
patches: Vec::new(),
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
blur_at: 0,
|
||||
}
|
||||
}
|
||||
@@ -695,6 +766,26 @@ impl HudBatch {
|
||||
uv: uv[i],
|
||||
color,
|
||||
depth: 0.0,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
/// A quad whose texture is the `GLINT_TEXTURING` sample at `glint_uv`;
|
||||
/// `uv` is the item's atlas UV, which the shader masks by.
|
||||
pub fn quad_glint(
|
||||
&mut self,
|
||||
p: [[f32; 2]; 4],
|
||||
uv: [[f32; 2]; 4],
|
||||
glint_uv: [[f32; 2]; 4],
|
||||
color: u32,
|
||||
) {
|
||||
for i in [0, 1, 2, 0, 2, 3] {
|
||||
self.vertices.push(HudVertex {
|
||||
pos: p[i],
|
||||
uv: uv[i],
|
||||
color,
|
||||
depth: 0.0,
|
||||
glint_uv: glint_uv[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -705,6 +796,7 @@ impl HudBatch {
|
||||
uv: uv[i],
|
||||
color,
|
||||
depth: p[i][2],
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -875,6 +967,7 @@ impl HudBatch {
|
||||
uv,
|
||||
color: c[i],
|
||||
depth: 0.0,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1033,7 +1126,7 @@ impl HudUniform {
|
||||
}
|
||||
}
|
||||
pub struct HudRenderer {
|
||||
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation, usize)>,
|
||||
pub sprite_animations: Vec<(Rect, crate::sprites::SpriteAnimation, (usize, usize, u32))>,
|
||||
/// The shared atlas texture, for run-time glyph uploads.
|
||||
pub atlas_texture: Option<wgpu::Texture>,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
@@ -1053,6 +1146,8 @@ impl HudRenderer {
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
view: &wgpu::TextureView,
|
||||
glint_view: &wgpu::TextureView,
|
||||
glint_sampler: &wgpu::Sampler,
|
||||
) -> Result<Self> {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HUD"),
|
||||
@@ -1097,6 +1192,22 @@ impl HudRenderer {
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -1115,6 +1226,14 @@ impl HudRenderer {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(glint_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(glint_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
@@ -1122,7 +1241,8 @@ impl HudRenderer {
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32];
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
|
||||
let create = |label, blend, fragment_entry, depth: bool| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
@@ -1235,7 +1355,7 @@ impl HudRenderer {
|
||||
});
|
||||
Ok(Self {
|
||||
atlas_texture: None,
|
||||
fire_animations: Vec::new(),
|
||||
sprite_animations: Vec::new(),
|
||||
pipeline,
|
||||
invert_pipeline,
|
||||
additive_pipeline,
|
||||
@@ -1248,7 +1368,7 @@ impl HudRenderer {
|
||||
capacity,
|
||||
})
|
||||
}
|
||||
pub fn animate_fire(
|
||||
pub fn animate_sprites(
|
||||
&mut self,
|
||||
uploads: &mut crate::gfx::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
@@ -1257,13 +1377,31 @@ impl HudRenderer {
|
||||
let Some(texture) = &self.atlas_texture else {
|
||||
return;
|
||||
};
|
||||
for (rect, animation, previous) in &mut self.fire_animations {
|
||||
let frame = animation.frame(tick);
|
||||
if frame == *previous {
|
||||
for (rect, animation, previous) in &mut self.sprite_animations {
|
||||
// `SpriteContents.AnimationState`: the frame from the mcmeta's
|
||||
// list, blended into the next one by the sub-frame's thousandths
|
||||
// when the sprite interpolates.
|
||||
let blend = animation.blend(tick);
|
||||
let state = (blend.frame, blend.next, blend.millis);
|
||||
if state == *previous {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = rect.texels((texture.width(), texture.height()));
|
||||
let pixels = animation.resized_pixels(frame, w, h);
|
||||
let pixels = if blend.millis == 0 {
|
||||
animation.resized_pixels(blend.frame, w, h)
|
||||
} else {
|
||||
let current = animation.resized_pixels(blend.frame, w, h);
|
||||
let next = animation.resized_pixels(blend.next, w, h);
|
||||
let millis = blend.millis;
|
||||
current
|
||||
.iter()
|
||||
.zip(next)
|
||||
.map(|(&a, b)| {
|
||||
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000)
|
||||
as u8
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
@@ -1280,7 +1418,7 @@ impl HudRenderer {
|
||||
h,
|
||||
&pixels,
|
||||
);
|
||||
*previous = frame;
|
||||
*previous = state;
|
||||
}
|
||||
}
|
||||
/// Stage the batch's atlas patches, vertices and uniform: before the
|
||||
@@ -1337,7 +1475,12 @@ impl HudRenderer {
|
||||
&self.uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&HudUniform::new(
|
||||
[size[0] as f32, size[1] as f32, batch.scale, 0.0],
|
||||
[
|
||||
size[0] as f32,
|
||||
size[1] as f32,
|
||||
batch.scale,
|
||||
batch.glint_alpha,
|
||||
],
|
||||
batch.end_portal,
|
||||
)),
|
||||
);
|
||||
@@ -1441,6 +1584,80 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Write a PNG `width x height` whose `frames` stacked rows are solid
|
||||
/// colours, then its mcmeta beside it.
|
||||
fn write_strip(path: &Path, width: u32, height: u32, frames: &[[u8; 4]], mcmeta: Option<&str>) {
|
||||
let file = std::io::BufWriter::new(fs::File::create(path).unwrap());
|
||||
let mut encoder = png::Encoder::new(file, width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
let mut pixels = Vec::with_capacity((width * height * 4) as usize);
|
||||
for frame in frames {
|
||||
let rows = height / frames.len() as u32;
|
||||
pixels.extend((0..width * rows).flat_map(|_| *frame));
|
||||
}
|
||||
writer.write_image_data(&pixels).unwrap();
|
||||
writer.finish().unwrap();
|
||||
if let Some(mcmeta) = mcmeta {
|
||||
let meta = path.with_extension("png.mcmeta");
|
||||
fs::write(meta, mcmeta).unwrap();
|
||||
}
|
||||
}
|
||||
/// A resource root holding one animated block texture: a strip of two
|
||||
/// 16x16 frames, red then blue, whose mcmeta starts on the blue one.
|
||||
fn animated_root() -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("horizon-atlas-anim-{}", std::process::id()));
|
||||
let block = root.join("assets/minecraft/textures/block");
|
||||
fs::create_dir_all(&block).unwrap();
|
||||
write_strip(
|
||||
&block.join("test_anim.png"),
|
||||
16,
|
||||
32,
|
||||
&[[255, 0, 0, 255], [0, 0, 255, 255]],
|
||||
Some(r#"{"animation": {"frames": [1, 0]}}"#),
|
||||
);
|
||||
root
|
||||
}
|
||||
fn texel_at(atlas: &HudAtlas, rect: Rect, x: u32, y: u32) -> [u8; 4] {
|
||||
let px = (rect.u0 * atlas.width as f32).round() as u32 + x;
|
||||
let py = (rect.v0 * atlas.height as f32).round() as u32 + y;
|
||||
let at = ((py * atlas.width + px) * 4) as usize;
|
||||
atlas.pixels[at..at + 4].try_into().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn animated_block_sprites_register_and_pack_their_first_mcmeta_frame() {
|
||||
let root = animated_root();
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let (rect, animation) = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.find(|(_, animation)| animation.frame_count() == 2)
|
||||
.expect("the animated block sprite");
|
||||
// The mcmeta's list starts on the strip's second (blue) frame.
|
||||
assert_eq!(animation.frame(0), 1);
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, *rect, 0, 0), [0, 0, 255, 255]);
|
||||
assert_eq!(texel_at(&atlas, *rect, 15, 15), [0, 0, 255, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn a_strip_frames_size_comes_from_its_mcmeta() {
|
||||
let root = animated_root();
|
||||
let block = root.join("assets/minecraft/textures/block");
|
||||
// A row of two frames with the animation's own frame size.
|
||||
write_strip(
|
||||
&block.join("test_row.png"),
|
||||
32,
|
||||
16,
|
||||
&[[0, 255, 0, 255], [255, 255, 0, 255]],
|
||||
Some(r#"{"animation": {"width": 16, "height": 16}}"#),
|
||||
);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["block/test_row"];
|
||||
// The first frame is the left 16x16, not the whole row.
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
|
||||
}
|
||||
/// `count` images of `w x h`; layout reads only their sizes.
|
||||
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
|
||||
(0..count)
|
||||
@@ -1787,6 +2004,7 @@ mod tests {
|
||||
patches: Vec::new(),
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
blur_at: 0,
|
||||
};
|
||||
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
|
||||
|
||||
@@ -7,28 +7,35 @@ struct Hud {
|
||||
@group(0) @binding(0) var<uniform> hud: Hud;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
|
||||
@group(0) @binding(3) var glint_tex: texture_2d<f32>;
|
||||
@group(0) @binding(4) var glint_sampler: sampler;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
// projection_from_position(gl_Position): the End portal's texProj0.
|
||||
@location(2) proj: vec4<f32>,
|
||||
@location(3) glint_uv: vec2<f32>,
|
||||
}
|
||||
@vertex fn vs_main(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>, @location(2) color: vec4<f32>, @location(3) depth: f32) -> Out {
|
||||
@vertex fn vs_main(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>, @location(2) color: vec4<f32>, @location(3) depth: f32, @location(4) glint_uv: vec2<f32>) -> Out {
|
||||
let pixels = pos * hud.size.z;
|
||||
var out: Out;
|
||||
out.clip = vec4(pixels.x / hud.size.x * 2.0 - 1.0, 1.0 - pixels.y / hud.size.y * 2.0, depth, 1.0);
|
||||
out.uv = uv;
|
||||
out.color = color;
|
||||
out.proj = vec4(out.clip.xy * 0.5 + 0.5, out.clip.zw);
|
||||
out.glint_uv = glint_uv;
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_main(in: Out) -> @location(0) vec4<f32> {
|
||||
// u >= 2: flat vertex colour masked by the texture's alpha (enchantment glint).
|
||||
// u >= 2: the GLINT_TEXTURING sample over the item, masked by the item's
|
||||
// texture alpha (item.fsh's ALPHA_CUTOUT and GLINT path).
|
||||
if in.uv.x >= 2.0 {
|
||||
let m = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a * in.color.a;
|
||||
if m < 0.004 { discard; }
|
||||
return vec4(in.color.rgb, m);
|
||||
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a;
|
||||
if mask < 0.1 { discard; }
|
||||
let glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
|
||||
return vec4(glint.rgb * glint.rgb, mask);
|
||||
}
|
||||
let c = textureSample(atlas, atlas_sampler, in.uv) * in.color;
|
||||
if c.a < 0.004 { discard; }
|
||||
|
||||
@@ -214,6 +214,19 @@ impl ItemStack {
|
||||
pub fn has_any_enchantments(&self) -> bool {
|
||||
!self.enchantments().is_empty() || !self.stored_enchantments().is_empty()
|
||||
}
|
||||
/// `ItemStack.hasFoil`: an explicit `minecraft:enchantment_glint_override`
|
||||
/// component wins, then the item's default override (`item_glint`), then a
|
||||
/// non-empty `minecraft:enchantments` component (`Item.isFoil` is
|
||||
/// `ItemStack.isEnchanted`; stored book enchantments do not count).
|
||||
pub fn has_foil(&self, item_glint: bool) -> bool {
|
||||
if let Some(Tag::Byte(flag)) = self
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:enchantment_glint_override"))
|
||||
{
|
||||
return *flag != 0;
|
||||
}
|
||||
item_glint || !self.enchantments().is_empty()
|
||||
}
|
||||
pub fn repair_cost(&self) -> u32 {
|
||||
self.data().map_or(0, |d| d.repair_cost)
|
||||
}
|
||||
@@ -321,7 +334,9 @@ fn read_text(t: &Tag) -> Option<String> {
|
||||
// A component's root `text` may be empty with the visible words in
|
||||
// `extra` (every legacy display name ViaVersion produces looks like
|
||||
// this), so flatten the whole component instead of reading one key.
|
||||
Tag::Compound(_) => Some(crate::entity_nbt::name_text(t).plain()),
|
||||
// ComponentSerialization.CODEC also takes a list (its first element
|
||||
// styling the rest), as servers behind ViaVersion send book pages.
|
||||
Tag::Compound(_) | Tag::List(_) => Some(crate::entity_nbt::name_text(t).plain()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -916,6 +931,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_book_pages_may_be_component_lists() {
|
||||
// A page as Hypixel sends it through ViaVersion: a list, not a compound.
|
||||
let page = Tag::List(vec![
|
||||
Tag::compound([("text", Tag::String("".into()))]),
|
||||
Tag::compound([("text", Tag::String("Please read ".into()))]),
|
||||
Tag::compound([("text", Tag::String("the rules".into()))]),
|
||||
]);
|
||||
let book = Tag::compound([
|
||||
(
|
||||
"title",
|
||||
Tag::compound([("raw", Tag::String("Rules".into()))]),
|
||||
),
|
||||
("author", Tag::String("Server".into())),
|
||||
(
|
||||
"pages",
|
||||
Tag::List(vec![
|
||||
page,
|
||||
Tag::compound([("raw", Tag::String("Two".into()))]),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
let components = Tag::compound([("minecraft:written_book_content", book)]);
|
||||
let (_, extra) = decode_components(Some(&components));
|
||||
let pages = extra.and_then(|d| d.written.clone()).map(|w| w.pages);
|
||||
assert_eq!(
|
||||
pages,
|
||||
Some(vec!["Please read the rules".to_owned(), "Two".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_layers_and_shield_base_round_trip() {
|
||||
let mut stack = ItemStack::new("minecraft:shield", 1);
|
||||
|
||||
@@ -12,7 +12,7 @@ mod special;
|
||||
use crate::{
|
||||
block_entity_render::{ModelPart, diffuse},
|
||||
hud::Rect,
|
||||
mesher::Vertex,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
};
|
||||
use glam::{Mat3, Mat4, Vec3};
|
||||
use horizon_assets::{
|
||||
@@ -41,11 +41,57 @@ use std::{
|
||||
pub struct ItemQuad {
|
||||
pub pos: [Vec3; 4],
|
||||
pub uv: [[f32; 2]; 4],
|
||||
/// The model UVs `GLINT_TEXTURING` scrolls for the glint (vanilla's UV0,
|
||||
/// or UV3 for a special model).
|
||||
pub foil_uv: [[f32; 2]; 4],
|
||||
pub normal: Vec3,
|
||||
pub color: u32,
|
||||
pub foil: Foil,
|
||||
/// `MaterialInfo.lightEmission` (block light floor).
|
||||
pub light_emission: u8,
|
||||
/// The render type culls back faces (`entitySolid`): world drawing discards them.
|
||||
pub cull_back: bool,
|
||||
}
|
||||
/// `TextureTransform.setupGlintTexturing`'s inputs.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Glint {
|
||||
/// `options.glintSpeed` (vanilla default 0.5).
|
||||
pub speed: f32,
|
||||
/// `options.glintStrength` / shader `GlintAlpha` (vanilla default 0.75).
|
||||
pub alpha: f32,
|
||||
/// `Util.getMillis()`.
|
||||
pub millis: f32,
|
||||
/// The transform's scale: `GLINT_TEXTURING`'s 8.0 for GUI items,
|
||||
/// `ENTITY_GLINT_TEXTURING`'s 0.5 for items in the world and
|
||||
/// `ARMOR_ENTITY_GLINT_TEXTURING`'s 0.16 for armour (entity.wgsl).
|
||||
pub scale: f32,
|
||||
}
|
||||
impl Glint {
|
||||
/// `Util.getMillis() * glintSpeed * 8.0` as vanilla's long.
|
||||
fn ticks(millis: f32, speed: f32) -> i64 {
|
||||
(f64::from(millis) * f64::from(speed) * 8.0) as i64
|
||||
}
|
||||
/// The scroll clock modulo the common period of both offsets (110000 and
|
||||
/// 30000 divide 330000), for `globals.time.w`: the entity shader derives
|
||||
/// `ARMOR_ENTITY_GLINT_TEXTURING` from it.
|
||||
pub fn scroll_ticks(millis: f32, speed: f32) -> f32 {
|
||||
(Self::ticks(millis, speed) % 330_000) as f32
|
||||
}
|
||||
/// `TextureTransform.setupGlintTexturing(scale)` applied to the model UV:
|
||||
/// translation(-o0, o1) * rotateZ(PI/18) * scale. The result repeats:
|
||||
/// the shader samples the dedicated glint texture with vanilla's
|
||||
/// repeating `GlintSampler`, so the UVs stay unwrapped.
|
||||
pub fn uv(&self, model: [f32; 2]) -> [f32; 2] {
|
||||
let ticks = Self::ticks(self.millis, self.speed);
|
||||
let offset0 = (ticks % 110_000) as f32 / 110_000.0;
|
||||
let offset1 = (ticks % 30_000) as f32 / 30_000.0;
|
||||
let angle = std::f32::consts::PI / 18.0;
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
[
|
||||
model[0] * self.scale * cos - model[1] * self.scale * sin - offset0,
|
||||
model[0] * self.scale * sin + model[1] * self.scale * cos + offset1,
|
||||
]
|
||||
}
|
||||
}
|
||||
/// Which glint render type a quad uses.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -242,10 +288,12 @@ fn submit_quads(
|
||||
sink.quad(ItemQuad {
|
||||
pos,
|
||||
uv,
|
||||
foil_uv: q.uv,
|
||||
normal: transform_normal(m, direction_vector(q.facing)),
|
||||
color: argb(color),
|
||||
foil,
|
||||
light_emission: q.light_emission,
|
||||
cull_back: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -680,6 +728,8 @@ pub struct WorldSink<'a> {
|
||||
pub light: u32,
|
||||
/// The level's diffuse lights; `None` leaves the colour unshaded.
|
||||
pub lights: Option<(Vec3, Vec3)>,
|
||||
/// The `GLINT_TEXTURING` sample for foil quads (`None` leaves them unglinted).
|
||||
pub glint: Option<Glint>,
|
||||
}
|
||||
impl ItemSink for WorldSink<'_> {
|
||||
fn quad(&mut self, q: ItemQuad) {
|
||||
@@ -694,15 +744,22 @@ impl ItemSink for WorldSink<'_> {
|
||||
} else {
|
||||
self.light
|
||||
};
|
||||
let overlay = if q.foil == Foil::None { 0 } else { 7 };
|
||||
for (p, uv) in q.pos.iter().zip(q.uv) {
|
||||
let glint = if q.foil == Foil::None {
|
||||
None
|
||||
} else {
|
||||
self.glint
|
||||
};
|
||||
let overlay = if glint.is_some() { 7 } else { 0 };
|
||||
for (i, (p, uv)) in q.pos.iter().zip(q.uv).enumerate() {
|
||||
let glint_uv = glint.map_or([0.0, 0.0], |glint| glint.uv(q.foil_uv[i]));
|
||||
self.out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
normal: if q.cull_back { CULL_BACK } else { [0; 4] },
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: overlay,
|
||||
color,
|
||||
light,
|
||||
glint_uv,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! drawn far to near, and clipped to the item's 16x16 slot unless the model
|
||||
//! is oversized in the GUI (`GuiItemAtlas` draws each item into a scissored
|
||||
//! 16x16 slot; oversized items get a picture-in-picture of their bounds).
|
||||
use super::{Foil, ItemQuad, ItemRenderState, ItemRenderer, SpecialHost};
|
||||
use super::{Foil, Glint, ItemQuad, ItemRenderState, ItemRenderer, SpecialHost};
|
||||
use crate::hud::{HudBatch, Rect};
|
||||
use glam::{EulerRot, Mat4, Vec2, Vec3};
|
||||
use std::{collections::HashMap, f32::consts::PI, sync::Arc};
|
||||
@@ -43,6 +43,7 @@ pub fn items_3d_lights() -> (Vec3, Vec3) {
|
||||
struct Point {
|
||||
p: Vec2,
|
||||
uv: [f32; 2],
|
||||
glint_uv: [f32; 2],
|
||||
}
|
||||
fn lerp(a: Point, b: Point, t: f32) -> Point {
|
||||
Point {
|
||||
@@ -51,6 +52,10 @@ fn lerp(a: Point, b: Point, t: f32) -> Point {
|
||||
a.uv[0] + (b.uv[0] - a.uv[0]) * t,
|
||||
a.uv[1] + (b.uv[1] - a.uv[1]) * t,
|
||||
],
|
||||
glint_uv: [
|
||||
a.glint_uv[0] + (b.glint_uv[0] - a.glint_uv[0]) * t,
|
||||
a.glint_uv[1] + (b.glint_uv[1] - a.glint_uv[1]) * t,
|
||||
],
|
||||
}
|
||||
}
|
||||
/// Sutherland-Hodgman against one half plane `dot(p, n) <= limit`.
|
||||
@@ -97,37 +102,25 @@ pub fn lit_color(q: &ItemQuad, lights: (Vec3, Vec3)) -> u32 {
|
||||
let c = |v: u8| (f32::from(v) * light) as u8;
|
||||
crate::hud::rgba(c(r), c(g), c(b), a)
|
||||
}
|
||||
/// The glint's moving sheen at a GUI position (a stand-in for the
|
||||
/// `GLINT_TEXTURING`-scrolled `enchanted_glint_item` texture added on top).
|
||||
fn glint_color(p: Vec2, time: f32) -> u32 {
|
||||
let d = ((p.x + p.y) / 32.0 - time * 0.35).rem_euclid(1.0);
|
||||
let band = (1.0 - (d - 0.5).abs() * 6.0).max(0.0);
|
||||
crate::hud::rgba(
|
||||
(170.0 + 70.0 * band) as u8,
|
||||
(90.0 + 110.0 * band) as u8,
|
||||
255,
|
||||
(45.0 + 110.0 * band) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
fn emit(batch: &mut HudBatch, fan: &[Point], color: impl Fn(Vec2) -> u32, glint: bool) {
|
||||
let shift = if glint { 2.0 } else { 0.0 };
|
||||
fn emit(batch: &mut HudBatch, fan: &[Point], color: impl Fn(Vec2) -> u32, glint: Option<&Glint>) {
|
||||
for i in 1..fan.len().saturating_sub(1) {
|
||||
let (a, b, c) = (fan[0], fan[i], fan[i + 1]);
|
||||
let corners = [a, b, c, c];
|
||||
// `HudBatch.quad` draws (0, 1, 2) and (0, 2, 3): the second is empty.
|
||||
batch.quad(
|
||||
corners.map(|v| v.p.to_array()),
|
||||
corners.map(|v| [v.uv[0] + shift, v.uv[1]]),
|
||||
color(a.p),
|
||||
);
|
||||
if glint {
|
||||
// Per-vertex colours are flat per quad in the HUD batch: use the
|
||||
// fan centre for the sheen.
|
||||
let n = batch.vertices.len();
|
||||
for v in &mut batch.vertices[n - 6..] {
|
||||
v.color = color(Vec2::from(v.pos));
|
||||
}
|
||||
if let Some(glint) = glint {
|
||||
let start = batch.vertices.len();
|
||||
batch.quad_glint(
|
||||
corners.map(|v| v.p.to_array()),
|
||||
corners.map(|v| [v.uv[0] + 2.0, v.uv[1]]),
|
||||
corners.map(|v| glint.uv(v.glint_uv)),
|
||||
u32::MAX,
|
||||
);
|
||||
batch.blend_since(start, crate::hud::Blend::Additive);
|
||||
} else {
|
||||
batch.quad(
|
||||
corners.map(|v| v.p.to_array()),
|
||||
corners.map(|v| v.uv),
|
||||
color(a.p),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +137,8 @@ pub fn draw(
|
||||
x: f32,
|
||||
y: f32,
|
||||
time: f32,
|
||||
glint_speed: f32,
|
||||
glint_alpha: f32,
|
||||
) {
|
||||
if state.is_empty() {
|
||||
return;
|
||||
@@ -162,7 +157,18 @@ pub fn draw(
|
||||
return;
|
||||
}
|
||||
let start = batch.vertices.len();
|
||||
draw_now(state, renderer, sprites, host, batch, x, y, time);
|
||||
draw_now(
|
||||
state,
|
||||
renderer,
|
||||
sprites,
|
||||
host,
|
||||
batch,
|
||||
x,
|
||||
y,
|
||||
time,
|
||||
glint_speed,
|
||||
glint_alpha,
|
||||
);
|
||||
if let Some(key) = key
|
||||
&& let Ok(mut drawn) = renderer.gui_drawn.lock()
|
||||
{
|
||||
@@ -236,6 +242,8 @@ fn draw_now(
|
||||
x: f32,
|
||||
y: f32,
|
||||
time: f32,
|
||||
glint_speed: f32,
|
||||
glint_alpha: f32,
|
||||
) {
|
||||
let pose = Mat4::from_translation(Vec3::new(x + 8.0, y + 8.0, 0.0))
|
||||
* Mat4::from_scale(Vec3::new(16.0, -16.0, 16.0));
|
||||
@@ -256,7 +264,16 @@ fn draw_now(
|
||||
} else {
|
||||
[x, y, x + 16.0, y + 16.0]
|
||||
};
|
||||
draw_quads(&quads, lights, batch, rect, time);
|
||||
let glint = sprites
|
||||
.contains_key("misc/enchanted_glint_item")
|
||||
.then_some(Glint {
|
||||
speed: glint_speed,
|
||||
alpha: glint_alpha,
|
||||
millis: time * 1000.0,
|
||||
scale: 8.0,
|
||||
});
|
||||
batch.glint_alpha = glint_alpha;
|
||||
draw_quads(&quads, lights, batch, rect, glint.as_ref());
|
||||
}
|
||||
|
||||
/// Lights, culls, sorts far to near and clips `quads` (GUI positions) to
|
||||
@@ -266,7 +283,7 @@ fn draw_quads(
|
||||
lights: (Vec3, Vec3),
|
||||
batch: &mut HudBatch,
|
||||
rect: [f32; 4],
|
||||
time: f32,
|
||||
glint: Option<&Glint>,
|
||||
) {
|
||||
for face in depth::sort(quads) {
|
||||
let q = &quads[face.quad];
|
||||
@@ -276,6 +293,7 @@ fn draw_quads(
|
||||
.map(|v| Point {
|
||||
p: v.pos.truncate(),
|
||||
uv: v.uv,
|
||||
glint_uv: v.glint_uv,
|
||||
})
|
||||
.collect();
|
||||
let color = lit_color(q, lights);
|
||||
@@ -285,9 +303,9 @@ fn draw_quads(
|
||||
if fan.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
emit(batch, &fan, |_| color, false);
|
||||
emit(batch, &fan, |_| color, None);
|
||||
if q.foil != Foil::None {
|
||||
emit(batch, &fan, |p| glint_color(p, time), true);
|
||||
emit(batch, &fan, |_| color, glint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,7 +360,7 @@ pub fn draw_banner_result(
|
||||
items_flat_lights(),
|
||||
batch,
|
||||
[x0, y0, x0 + 20.0, y0 + 40.0],
|
||||
0.0,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -354,10 +372,12 @@ mod tests {
|
||||
ItemQuad {
|
||||
pos: [(0.0, y0), (0.0, y1), (8.0, y1), (8.0, y0)].map(|(x, y)| Vec3::new(x, y, z(y))),
|
||||
uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
|
||||
foil_uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
|
||||
normal: Vec3::Z,
|
||||
color,
|
||||
foil: Foil::None,
|
||||
light_emission: 0,
|
||||
cull_back: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +414,7 @@ mod tests {
|
||||
(Vec3::Z, Vec3::Z),
|
||||
&mut batch,
|
||||
[0.0, 0.0, 16.0, 16.0],
|
||||
0.0,
|
||||
None,
|
||||
);
|
||||
assert_eq!(color_at(&batch, Vec2::new(4.0, 0.5)), Some(u32::MAX));
|
||||
}
|
||||
@@ -410,7 +430,7 @@ mod tests {
|
||||
(Vec3::Z, Vec3::Z),
|
||||
&mut batch,
|
||||
[0.0, 0.0, 16.0, 16.0],
|
||||
0.0,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
color_at(&batch, Vec2::new(4.0, 2.0)),
|
||||
@@ -442,7 +462,7 @@ mod tests {
|
||||
(Vec3::Z, Vec3::Z),
|
||||
&mut batch,
|
||||
[0.0, 0.0, 16.0, 16.0],
|
||||
0.0,
|
||||
None,
|
||||
);
|
||||
for y in [0.5, 6.0] {
|
||||
assert_eq!(
|
||||
@@ -468,6 +488,7 @@ mod tests {
|
||||
let p = |x: f32, y: f32| Point {
|
||||
p: Vec2::new(x, y),
|
||||
uv: [x, y],
|
||||
glint_uv: [0.0, 0.0],
|
||||
};
|
||||
let fan = clip_triangle(
|
||||
[p(-4.0, 0.0), p(8.0, 0.0), p(8.0, 8.0)],
|
||||
|
||||
@@ -10,6 +10,7 @@ use glam::{Vec3, Vec4};
|
||||
pub(super) struct Corner {
|
||||
pub pos: Vec3,
|
||||
pub uv: [f32; 2],
|
||||
pub glint_uv: [f32; 2],
|
||||
}
|
||||
|
||||
pub(super) struct Face {
|
||||
@@ -38,6 +39,9 @@ fn split(face: Face, distances: &[f32]) -> (Face, Face) {
|
||||
let corner = Corner {
|
||||
pos: a.pos.lerp(b.pos, t),
|
||||
uv: std::array::from_fn(|k| a.uv[k] + (b.uv[k] - a.uv[k]) * t),
|
||||
glint_uv: std::array::from_fn(|k| {
|
||||
a.glint_uv[k] + (b.glint_uv[k] - a.glint_uv[k]) * t
|
||||
}),
|
||||
};
|
||||
back.push(corner);
|
||||
front.push(corner);
|
||||
@@ -73,6 +77,7 @@ pub(super) fn sort(quads: &[ItemQuad]) -> Vec<Face> {
|
||||
corners: std::array::from_fn::<_, 4, _>(|k| Corner {
|
||||
pos: q.pos[k],
|
||||
uv: q.uv[k],
|
||||
glint_uv: q.foil_uv[k],
|
||||
})
|
||||
.to_vec(),
|
||||
})
|
||||
|
||||
@@ -65,6 +65,17 @@ pub(super) fn load_parts(root: &Path) -> HashMap<String, ModelPart> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Marks every quad passed on as drawn with a back-face-culling render type (`entitySolid`).
|
||||
struct CullBack<'a>(&'a mut dyn ItemSink);
|
||||
impl ItemSink for CullBack<'_> {
|
||||
fn quad(&mut self, quad: ItemQuad) {
|
||||
self.0.quad(ItemQuad {
|
||||
cull_back: true,
|
||||
..quad
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A pose override for a named part (`setupAnim`).
|
||||
type Adjust<'a> = &'a dyn Fn(&str, &mut [f32; 9]);
|
||||
fn none(_: &str, _: &mut [f32; 9]) {}
|
||||
@@ -115,10 +126,12 @@ fn draw(
|
||||
sprite.v0 + (sprite.v1 - sprite.v0) * v[4],
|
||||
]
|
||||
}),
|
||||
foil_uv: poly.map(|v| [v[3], v[4]]),
|
||||
normal,
|
||||
color,
|
||||
foil,
|
||||
light_emission: 0,
|
||||
cull_back: false,
|
||||
});
|
||||
}
|
||||
for (child_name, child) in &part.children {
|
||||
@@ -498,7 +511,7 @@ pub(super) fn submit(
|
||||
let skin = (!argument.is_null())
|
||||
.then(|| host.player_skin(argument))
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "entity/player/wide/steve".to_owned());
|
||||
.unwrap_or_else(|| "entity/player/slim/steve".to_owned());
|
||||
part("player", &none, &skin, white, Foil::None);
|
||||
}
|
||||
SpecialKind::Trident => {
|
||||
@@ -569,6 +582,8 @@ pub(super) fn submit(
|
||||
}
|
||||
SpecialKind::DecoratedPot => {
|
||||
let sides = pot_sides(argument);
|
||||
// `DecoratedPotRenderer` draws every part with `entitySolid`.
|
||||
let sink = &mut CullBack(sink);
|
||||
if let Some(base) = renderer.part("decorated_pot_base")
|
||||
&& let Some(r) = sprites
|
||||
.get("entity/decorated_pot/decorated_pot_base")
|
||||
@@ -672,4 +687,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `DecoratedPotRenderer` draws every part with `RenderTypes.entitySolid`,
|
||||
/// whose pipeline culls back faces: the pot's flat `top` and `bottom`
|
||||
/// planes would otherwise show both sides and z-fight in the hand too.
|
||||
#[test]
|
||||
fn the_decorated_pot_item_culls_its_back_faces() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let renderer = ItemRenderer::load(&root).unwrap();
|
||||
let sprite = Rect {
|
||||
u0: 0.25,
|
||||
v0: 0.125,
|
||||
u1: 0.375,
|
||||
v1: 0.25,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
let sprites: HashMap<String, Rect> = [
|
||||
"entity/decorated_pot/decorated_pot_base",
|
||||
"entity/decorated_pot/decorated_pot_side",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| (name.to_owned(), sprite))
|
||||
.collect();
|
||||
let model = SpecialModel {
|
||||
kind: SpecialKind::DecoratedPot,
|
||||
};
|
||||
let mut quads: Vec<ItemQuad> = Vec::new();
|
||||
submit(
|
||||
&renderer,
|
||||
&model,
|
||||
&Value::Null,
|
||||
false,
|
||||
Mat4::IDENTITY,
|
||||
&sprites,
|
||||
&super::super::NoHost,
|
||||
&mut quads,
|
||||
);
|
||||
assert!(!quads.is_empty(), "the pot item draws its parts");
|
||||
assert!(
|
||||
quads.iter().all(|q| q.cull_back),
|
||||
"entitySolid culls the pot's back faces"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,17 @@ fn profile_tag(p: &Profile) -> Tag {
|
||||
if let Some(name) = &p.name {
|
||||
entries.push(("name", Tag::String(name.as_str().into())));
|
||||
}
|
||||
if let Some(id) = p.id {
|
||||
entries.push((
|
||||
"id",
|
||||
Tag::IntArray(vec![
|
||||
(id >> 96) as i32,
|
||||
(id >> 64) as i32,
|
||||
(id >> 32) as i32,
|
||||
id as i32,
|
||||
]),
|
||||
));
|
||||
}
|
||||
entries.push(("properties", Tag::List(properties)));
|
||||
Tag::compound(entries)
|
||||
}
|
||||
|
||||
@@ -791,6 +791,16 @@ fn login_and_configure(
|
||||
},
|
||||
}),
|
||||
)?;
|
||||
// ServerConfigurationPacketListenerImpl.startConfiguration: links, only
|
||||
// when there are any (MinecraftServer.serverLinks is empty elsewhere).
|
||||
if !shared.config.server_links.is_empty() {
|
||||
send(
|
||||
c,
|
||||
cfg::Clientbound::ServerLinks(cfg::ServerLinks {
|
||||
links: shared.config.server_links.clone(),
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
send(
|
||||
c,
|
||||
cfg::Clientbound::UpdateEnabledFeatures(cfg::UpdateEnabledFeatures {
|
||||
|
||||
@@ -2068,6 +2068,7 @@ impl Hub {
|
||||
| S::PlayerLoaded(_)
|
||||
| S::ChunkBatchReceived(_)
|
||||
| S::ClientInformation(_)
|
||||
| S::RecipeBookChangeSettings(_)
|
||||
| S::ClientTickEnd(_)
|
||||
| S::KeepAlive(_) => false,
|
||||
S::MovePlayerPos(p) => self.moved_far(i, [p.x, p.y, p.z]),
|
||||
@@ -2134,6 +2135,15 @@ impl Hub {
|
||||
g.max_unacknowledged = 10;
|
||||
}
|
||||
S::ClientTickEnd(_) => self.guests[i].position_this_tick = false,
|
||||
// ServerGamePacketListenerImpl.handleRecipeBookChangeSettingsPacket.
|
||||
S::RecipeBookChangeSettings(p) => {
|
||||
if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize)
|
||||
{
|
||||
let settings = &mut self.guests[i].recipe_book;
|
||||
settings.open[book.index()] = p.open;
|
||||
settings.filtering[book.index()] = p.filtering;
|
||||
}
|
||||
}
|
||||
S::ClientInformation(info) => {
|
||||
let g = &mut self.guests[i];
|
||||
g.view_distance =
|
||||
@@ -2269,11 +2279,16 @@ impl Hub {
|
||||
packets = 1;
|
||||
}
|
||||
// shouldCheckPlayerMovement (never the single-player owner).
|
||||
// The server holds no velocity for a guest (getDeltaMovement),
|
||||
// so nothing is expected.
|
||||
if !owner && check && (!g.fall_flying || elytra_check) {
|
||||
let limit = if g.fall_flying { 300.0 } else { 100.0 };
|
||||
if moved > limit * f64::from(packets) {
|
||||
// `movedDist - expectedDist` (getDeltaMovement): the velocity
|
||||
// the server last simulated for the guest is expected
|
||||
// movement, not a violation — a landing glide keeps its speed.
|
||||
let expected = g.motion.as_ref().map_or(0.0, |m| {
|
||||
let v = m.velocity;
|
||||
v.x * v.x + v.y * v.y + v.z * v.z
|
||||
});
|
||||
if moved - expected > limit * f64::from(packets) {
|
||||
events.push(HostEvent::Warn(format!(
|
||||
"{} moved too quickly! {},{},{}",
|
||||
g.name,
|
||||
|
||||
@@ -142,6 +142,10 @@ pub struct NetConfig {
|
||||
/// server.properties `resource-pack`: offered to every player during
|
||||
/// configuration (a dedicated server's; MinecraftServer.getServerResourcePack).
|
||||
pub resource_pack: Option<crate::server::properties::ResourcePackInfo>,
|
||||
/// MinecraftServer.serverLinks (a dedicated server's `bug-report-link`):
|
||||
/// sent in configuration when non-empty
|
||||
/// (ServerConfigurationPacketListenerImpl.startConfiguration).
|
||||
pub server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
|
||||
}
|
||||
impl NetConfig {
|
||||
/// MinecraftServer.enforceSecureProfile.
|
||||
@@ -168,6 +172,7 @@ impl NetConfig {
|
||||
online: None,
|
||||
log: None,
|
||||
resource_pack: None,
|
||||
server_links: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user