mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
141
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52e0dbd6cc | ||
|
|
f5c58b96ab | ||
|
|
4b94fb759f | ||
|
|
2007676d23 | ||
|
|
517608055c | ||
|
|
e07f41f752 | ||
|
|
91e0bb7d78 | ||
|
|
00c78d6662 | ||
|
|
5788648663 | ||
|
|
2706c070f3 | ||
|
|
aa8d5c9be8 | ||
|
|
910c413d86 | ||
|
|
ddde989ce3 | ||
|
|
7bae97ee4f | ||
|
|
8ecb38688a | ||
|
|
9241d6e490 | ||
|
|
89cdd09a2e | ||
|
|
ed35f7c90f | ||
|
|
186410848f | ||
|
|
45f1621c0a | ||
|
|
0fc7703c72 | ||
|
|
c08bbcfec7 | ||
|
|
0507b4dc84 | ||
|
|
f8df12f724 | ||
|
|
99c8c90021 | ||
|
|
9812674ec9 | ||
|
|
7ba38fd574 | ||
|
|
24ca331daa | ||
|
|
9be7d9602f | ||
|
|
26ee5035e4 | ||
|
|
c541dc7e75 | ||
|
|
4d89ce522f | ||
|
|
03589605fc | ||
|
|
326719539d | ||
|
|
a72f06cfab | ||
|
|
fab9c37413 | ||
|
|
ac7e491f3b | ||
|
|
e48d8560e0 | ||
|
|
b85a9e8350 | ||
|
|
cb1c3499bf | ||
|
|
4a094bee90 | ||
|
|
598668e4bc | ||
|
|
a1a249e2f6 | ||
|
|
eb5e0bb914 | ||
|
|
d93c3cdd82 | ||
|
|
91ed783e8d | ||
|
|
bc3a724c01 | ||
|
|
24e316855e | ||
|
|
cbf0a4f289 | ||
|
|
d7ee852c7c | ||
|
|
04ad9d5c6e | ||
|
|
77bcba839b | ||
|
|
9ac7754c45 | ||
|
|
677dbd677e | ||
|
|
e41443c64c | ||
|
|
61713b8359 | ||
|
|
0e266fd807 | ||
|
|
bebabd0d1a | ||
|
|
b84cbb7921 | ||
|
|
e564f9d6ea | ||
|
|
f8b7a0c2ae | ||
|
|
ce5fa3cb8b | ||
|
|
0a8720be88 | ||
|
|
46fba800fa | ||
|
|
10415990a5 | ||
|
|
f56135aec2 | ||
|
|
1d8e192360 | ||
|
|
1734325664 | ||
|
|
8f7b90cf46 | ||
|
|
60cd02204c | ||
|
|
43f2d07841 | ||
|
|
b88d6230e9 | ||
|
|
b2d7ec6b1b | ||
|
|
4c645729d5 | ||
|
|
36af8b0879 | ||
|
|
93564d764c | ||
|
|
403b417073 | ||
|
|
eac87c1a0b | ||
|
|
d46bed5c36 | ||
|
|
d26c7b42c2 | ||
|
|
9b9d045150 | ||
|
|
50bf909d7b | ||
|
|
1e5cc9031f | ||
|
|
e2a2cf4387 | ||
|
|
ee351caed8 | ||
|
|
cd4b744abc | ||
|
|
358b211e97 | ||
|
|
af5268eacd | ||
|
|
41d3890a3c | ||
|
|
2f13cb3ac6 | ||
|
|
f52e94cfb9 | ||
|
|
e0a48b3f66 | ||
|
|
d35efa25ec | ||
|
|
b8b15b1682 | ||
|
|
113d694a87 | ||
|
|
f049a08e48 | ||
|
|
f5f5532c19 | ||
|
|
4d0962e611 | ||
|
|
2ee238e5f1 | ||
|
|
7711bfcdc5 | ||
|
|
2ac08efcb2 | ||
|
|
559b83626f | ||
|
|
c2915208fd | ||
|
|
04bc30606d | ||
|
|
6893bcff88 | ||
|
|
aae98d3db5 | ||
|
|
6bb648db1e | ||
|
|
fece4c6a76 | ||
|
|
a0b19a4820 | ||
|
|
02b1d1f430 | ||
|
|
052dac817a | ||
|
|
42388d2512 | ||
|
|
2deaab2602 | ||
|
|
ca28a0edad | ||
|
|
80df20837b | ||
|
|
2092c5a2e8 | ||
|
|
3246732087 | ||
|
|
c215a48c91 | ||
|
|
783c25e9bc | ||
|
|
6aabf3f618 | ||
|
|
fbab7d29a5 | ||
|
|
be26c6db88 | ||
|
|
66b705cec3 | ||
|
|
1232745064 | ||
|
|
3382b666ce | ||
|
|
36ba5c65a6 | ||
|
|
92c073de61 | ||
|
|
139de824aa | ||
|
|
fea8346f23 | ||
|
|
523dd491c4 | ||
|
|
1a56e74324 | ||
|
|
a2098c2ffa | ||
|
|
7c58bf004f | ||
|
|
4119284a4f | ||
|
|
957a3c6622 | ||
|
|
58f2ea700e | ||
|
|
9da81f9293 | ||
|
|
7f8eb60682 | ||
|
|
75267d2fc6 | ||
|
|
9b88df32d3 | ||
|
|
36ebd0c545 |
No files matched your search
@@ -494,7 +494,11 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
_ => string("minecraft:air"),
|
||||
},
|
||||
);
|
||||
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
|
||||
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
|
||||
f.put(
|
||||
"facing",
|
||||
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
|
||||
);
|
||||
f.float("progress", 0.0);
|
||||
f.flag("extending", false);
|
||||
f.flag("source", false);
|
||||
@@ -873,6 +877,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
|
||||
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
|
||||
#[test]
|
||||
fn moving_piston_fields_match_vanilla() {
|
||||
let out = canon(
|
||||
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
|
||||
facing:5,progress:0.5f,extending:1b,source:0b}",
|
||||
);
|
||||
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
|
||||
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
|
||||
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
|
||||
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
|
||||
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
|
||||
let out = canon(
|
||||
|
||||
@@ -45,20 +45,30 @@ pub fn tooltip(
|
||||
Category::Neutral,
|
||||
)];
|
||||
}
|
||||
def.effects
|
||||
.iter()
|
||||
effect_lines(def.effects.iter().copied(), scale, translate)
|
||||
}
|
||||
/// `PotionContents.addPotionTooltip`'s line per effect: its name, potency and
|
||||
/// scaled duration, for (effect id, duration, amplifier) entries.
|
||||
pub fn effect_lines<'a>(
|
||||
effects: impl IntoIterator<Item = (&'a str, i32, i32)>,
|
||||
scale: f32,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, Category)> {
|
||||
effects
|
||||
.into_iter()
|
||||
.map(|(e, duration, amp)| {
|
||||
let e = e.trim_start_matches("minecraft:");
|
||||
let mut line =
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| (*e).to_owned());
|
||||
if *amp > 0 {
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
|
||||
if amp > 0 {
|
||||
line.push(' ');
|
||||
line.push_str(
|
||||
&translate(&format!("potion.potency.{amp}"))
|
||||
.unwrap_or_else(|| crate::enchant::level_name(*amp as u32 + 1)),
|
||||
.unwrap_or_else(|| crate::enchant::level_name(amp as u32 + 1)),
|
||||
);
|
||||
}
|
||||
let instant = effect(e).is_some_and(|d| d.instant);
|
||||
let scaled = (*duration as f32 * scale) as i32;
|
||||
let scaled = (duration as f32 * scale) as i32;
|
||||
if !instant && scaled > 20 {
|
||||
line = format!("{line} ({})", format_duration(scaled));
|
||||
}
|
||||
|
||||
@@ -74,9 +74,28 @@ pub enum BlockEntity {
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// Suspicious sand and gravel: saved loot and the transient brushed face.
|
||||
Brushable(Box<Brushable>),
|
||||
/// A shelf holds three displayed item stacks.
|
||||
Shelf(Box<Shelf>),
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
}
|
||||
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Brushable {
|
||||
pub loot_table: Option<horizon_world::nbt::Tag>,
|
||||
pub loot_seed: Option<horizon_world::nbt::Tag>,
|
||||
pub item: Option<ItemStack>,
|
||||
/// Direction's legacy network id; not saved to disk by vanilla.
|
||||
pub hit_direction: Option<i32>,
|
||||
}
|
||||
/// `ShelfBlockEntity`'s displayed slots and alignment flag.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Shelf {
|
||||
pub items: [Option<ItemStack>; 3],
|
||||
pub align_to_bottom: bool,
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChiseledBookshelf {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
|
||||
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
@@ -42,6 +42,45 @@ pub fn recipe_used_key(id: &str) -> String {
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
/// `getDefaultName` of the `BaseContainerBlockEntity` that `block` holds: the
|
||||
/// translation key of its menu's title when it has no custom name. Copper
|
||||
/// chests hold a `ChestBlockEntity`, shulker boxes of every colour a
|
||||
/// `ShulkerBoxBlockEntity`.
|
||||
pub fn default_name_key(block: &str) -> Option<&'static str> {
|
||||
Some(match block {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" => "container.chest",
|
||||
n if n.ends_with("copper_chest") => "container.chest",
|
||||
"minecraft:barrel" => "container.barrel",
|
||||
n if n.ends_with("shulker_box") => "container.shulkerBox",
|
||||
"minecraft:dispenser" => "container.dispenser",
|
||||
"minecraft:dropper" => "container.dropper",
|
||||
"minecraft:hopper" => "container.hopper",
|
||||
"minecraft:furnace" => "container.furnace",
|
||||
"minecraft:smoker" => "container.smoker",
|
||||
"minecraft:blast_furnace" => "container.blast_furnace",
|
||||
"minecraft:brewing_stand" => "container.brewing",
|
||||
"minecraft:crafter" => "container.crafter",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `BaseContainerBlockEntity.getDisplayName` (`getName`), the title of the
|
||||
/// `ClientboundOpenScreenPacket` that opens it: the block entity's custom
|
||||
/// name (`CustomName`, a literal), else its default name.
|
||||
pub fn display_name(custom: Option<&str>, default_key: &str) -> horizon_world::nbt::Tag {
|
||||
match custom {
|
||||
Some(name) => horizon_protocol::text::literal(name),
|
||||
None => horizon_protocol::text::translatable(default_key, &[]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChestBlock.MENU_PROVIDER_COMBINER`'s `getDisplayName` for a double chest:
|
||||
/// the first half's custom name (the right half, `DoubleBlockCombiner`'s
|
||||
/// `FIRST`), else the second's, else `container.chestDouble`.
|
||||
pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_world::nbt::Tag {
|
||||
display_name(first.or(second), "container.chestDouble")
|
||||
}
|
||||
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
@@ -191,6 +230,14 @@ impl BlockEntity {
|
||||
recipes_used: HashMap::new(),
|
||||
}));
|
||||
}
|
||||
if matches!(
|
||||
name,
|
||||
"minecraft:suspicious_sand"
|
||||
| "minecraft:suspicious_gravel"
|
||||
| "minecraft:brushable_block"
|
||||
) {
|
||||
return Some(Self::Brushable(Box::default()));
|
||||
}
|
||||
if name == "minecraft:creaking_heart" {
|
||||
return Some(Self::CreakingHeart(Box::default()));
|
||||
}
|
||||
@@ -253,6 +300,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
if name == "minecraft:shelf" || name.ends_with("_shelf") {
|
||||
return Some(Self::Shelf(Box::default()));
|
||||
}
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
@@ -295,8 +345,10 @@ impl BlockEntity {
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Brushable(_) => "minecraft:brushable_block",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
Self::Shelf(_) => "minecraft:shelf",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -315,13 +367,15 @@ impl BlockEntity {
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => Vec::new(),
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
Self::Shelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -337,9 +391,11 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::ChiseledBookshelf(b) => &b.items,
|
||||
Self::Shelf(b) => &b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -363,9 +419,11 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::ChiseledBookshelf(b) => &mut b.items,
|
||||
Self::Shelf(b) => &mut b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
|
||||
@@ -308,6 +308,25 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The report for a hung server tick, written by the watchdog thread
|
||||
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
|
||||
/// the report carries none.
|
||||
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
|
||||
deliver(
|
||||
home,
|
||||
Report {
|
||||
time: Local::now(),
|
||||
description: "Watching Server".into(),
|
||||
error: format!("{message} (Watching Server)"),
|
||||
stack: String::new(),
|
||||
thread: thread_name(),
|
||||
side,
|
||||
details: system_details(),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let payload = info.payload();
|
||||
let text = payload
|
||||
|
||||
@@ -125,6 +125,114 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
|
||||
packs
|
||||
}
|
||||
|
||||
// ---- Create World ------------------------------------------------------------
|
||||
|
||||
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
|
||||
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
|
||||
/// built-in one (`resources` holds its `pack.png`), which can be unselected
|
||||
/// and stays at the bottom. Horizon has no experiments, so their feature
|
||||
/// packs are not offered.
|
||||
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
|
||||
use crate::{
|
||||
pack_screen::{Compat, PackInfo, Source, load_icon},
|
||||
packs::Position,
|
||||
text::{Style, Text},
|
||||
};
|
||||
let format = horizon_core::version::DATA_PACK_FORMAT;
|
||||
let mut infos: Vec<PackInfo> = crate::packs::folder(dir, format)
|
||||
.into_iter()
|
||||
.map(|p| PackInfo {
|
||||
icon: p.icon_png().as_deref().and_then(load_icon),
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.raw_description,
|
||||
description_style: p.description_style,
|
||||
source: Source::World,
|
||||
compat: p.compatibility,
|
||||
required: false,
|
||||
position: Position::Top,
|
||||
fixed: false,
|
||||
})
|
||||
.collect();
|
||||
infos.push(PackInfo {
|
||||
id: "vanilla".into(),
|
||||
title: Text::translate("dataPack.vanilla.name", &[]),
|
||||
description: Text::translate("dataPack.vanilla.description", &[]),
|
||||
description_style: Style::default(),
|
||||
source: Source::BuiltIn,
|
||||
compat: Compat::Compatible,
|
||||
icon: std::fs::read(resources.join("pack.png"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(load_icon),
|
||||
required: false,
|
||||
position: Position::Bottom,
|
||||
fixed: false,
|
||||
});
|
||||
infos
|
||||
}
|
||||
/// A new empty folder for Create World's packs
|
||||
/// (`Files.createTempDirectory("mcworld-")`).
|
||||
pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("mcworld-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
/// What's in a folder of packs, to notice packs added or removed while the
|
||||
/// screen shows it (`PackSelectionScreen.Watcher`).
|
||||
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
|
||||
let mut stamp: Vec<_> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let modified = e.metadata().and_then(|m| m.modified()).ok();
|
||||
(e.file_name().to_string_lossy().into_owned(), modified)
|
||||
})
|
||||
.collect();
|
||||
stamp.sort();
|
||||
stamp
|
||||
}
|
||||
/// Create World's packs into the new world before it opens
|
||||
/// (`copyTempDataPackDirToNewWorld`): every pack of `dir` goes to
|
||||
/// `<world>/datapacks`, and `datapacks.json` enables those `selected`
|
||||
/// (lowest priority first) and disables the rest.
|
||||
pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), String> {
|
||||
let err = |p: &Path, e: std::io::Error| format!("{}: {e}", p.display());
|
||||
let target = world.join("datapacks");
|
||||
std::fs::create_dir_all(&target).map_err(|e| err(&target, e))?;
|
||||
let mut disabled = Vec::new();
|
||||
for pack in crate::packs::folder(dir, horizon_core::version::DATA_PACK_FORMAT) {
|
||||
let (crate::packs::Source::Dir(from) | crate::packs::Source::Zip(from)) = &pack.source;
|
||||
copy(from, &target.join(pack.id.trim_start_matches("file/")))?;
|
||||
if !selected.contains(&pack.id) {
|
||||
disabled.push(pack.id);
|
||||
}
|
||||
}
|
||||
if !selected.iter().any(|id| id == "vanilla") {
|
||||
disabled.push("vanilla".into());
|
||||
}
|
||||
let json = serde_json::json!({ "enabled": selected, "disabled": disabled });
|
||||
let file = world.join("datapacks.json");
|
||||
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
|
||||
}
|
||||
/// A file, or a folder and everything in it.
|
||||
fn copy(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
|
||||
if !from.is_dir() {
|
||||
return std::fs::copy(from, to).map(drop).map_err(err);
|
||||
}
|
||||
std::fs::create_dir_all(to).map_err(err)?;
|
||||
for e in std::fs::read_dir(from).map_err(err)?.flatten() {
|
||||
copy(&e.path(), &to.join(e.file_name()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- functions ---------------------------------------------------------------
|
||||
|
||||
/// A `.mcfunction`: plain command lines and macro lines.
|
||||
@@ -566,4 +674,51 @@ mod tests {
|
||||
assert_eq!(p.list_kinds(&kinds), wanted);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_world_installs_its_packs() {
|
||||
let root = std::env::temp_dir().join(format!("hz-datapack-install-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (dir, world) = (root.join("mcworld"), root.join("world"));
|
||||
let write = |pack: &str, p: &str, t: &str| {
|
||||
let path = dir.join(pack).join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
write("fns", "data/x/function/f.mcfunction", "say hi");
|
||||
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
|
||||
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut zip, br#"{"pack":{"pack_format":48}}"#).unwrap();
|
||||
zip.finish().unwrap();
|
||||
let infos = screen_packs(&dir, &root);
|
||||
let ids: Vec<&str> = infos.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip", "vanilla"]);
|
||||
let compat: Vec<_> = infos.iter().map(|p| p.compat).collect();
|
||||
use crate::pack_screen::Compat;
|
||||
assert_eq!(
|
||||
compat,
|
||||
[
|
||||
Compat::Compatible,
|
||||
Compat::Compatible,
|
||||
Compat::TooOld,
|
||||
Compat::Compatible
|
||||
]
|
||||
);
|
||||
let selected = ["vanilla", "file/old.zip", "file/fns"].map(String::from);
|
||||
install(&dir, &world, &selected).unwrap();
|
||||
let packs = discover(&world);
|
||||
let ids: Vec<&str> = packs.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip"]);
|
||||
let saved: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(world.join("datapacks.json")).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
saved,
|
||||
serde_json::json!({"enabled": selected, "disabled": ["file/off"]})
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ struct Out {
|
||||
@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,
|
||||
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
|
||||
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
|
||||
@location(13) @interpolate(flat) tier: u32,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -125,8 +128,9 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@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>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay, color, light, normal);
|
||||
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = overlay >> 16u;
|
||||
return out;
|
||||
}
|
||||
// Retained bolt-local geometry, translated using the same f64 camera
|
||||
@@ -278,6 +282,14 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
return vec4(in.color.rgb, 1.0);
|
||||
}
|
||||
|
||||
// HDR output: a light-giving particle's pixels marked in the frame's alpha the
|
||||
// way terrain.wgsl marks a light source's, drawn again after the entities with
|
||||
// only alpha written, so its colour stays as blended.
|
||||
@fragment fn fs_hdr_mark(in: Out) -> @location(0) vec4<f32> {
|
||||
if in.tier == 0u || textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
|
||||
@@ -203,6 +203,13 @@ pub struct FogInputs<'a> {
|
||||
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
|
||||
/// `createWorldFog` is shown (the Ender Dragon's).
|
||||
pub boss_world_fog: bool,
|
||||
/// The camera entity is a spectator (`LavaFogEnvironment`).
|
||||
pub spectator: bool,
|
||||
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
|
||||
pub fire_resistance: bool,
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -272,7 +279,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
color[1] += (color[1] * 0.6 - color[1]) * t;
|
||||
color[2] += (color[2] * 0.6 - color[2]) * t;
|
||||
}
|
||||
let brighten = if eye_in_water { water_vision } else { 0.0 };
|
||||
let brighten = if eye_in_water {
|
||||
water_vision
|
||||
} else {
|
||||
input.night_vision
|
||||
};
|
||||
if color.iter().all(|&c| c != 0.0) {
|
||||
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
|
||||
color = color.map(|c| c + (c * scale - c) * brighten);
|
||||
@@ -288,10 +299,16 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
};
|
||||
match input.fog_type {
|
||||
FogType::Lava => {
|
||||
fog.environmental_start = 0.25;
|
||||
fog.environmental_end = 1.0;
|
||||
fog.sky_end = 1.0;
|
||||
fog.cloud_end = 1.0;
|
||||
// LavaFogEnvironment.setupFog.
|
||||
(fog.environmental_start, fog.environmental_end) = if input.spectator {
|
||||
(-8.0, rd * 0.5)
|
||||
} else if input.fire_resistance {
|
||||
(0.0, 5.0)
|
||||
} else {
|
||||
(0.25, 1.0)
|
||||
};
|
||||
fog.sky_end = fog.environmental_end;
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
FogType::PowderSnow => {
|
||||
fog.environmental_start = 0.0;
|
||||
@@ -405,6 +422,9 @@ mod tests {
|
||||
darkness: None,
|
||||
darken_world: 0.0,
|
||||
boss_world_fog: false,
|
||||
spectator: false,
|
||||
fire_resistance: false,
|
||||
night_vision: 0.0,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -349,6 +349,14 @@ struct BorderUniform {
|
||||
color: [f32; 4],
|
||||
offset: [f32; 4],
|
||||
}
|
||||
/// A frame to save as a PNG: F2's screenshot, an agent's `game_screenshot`,
|
||||
/// or the icon the Select World list shows.
|
||||
pub struct ScreenshotRequest {
|
||||
pub path: PathBuf,
|
||||
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
|
||||
/// and average it down to this size. `None` saves the frame as drawn.
|
||||
pub size: Option<[u32; 2]>,
|
||||
}
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
@@ -407,6 +415,7 @@ pub struct Renderer {
|
||||
crosshair_group: wgpu::BindGroup,
|
||||
crosshair_globals: wgpu::Buffer,
|
||||
entity_pipeline: wgpu::RenderPipeline,
|
||||
hdr_mark_pipeline: wgpu::RenderPipeline,
|
||||
weather: weather::Weather,
|
||||
crumbling_pipeline: wgpu::RenderPipeline,
|
||||
shadow_blob_pipeline: wgpu::RenderPipeline,
|
||||
@@ -570,8 +579,8 @@ pub struct Renderer {
|
||||
pub section_draw_calls: usize,
|
||||
pub uploaded_bytes: u64,
|
||||
started: Instant,
|
||||
/// Write the next presented frame to this PNG path (control socket).
|
||||
pub screenshot: Option<PathBuf>,
|
||||
/// The next frame to write to a PNG path (control socket, F2, world icon).
|
||||
pub screenshot: Option<ScreenshotRequest>,
|
||||
/// Outcome of the last screenshot: frame size, or the error.
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// Brightness option (lightmap gamma), 0..1.
|
||||
@@ -1128,7 +1137,14 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
(config, caps.present_modes.clone(), hdr::output(&caps))
|
||||
let hdr_output = hdr::output(&caps);
|
||||
if previous.is_none() {
|
||||
log::info!(
|
||||
"Horizon renderer: HDR output {}",
|
||||
hdr_output.map_or("not offered by this display", hdr::Output::name)
|
||||
);
|
||||
}
|
||||
(config, caps.present_modes.clone(), hdr_output)
|
||||
} else {
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
@@ -2046,6 +2062,18 @@ impl Renderer {
|
||||
"fs_energy_swirl",
|
||||
wgpu::ColorWrites::ALL,
|
||||
);
|
||||
// HDR output's mark on light-giving particles (entity.wgsl fs_hdr_mark).
|
||||
let hdr_mark_pipeline = entity_pipeline_with(
|
||||
"HDR particle mark",
|
||||
None,
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
Default::default(),
|
||||
wgpu::BlendState::REPLACE,
|
||||
false,
|
||||
"fs_hdr_mark",
|
||||
wgpu::ColorWrites::ALPHA,
|
||||
);
|
||||
// Attribute 7: 6 is the entity vertex buffer's glint UV.
|
||||
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let lightning_vertex = [
|
||||
@@ -2557,6 +2585,7 @@ impl Renderer {
|
||||
crosshair_group,
|
||||
crosshair_globals,
|
||||
entity_pipeline,
|
||||
hdr_mark_pipeline,
|
||||
weather,
|
||||
crumbling_pipeline,
|
||||
shadow_blob_pipeline,
|
||||
@@ -3206,6 +3235,19 @@ impl Renderer {
|
||||
}
|
||||
config
|
||||
}
|
||||
/// The F3 screen's HDR line while the setting is on: the swapchain HDR presents to with
|
||||
/// its paper white and peak, or that the surface offers none.
|
||||
pub fn hdr_line(&self) -> String {
|
||||
match &self.hdr {
|
||||
Some(hdr) => format!(
|
||||
"HDR: {}, {}/{} nits",
|
||||
hdr.output.name(),
|
||||
hdr.paper_white.round(),
|
||||
hdr.peak.round()
|
||||
),
|
||||
None => "HDR: not offered by this display".into(),
|
||||
}
|
||||
}
|
||||
/// 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, marker: &std::path::Path) {
|
||||
@@ -3222,6 +3264,14 @@ impl Renderer {
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.configure_surface();
|
||||
match output {
|
||||
Some(output) => log::info!(
|
||||
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
|
||||
output.name(),
|
||||
output.format()
|
||||
),
|
||||
None => log::info!("Horizon renderer: HDR off"),
|
||||
}
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
&& (hdr.paper_white, hdr.peak) != (paper_white, peak)
|
||||
@@ -4755,6 +4805,10 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
if self.hdr.is_some() {
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_item_quads > 0 {
|
||||
@@ -5086,6 +5140,13 @@ impl Renderer {
|
||||
&self.entity_group,
|
||||
&self.indices,
|
||||
);
|
||||
// The world icon grabs here: after the level and the hand, before
|
||||
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
|
||||
// runs right after `renderLevel`).
|
||||
let readback = self
|
||||
.screenshot
|
||||
.take_if(|request| request.size.is_some())
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
|
||||
if !frame.screen.is_empty() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
@@ -5189,18 +5250,10 @@ impl Renderer {
|
||||
(&view_target, self.config.format.remove_srgb_suffix()),
|
||||
);
|
||||
}
|
||||
let readback = self.screenshot.take().and_then(|path| {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
Some((path, self.copy_frame(&mut encoder, &target_texture)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
None
|
||||
}
|
||||
let readback = readback.or_else(|| {
|
||||
self.screenshot
|
||||
.take()
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture))
|
||||
});
|
||||
self.uploads.finish(&encoder);
|
||||
if has_section_copies {
|
||||
@@ -5227,9 +5280,9 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((path, buffer)) = readback {
|
||||
if let Some((request, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
@@ -5283,6 +5336,26 @@ impl Renderer {
|
||||
fn padded_row(&self) -> u32 {
|
||||
(self.config.width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
}
|
||||
/// Take a screenshot request: queue the frame's copy, or report why the
|
||||
/// frame cannot be read back.
|
||||
fn take_readback(
|
||||
&mut self,
|
||||
request: ScreenshotRequest,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
Some((request, self.copy_frame(encoder, texture)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
None
|
||||
}
|
||||
}
|
||||
fn copy_frame(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
@@ -5312,7 +5385,11 @@ impl Renderer {
|
||||
);
|
||||
buffer
|
||||
}
|
||||
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return Err("this surface does not support frame readback".into());
|
||||
}
|
||||
@@ -5350,8 +5427,18 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, self.config.width, self.config.height, size),
|
||||
),
|
||||
None => (self.config.width, self.config.height, pixels),
|
||||
};
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
@@ -5361,6 +5448,42 @@ impl Renderer {
|
||||
Ok([width, height])
|
||||
}
|
||||
}
|
||||
/// Centre-crop the frame to a square and average it down to `size`.
|
||||
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
|
||||
let (side, cx, cy) = if width > height {
|
||||
(height, (width - height) / 2, 0)
|
||||
} else {
|
||||
(width, 0, (height - width) / 2)
|
||||
};
|
||||
let [tw, th] = size;
|
||||
let mut out = vec![0u8; (tw * th * 4) as usize];
|
||||
for ty in 0..th {
|
||||
let y0 = cy + ty * side / th;
|
||||
let y1 = cy + (ty + 1) * side / th;
|
||||
for tx in 0..tw {
|
||||
let x0 = cx + tx * side / tw;
|
||||
let x1 = cx + (tx + 1) * side / tw;
|
||||
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let p = ((y * width + x) * 4) as usize;
|
||||
r += u32::from(pixels[p]);
|
||||
g += u32::from(pixels[p + 1]);
|
||||
b += u32::from(pixels[p + 2]);
|
||||
}
|
||||
}
|
||||
let count = (x1 - x0) * (y1 - y0);
|
||||
let o = ((ty * tw + tx) * 4) as usize;
|
||||
out[o..o + 4].copy_from_slice(&[
|
||||
(r / count) as u8,
|
||||
(g / count) as u8,
|
||||
(b / count) as u8,
|
||||
255,
|
||||
]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
|
||||
@@ -14,6 +14,12 @@ pub enum Output {
|
||||
}
|
||||
|
||||
impl Output {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pq => "HDR10 (PQ)",
|
||||
Self::ScRgb => "scRGB",
|
||||
}
|
||||
}
|
||||
pub fn format(self) -> wgpu::TextureFormat {
|
||||
match self {
|
||||
Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm,
|
||||
@@ -54,6 +60,38 @@ struct Targets {
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tier {
|
||||
pub share: f32,
|
||||
pub start: f32,
|
||||
pub full: f32,
|
||||
}
|
||||
/// Low, incandescent, bright and very bright.
|
||||
pub const TIERS: [Tier; 4] = [
|
||||
Tier {
|
||||
share: 0.2,
|
||||
start: 0.08,
|
||||
full: 0.09,
|
||||
},
|
||||
Tier {
|
||||
share: 0.5,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 0.6,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 1.0,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
|
||||
pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
@@ -62,11 +100,12 @@ pub struct Hdr {
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
format: wgpu::TextureFormat,
|
||||
targets: Option<Targets>,
|
||||
/// `HORIZON_HDR_DEBUG=1`: paint expanded pixels magenta.
|
||||
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
|
||||
debug: bool,
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
pub tiers: [Tier; 4],
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
@@ -134,7 +173,7 @@ impl Hdr {
|
||||
});
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 32,
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -148,6 +187,7 @@ impl Hdr {
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
tiers: TIERS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,16 +268,19 @@ impl Hdr {
|
||||
return;
|
||||
};
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let params = [
|
||||
let mut params = [0.0; 20];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(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,
|
||||
];
|
||||
]);
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
params[12 + i] = t.start;
|
||||
params[16 + i] = t.full;
|
||||
}
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -265,6 +308,24 @@ mod tests {
|
||||
use super::{Output, pick};
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
#[test]
|
||||
fn hdr_shaders_validate() {
|
||||
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
|
||||
for source in [
|
||||
include_str!("../hdr.wgsl"),
|
||||
include_str!("../terrain.wgsl"),
|
||||
&entity,
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities {
|
||||
wgpu::SurfaceCapabilities {
|
||||
format_capabilities: offered
|
||||
|
||||
@@ -570,6 +570,7 @@ impl Item {
|
||||
pub enum Action {
|
||||
ButtonClick,
|
||||
Changed,
|
||||
SliderDrag,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
Done,
|
||||
@@ -800,7 +801,7 @@ impl GraphicsUi {
|
||||
.find(|wd| matches!(wd.target, Target::Item(i) if i == item))
|
||||
{
|
||||
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
|
||||
return vec![Action::Changed];
|
||||
return vec![Action::SliderDrag];
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -822,11 +823,11 @@ impl GraphicsUi {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
|
||||
vec![Action::SliderDrag]
|
||||
} else {
|
||||
item.cycle(s, right, framebuffer);
|
||||
return vec![Action::ButtonClick, Action::Changed];
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
}
|
||||
vec![Action::Changed]
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -897,7 +898,7 @@ mod tests {
|
||||
false,
|
||||
false
|
||||
),
|
||||
vec![Action::Changed]
|
||||
vec![Action::SliderDrag]
|
||||
);
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
@@ -909,7 +910,7 @@ mod tests {
|
||||
true,
|
||||
false
|
||||
),
|
||||
vec![Action::Changed]
|
||||
vec![Action::SliderDrag]
|
||||
);
|
||||
assert!(ui.release());
|
||||
assert!(!ui.release());
|
||||
|
||||
@@ -12,23 +12,27 @@ struct Params {
|
||||
peak: f32,
|
||||
// 1 when this frame drew a world.
|
||||
world: f32,
|
||||
// 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1).
|
||||
// 1 greys what isn't expanded and tints what is (HORIZON_HDR_DEBUG=1).
|
||||
debug: f32,
|
||||
// 1 for an HDR10 swapchain, 0 for scRGB.
|
||||
pq: f32,
|
||||
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
|
||||
// the brightest pixels get, and the brightness where the lift starts and
|
||||
// where it is full.
|
||||
share: vec4<f32>,
|
||||
start: vec4<f32>,
|
||||
full: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
|
||||
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
|
||||
// textures were drawn for SDR, so a curve on their brightest pixels blows out
|
||||
// near-white spots (glowstone) and leaves the rest (lava, a jack o'lantern's
|
||||
// face) nearly as it was. Pixels brighter than LIFT_FULL get STRENGTH of the
|
||||
// headroom, dimmer ones less down to none at LIFT_START (lava's crust, a
|
||||
// lantern's metal), and no channel goes past the peak. Brightness is the mean
|
||||
// of luminance and the brightest channel, so saturated light counts as bright.
|
||||
const LIFT_START: f32 = 0.18;
|
||||
const LIFT_FULL: f32 = 0.42;
|
||||
const STRENGTH: f32 = 0.7;
|
||||
// face) nearly as it was. Each tier's pixels brighter than its `full` get its
|
||||
// `share` of the headroom, dimmer ones less down to none at its `start`
|
||||
// (lava's crust, a lantern's metal), and no channel goes past the peak.
|
||||
// Brightness is the mean of luminance and the brightest channel, so saturated
|
||||
// light counts as bright.
|
||||
|
||||
// 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.
|
||||
@@ -67,21 +71,31 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
let w = textureLoad(world, at, 0);
|
||||
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine.
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine, by the tier it
|
||||
// holds, the lower the brighter.
|
||||
var lifted = false;
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
|
||||
// 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);
|
||||
let tier = u32(clamp(round(4.0 - 4.0 * w.a / 0.7), 1.0, 4.0)) - 1u;
|
||||
let lift = smoothstep(params.start[tier], params.full[tier], y);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * STRENGTH * lift, params.peak / max(top, 1e-4));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
|
||||
light *= max(gain, 1.0);
|
||||
if params.debug > 0.5 {
|
||||
light = mix(light, vec3(gain, 0.0, gain), 0.6);
|
||||
}
|
||||
lifted = true;
|
||||
}
|
||||
}
|
||||
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
|
||||
// colour with a magenta tint, so a lift shows on any colour.
|
||||
if params.debug > 0.5 && params.world > 0.5 {
|
||||
if lifted {
|
||||
light *= vec3(1.0, 0.6, 1.0);
|
||||
} else {
|
||||
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
}
|
||||
let scrgb = light * params.paper;
|
||||
|
||||
@@ -346,6 +346,59 @@ impl HudAtlas {
|
||||
atlas.pixels = Vec::new();
|
||||
Ok(atlas)
|
||||
}
|
||||
/// The boot screen's atlas, before any pack loads: the theme's run-time
|
||||
/// glyphs (`horizon/ui-text`), a white sprite and the glyph rows over
|
||||
/// transparent black. Nothing from a pack: the boot screen draws with
|
||||
/// the theme's bundled faces, so the font here has no sheets to read.
|
||||
pub fn boot() -> Self {
|
||||
let (width, height) = (1024, 1024);
|
||||
let text_rows = 512;
|
||||
let glyph_top = text_rows + 4;
|
||||
let mut pixels = vec![0u8; (width * height * 4) as usize];
|
||||
// The white sprite the fills and the font's underline pass sample.
|
||||
for row in 0..4 {
|
||||
let at = ((text_rows + row) * width * 4) as usize;
|
||||
pixels[at..at + 16].fill(255);
|
||||
}
|
||||
let rect = |u0: u32, v0: u32, u1: u32, v1: u32, w: u32, h: u32| Rect {
|
||||
u0: u0 as f32 / width as f32,
|
||||
v0: v0 as f32 / height as f32,
|
||||
u1: u1 as f32 / width as f32,
|
||||
v1: v1 as f32 / height as f32,
|
||||
w,
|
||||
h,
|
||||
};
|
||||
let mut sprites = HashMap::new();
|
||||
sprites.insert(
|
||||
"horizon/white".into(),
|
||||
rect(0, text_rows, 4, glyph_top, 4, 4),
|
||||
);
|
||||
sprites.insert(
|
||||
"horizon/ui-text".into(),
|
||||
rect(0, 0, width, text_rows, width, text_rows),
|
||||
);
|
||||
let white = sprites["horizon/white"];
|
||||
Self {
|
||||
font: crate::font::Font::load(
|
||||
Path::new("horizon-boot"),
|
||||
&sprites,
|
||||
&pixels,
|
||||
(width, height),
|
||||
(glyph_top, height - glyph_top),
|
||||
white,
|
||||
),
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
translucent: HashSet::new(),
|
||||
flags: HashMap::new(),
|
||||
sprite_animations: Vec::new(),
|
||||
glint: (vec![0; 4], 1, 1),
|
||||
glint_armor: (vec![0; 4], 1, 1),
|
||||
white,
|
||||
}
|
||||
}
|
||||
/// Shelf placements of `images` (tallest first) in the narrowest atlas
|
||||
/// width from 4096 up that keeps the atlas, glyph rows included, within
|
||||
/// `max` texels a side; and the first row free for glyphs. High-resolution
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod collisions;
|
||||
pub mod command_block;
|
||||
pub mod containers;
|
||||
pub mod crash;
|
||||
pub mod datapack;
|
||||
pub mod dynamic_light;
|
||||
pub mod effects;
|
||||
pub mod environment;
|
||||
|
||||
@@ -266,6 +266,33 @@ const OFFSETS: [(i32, i32, i32); 6] = [
|
||||
(1, 0, 0),
|
||||
];
|
||||
|
||||
/// Per-worker light scratch, reused between jobs: the 48x48xheight region and
|
||||
/// its flood-fill queues. Resized only upward; cleared before each job.
|
||||
pub struct LightScratch {
|
||||
states: Vec<u16>,
|
||||
sky: Vec<u8>,
|
||||
block: Vec<u8>,
|
||||
present: Vec<bool>,
|
||||
lowest: Vec<i32>,
|
||||
queue: VecDeque<u32>,
|
||||
sky_queue: VecDeque<u32>,
|
||||
ids: Box<[u32; 4096]>,
|
||||
}
|
||||
impl Default for LightScratch {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
states: Vec::new(),
|
||||
sky: Vec::new(),
|
||||
block: Vec::new(),
|
||||
present: Vec::new(),
|
||||
lowest: Vec::new(),
|
||||
queue: VecDeque::new(),
|
||||
sky_queue: VecDeque::new(),
|
||||
ids: Box::new([0; 4096]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `region[dz][dx]` holds the chunks around the centre (index 1,1).
|
||||
/// `sky` is false in dimensions without skylight (the Nether).
|
||||
pub fn light_chunk(
|
||||
@@ -273,6 +300,26 @@ pub fn light_chunk(
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
light_chunk_masked(region, tables, sky_light).0
|
||||
}
|
||||
|
||||
/// `light_chunk`, plus the sections whose light differs from the centre
|
||||
/// chunk's snapshot (bit `sy` set): the game thread then replays only those.
|
||||
pub fn light_chunk_masked(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
) -> (Vec<Light>, u32) {
|
||||
light_chunk_masked_with(region, tables, sky_light, &mut LightScratch::default())
|
||||
}
|
||||
|
||||
/// `light_chunk_masked` with a caller-owned scratch buffer (`LightScratch`).
|
||||
pub fn light_chunk_masked_with(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
scratch: &mut LightScratch,
|
||||
) -> (Vec<Light>, u32) {
|
||||
region[1][1].expect("centre chunk");
|
||||
// Highest occupied section in the region bounds the work.
|
||||
let mut top_section = 0;
|
||||
@@ -289,13 +336,30 @@ pub fn light_chunk(
|
||||
let h = ((top_section + 1) * 16).min(HEIGHT as usize);
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
// Packed cells (`type << 4 | dampening`), not state ids: the flood fill
|
||||
// then never touches the large `LightTables::cells` table.
|
||||
let mut states = vec![0u16; W * W * h];
|
||||
let mut sky = vec![0u8; W * W * h];
|
||||
let mut block = vec![0u8; W * W * h];
|
||||
let mut queue = VecDeque::new();
|
||||
let mut sky_queue = VecDeque::new();
|
||||
let mut ids = Box::new([0u32; 4096]);
|
||||
// then never touches the large `LightTables::cells` table. Every used cell
|
||||
// is cleared: the flood fill reads stale values otherwise.
|
||||
let cells = W * W * h;
|
||||
scratch.states.resize(cells, 0);
|
||||
scratch.states.fill(0);
|
||||
scratch.sky.resize(cells, 0);
|
||||
scratch.sky.fill(0);
|
||||
scratch.block.resize(cells, 0);
|
||||
scratch.block.fill(0);
|
||||
scratch.queue.clear();
|
||||
scratch.sky_queue.clear();
|
||||
let LightScratch {
|
||||
states,
|
||||
sky,
|
||||
block,
|
||||
present,
|
||||
lowest,
|
||||
queue,
|
||||
sky_queue,
|
||||
ids,
|
||||
} = scratch;
|
||||
let states = &mut states[..cells];
|
||||
let sky = &mut sky[..cells];
|
||||
let block = &mut block[..cells];
|
||||
for (dz, row) in region.iter().enumerate() {
|
||||
for (dx, chunk) in row.iter().enumerate() {
|
||||
let Some(chunk) = chunk else { continue };
|
||||
@@ -303,7 +367,7 @@ pub fn light_chunk(
|
||||
if section.is_uniform(0) {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut ids);
|
||||
section.unpack(ids);
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
|
||||
let p = at(dx * 16 + lx, si * 16 + ly, dz * 16 + lz);
|
||||
@@ -319,13 +383,15 @@ pub fn light_chunk(
|
||||
}
|
||||
}
|
||||
// Columns of missing neighbour chunks neither hold nor pass light.
|
||||
let mut present = vec![false; W * W];
|
||||
present.resize(W * W, false);
|
||||
present.fill(false);
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
present[z * W + x] = region[z / 16][x / 16].is_some();
|
||||
}
|
||||
}
|
||||
let mut lowest = vec![h as i32; W * W];
|
||||
lowest.resize(W * W, h as i32);
|
||||
lowest.fill(h as i32);
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
@@ -420,21 +486,36 @@ pub fn light_chunk(
|
||||
}
|
||||
}
|
||||
};
|
||||
spread(&mut sky, &mut sky_queue);
|
||||
spread(&mut block, &mut queue);
|
||||
pack_lighting(h, &sky, &block, sky_light)
|
||||
spread(sky, sky_queue);
|
||||
spread(block, queue);
|
||||
pack_lighting(h, sky, block, sky_light, region[1][1])
|
||||
}
|
||||
|
||||
/// Extract just the centre column's light; never copy worker snapshot sections.
|
||||
fn pack_lighting(h: usize, sky: &[u8], block: &[u8], sky_light: bool) -> Vec<Light> {
|
||||
fn pack_lighting(
|
||||
h: usize,
|
||||
sky: &[u8],
|
||||
block: &[u8],
|
||||
sky_light: bool,
|
||||
old: Option<&Chunk>,
|
||||
) -> (Vec<Light>, u32) {
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
let open = if sky_light { 0xF0 } else { 0x00 };
|
||||
(0..SECTIONS)
|
||||
let mut changed = 0u32;
|
||||
let lights = (0..SECTIONS)
|
||||
.map(|si| {
|
||||
let old = old.and_then(|c| c.sections.get(si)).map(|s| &s.light);
|
||||
if si * 16 >= h {
|
||||
return Light::Uniform(open);
|
||||
let light = Light::Uniform(open);
|
||||
match old {
|
||||
Some(Light::Uniform(v)) if *v == open => {}
|
||||
Some(Light::Full(data)) if data.iter().all(|&v| v == open) => {}
|
||||
_ => changed |= 1 << si,
|
||||
}
|
||||
return light;
|
||||
}
|
||||
let mut data = Box::new([open; 4096]);
|
||||
let mut differs = false;
|
||||
for ly in 0..16 {
|
||||
let y = si * 16 + ly;
|
||||
if y >= h {
|
||||
@@ -443,17 +524,24 @@ fn pack_lighting(h: usize, sky: &[u8], block: &[u8], sky_light: bool) -> Vec<Lig
|
||||
for lz in 0..16 {
|
||||
for lx in 0..16 {
|
||||
let p = at(16 + lx, y, 16 + lz);
|
||||
data[index(lx as i32, ly as i32, lz as i32)] = sky[p] << 4 | block[p];
|
||||
let i = index(lx as i32, ly as i32, lz as i32);
|
||||
let v = sky[p] << 4 | block[p];
|
||||
data[i] = v;
|
||||
differs |= old.is_some_and(|old| old.get(i) != v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if differs {
|
||||
changed |= 1 << si;
|
||||
}
|
||||
if data.iter().all(|&v| v == data[0]) {
|
||||
Light::Uniform(data[0])
|
||||
} else {
|
||||
Light::Full(data)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
(lights, changed)
|
||||
}
|
||||
|
||||
/// Move a worker's light into current sections, preserving later block/biome
|
||||
@@ -465,9 +553,18 @@ pub fn apply_lighting(chunk: &mut Chunk, lights: Vec<Light>) {
|
||||
}
|
||||
|
||||
/// `apply_lighting` for a chunk lit before: writes only the sections whose
|
||||
/// light changed and adds the cells that changed to `changed`.
|
||||
pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut LightChanges) {
|
||||
/// light changed and adds the cells that changed to `changed`. `mask` has a bit
|
||||
/// per section the worker saw differ from its snapshot.
|
||||
pub fn apply_relighting_masked(
|
||||
chunk: &mut Chunk,
|
||||
lights: Vec<Light>,
|
||||
mask: u32,
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
|
||||
if mask & (1 << sy) == 0 {
|
||||
continue;
|
||||
}
|
||||
let old = §ion.light;
|
||||
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
|
||||
&& a == b
|
||||
@@ -488,6 +585,10 @@ pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut Lig
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut LightChanges) {
|
||||
apply_relighting_masked(chunk, lights, u32::MAX, changed);
|
||||
}
|
||||
|
||||
/// `DataLayer.get`: the nibble at index `i` (`y << 8 | z << 4 | x`), low first.
|
||||
#[inline]
|
||||
fn nibble(layer: &[u8], i: usize) -> u8 {
|
||||
@@ -1015,6 +1116,11 @@ mod tests {
|
||||
.collect();
|
||||
lit.into_iter().collect()
|
||||
}
|
||||
fn region(chunks: &HashMap<(i32, i32), Arc<Chunk>>) -> [[Option<&Chunk>; 3]; 3] {
|
||||
std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| chunks.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
|
||||
})
|
||||
}
|
||||
fn sky(world: &HashMap<(i32, i32), Arc<Chunk>>, x: i32, y: i32, z: i32) -> u8 {
|
||||
world[&(x >> 4, z >> 4)].light(x & 15, y, z & 15) >> 4
|
||||
}
|
||||
@@ -1242,6 +1348,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_mask_names_the_sections_the_full_diff_changes() {
|
||||
let w = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
});
|
||||
let (_, same) = light_chunk_masked(®ion(&w), &tables(), true);
|
||||
assert_eq!(same, 0, "the centre is already lit like this");
|
||||
|
||||
let mut edited = w.clone();
|
||||
Arc::make_mut(edited.get_mut(&(0, 0)).unwrap()).set(8, 0, 8, STONE);
|
||||
let (lights, mask) = light_chunk_masked(®ion(&edited), &tables(), true);
|
||||
let mut expected = 0;
|
||||
for (sy, (old, new)) in w[&(0, 0)].sections.iter().zip(&lights).enumerate() {
|
||||
if (0..4096).any(|i| old.light(i) != new.get(i)) {
|
||||
expected |= 1 << sy;
|
||||
}
|
||||
}
|
||||
assert_eq!(mask, expected);
|
||||
assert_ne!(mask, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_light_scratch_matches_a_fresh_call() {
|
||||
let short = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
});
|
||||
let tall = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
c.set(9, 200, 9, STONE);
|
||||
});
|
||||
let mut scratch = LightScratch::default();
|
||||
// Shrink, grow, then shrink again: every cell must be cleared.
|
||||
for w in [&short, &tall, &short] {
|
||||
let (fresh, fresh_mask) = light_chunk_masked(®ion(w), &tables(), true);
|
||||
let (reused, reused_mask) =
|
||||
light_chunk_masked_with(®ion(w), &tables(), true, &mut scratch);
|
||||
assert_eq!(fresh_mask, reused_mask);
|
||||
for (a, b) in fresh.iter().zip(&reused) {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(a.get(i), b.get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relighting_reports_exactly_the_changed_positions() {
|
||||
let mut chunk = Chunk::empty(1, -1);
|
||||
@@ -1460,7 +1611,7 @@ mod tests {
|
||||
let mut old = fixture.clone();
|
||||
let mut new = fixture.clone();
|
||||
baseline_apply(&mut old, baseline_pack(&fixture, h, &sky, &block));
|
||||
apply_lighting(&mut new, pack_lighting(h, &sky, &block, true));
|
||||
apply_lighting(&mut new, pack_lighting(h, &sky, &block, true, None).0);
|
||||
for (a, b) in old.sections.iter().zip(&new.sections) {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(a.light(i), b.light(i));
|
||||
@@ -1477,14 +1628,14 @@ mod tests {
|
||||
let mut current = fixture.clone();
|
||||
let held = shared.then(|| current.clone());
|
||||
// Warm up and detach for the uniquely-owned scenario.
|
||||
apply_lighting(&mut current, pack_lighting(h, &sky, &block, true));
|
||||
apply_lighting(&mut current, pack_lighting(h, &sky, &block, true, None).0);
|
||||
let started = Instant::now();
|
||||
for _ in 0..500 {
|
||||
let retained = shared.then(|| current.clone());
|
||||
if optimized {
|
||||
apply_lighting(
|
||||
black_box(&mut current),
|
||||
pack_lighting(h, black_box(&sky), black_box(&block), true),
|
||||
pack_lighting(h, black_box(&sky), black_box(&block), true, None).0,
|
||||
);
|
||||
} else {
|
||||
baseline_apply(
|
||||
|
||||
@@ -250,8 +250,17 @@ fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
let state = BlockStateId(i as u32);
|
||||
let f = facts.get(state);
|
||||
let fluid = matches!(&*f.name, "minecraft:water" | "minecraft:lava");
|
||||
// Chorus is thin and non-solid but a visible part of the far End.
|
||||
let chorus = matches!(
|
||||
&*f.name,
|
||||
"minecraft:chorus_plant" | "minecraft:chorus_flower"
|
||||
);
|
||||
let kept = !facts.is_air(state)
|
||||
&& (f.solid_render || f.full_collision || f.name.ends_with("_leaves") || fluid);
|
||||
&& (f.solid_render
|
||||
|| f.full_collision
|
||||
|| f.name.ends_with("_leaves")
|
||||
|| chorus
|
||||
|| fluid);
|
||||
(u8::from(kept) * KEPT)
|
||||
| (u8::from(fluid) * FLUID)
|
||||
| (u8::from(state == facts.air) * AIR)
|
||||
@@ -515,6 +524,8 @@ struct Shared {
|
||||
kinds: std::sync::OnceLock<Vec<u8>>,
|
||||
wake: Condvar,
|
||||
stop: AtomicBool,
|
||||
/// Reduced far detail: samplers approximate wide cells.
|
||||
approximate: AtomicBool,
|
||||
}
|
||||
/// One background worker and at most two outstanding sample jobs. Only the
|
||||
/// current view's quadtree and a bounded set of visited summaries are retained.
|
||||
@@ -567,6 +578,7 @@ impl Lod {
|
||||
kinds: std::sync::OnceLock::new(),
|
||||
wake: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
approximate: AtomicBool::new(false),
|
||||
});
|
||||
let worker = shared.clone();
|
||||
let mesh_context = context.clone();
|
||||
@@ -610,6 +622,9 @@ impl Lod {
|
||||
}
|
||||
};
|
||||
let [x, _, z] = key.origin();
|
||||
sample.set_approximate(
|
||||
shared.approximate.load(Ordering::Relaxed),
|
||||
);
|
||||
let samples =
|
||||
sample.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
if shared.stop.load(Ordering::Relaxed) {
|
||||
@@ -672,6 +687,7 @@ impl Lod {
|
||||
}
|
||||
Job::Sample(key) => {
|
||||
let [x, _, z] = key.origin();
|
||||
sampler.set_approximate(worker.approximate.load(Ordering::Relaxed));
|
||||
let samples = sampler.grid(x, z, key.size() / CELLS as i32, CELLS);
|
||||
Done::Sample { key, samples }
|
||||
}
|
||||
@@ -967,9 +983,11 @@ impl Lod {
|
||||
self.near_loading = loading;
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
|
||||
/// replans when it changes.
|
||||
/// replans when it changes, and tiles sampled from then on approximate
|
||||
/// wide cells (`GeneratorWorker::lod_grid`).
|
||||
pub fn set_reduced_detail(&mut self, reduced: bool) {
|
||||
self.reduced_detail = reduced;
|
||||
self.shared.approximate.store(reduced, Ordering::Relaxed);
|
||||
}
|
||||
fn held(&self) -> bool {
|
||||
self.small && self.near_loading
|
||||
@@ -1810,6 +1828,23 @@ mod tests {
|
||||
for y in 55..64 {
|
||||
chunk.set(4, y, 4, water.0);
|
||||
}
|
||||
// Chorus is thin and non-solid but must survive the visit summary.
|
||||
let end_stone = assets
|
||||
.registry
|
||||
.default_state("minecraft:end_stone")
|
||||
.unwrap();
|
||||
let chorus = assets
|
||||
.registry
|
||||
.default_state("minecraft:chorus_plant")
|
||||
.unwrap();
|
||||
let flower = assets
|
||||
.registry
|
||||
.default_state("minecraft:chorus_flower")
|
||||
.unwrap();
|
||||
chunk.set(8, 70, 8, end_stone.0);
|
||||
chunk.set(8, 71, 8, chorus.0);
|
||||
chunk.set(8, 72, 8, chorus.0);
|
||||
chunk.set(8, 73, 8, flower.0);
|
||||
lod.observe(Arc::new(chunk.clone()), &facts);
|
||||
receive_observed(&mut lod);
|
||||
let observed = &lod.saved[&(-1, -1)];
|
||||
@@ -1817,6 +1852,16 @@ mod tests {
|
||||
assert_eq!(observed[0].surface.unwrap().state, stone);
|
||||
assert_eq!(observed[68].surface.unwrap().height, 55);
|
||||
assert_eq!(observed[68].surface.unwrap().water, Some((64, water)));
|
||||
let chorus_column = &observed[8 * 16 + 8];
|
||||
assert_eq!(chorus_column.surface.unwrap().state, flower);
|
||||
assert_eq!(
|
||||
chorus_column
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| (span.min, span.max, span.state))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)]
|
||||
);
|
||||
|
||||
let fine = TileKey {
|
||||
x: -1,
|
||||
|
||||
@@ -123,9 +123,25 @@ mod vertex_tests {
|
||||
fn emission_keeps_the_colour_and_the_free_bits() {
|
||||
let rgb = super::pack_rgb([0x12, 0x34, 0x56]);
|
||||
assert_eq!(super::emitting(rgb, 0), rgb);
|
||||
assert_eq!(super::emitting(rgb, 9), 0xF656_3412);
|
||||
assert_eq!(super::emitting(rgb, 15), 0xF056_3412);
|
||||
assert_eq!(super::emitting(rgb, 15) | super::EMISSION_BITS, rgb);
|
||||
assert_eq!(super::emitting(rgb, super::INCANDESCENT), 0xFD56_3412);
|
||||
assert_eq!(super::emitting(rgb, super::VERY_BRIGHT), 0xFB56_3412);
|
||||
assert_eq!(
|
||||
super::emitting(rgb, super::VERY_BRIGHT) | super::EMISSION_BITS,
|
||||
rgb
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn light_sources_shine_by_kind_not_light_level() {
|
||||
use super::{BRIGHT, INCANDESCENT, LOW, hdr_tier};
|
||||
assert_eq!(hdr_tier("glowstone", 15), BRIGHT);
|
||||
// A candle's flame is a particle: the block keeps only its wick.
|
||||
assert_eq!(hdr_tier("torch", 14), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("red_candle", 3), LOW);
|
||||
assert_eq!(hdr_tier("glow_lichen", 7), 0);
|
||||
assert_eq!(hdr_tier("stone", 0), 0);
|
||||
// Unlisted ones by their light level.
|
||||
assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
|
||||
}
|
||||
}
|
||||
pub struct SectionMesh {
|
||||
@@ -282,6 +298,8 @@ struct Info {
|
||||
/// `getShadeBrightness` (ambient occlusion contribution).
|
||||
shade: f32,
|
||||
emission: u8,
|
||||
/// `hdr_tier`.
|
||||
tier: u8,
|
||||
/// `emissiveRendering`: drawn and sampled at full brightness.
|
||||
emissive: bool,
|
||||
fluid: FluidState,
|
||||
@@ -450,6 +468,7 @@ impl MeshTables {
|
||||
permeable: !s.solid_render || s.dampening == 0,
|
||||
shade,
|
||||
emission: s.emission,
|
||||
tier: hdr_tier(name, s.emission),
|
||||
emissive,
|
||||
fluid: FluidState::from_block(state)?,
|
||||
model: s.render_shape == RenderShape::Model,
|
||||
@@ -513,6 +532,34 @@ impl MeshTables {
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
|
||||
}
|
||||
/// `BlockColors` for each of the state's tint indices at `pos`, as the
|
||||
/// chunk mesher tints them (grass blocks included): `None` for an
|
||||
/// untinted (-1) layer. For block models drawn outside the chunk mesh.
|
||||
pub fn block_tints(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
biomes: &Biomes,
|
||||
zoom: BiomeZoom,
|
||||
blend_radius: i32,
|
||||
) -> impl Fn(i32) -> Option<u32> + use<> {
|
||||
let sources = self.info.get(state.0 as usize).map(|info| {
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let local = [x & 15, y & 15, z & 15];
|
||||
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
|
||||
(colors, i32::from(info.tint_count))
|
||||
});
|
||||
move |index| {
|
||||
let (colors, count) = sources?;
|
||||
if index < 0 || index >= count {
|
||||
return None;
|
||||
}
|
||||
colors[(index as usize).min(1)]
|
||||
}
|
||||
}
|
||||
/// `BlockState.getOffset` for blocks with a random model offset.
|
||||
pub fn offset(&self, state: BlockStateId, x: i32, z: i32) -> Option<[f64; 3]> {
|
||||
offset(self.info.get(state.0 as usize)?, x, z)
|
||||
@@ -580,13 +627,37 @@ impl Padded {
|
||||
}
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold the light its face emits, for
|
||||
/// HDR output's highlights (`gfx/hdr.rs`): 15 minus the level, so the usual 0xFF means none.
|
||||
/// The byte's other four bits stay set.
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
|
||||
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, so the usual 0xFF means none. The
|
||||
/// byte's other four bits stay set.
|
||||
pub const EMISSION_BITS: u32 = 0x0F00_0000;
|
||||
/// `color` marked as emitting light `level` (0 to 15).
|
||||
pub fn emitting(color: u32, level: u8) -> u32 {
|
||||
color & !EMISSION_BITS | u32::from(15 - level.min(15)) << 24
|
||||
/// HDR tiers, by the kind of light rather than its level: a candle's flame is as bright as
|
||||
/// a torch's, it only lights less around it. 0 stays as in SDR.
|
||||
pub const LOW: u8 = 1;
|
||||
pub const INCANDESCENT: u8 = 2;
|
||||
pub const BRIGHT: u8 = 3;
|
||||
/// The sun and lightning.
|
||||
pub const VERY_BRIGHT: u8 = 4;
|
||||
/// `color` marked as shining at `tier` (0 to `VERY_BRIGHT`).
|
||||
pub fn emitting(color: u32, tier: u8) -> u32 {
|
||||
color & !EMISSION_BITS | u32::from(15 - tier.min(VERY_BRIGHT)) << 24
|
||||
}
|
||||
/// The HDR tier of block `name` (no namespace), which emits light `level`.
|
||||
pub fn hdr_tier(name: &str, level: u8) -> u8 {
|
||||
match name {
|
||||
_ if level == 0 => 0,
|
||||
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
|
||||
| "soul_torch" | "soul_wall_torch" | "soul_fire" => BRIGHT,
|
||||
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
|
||||
| "sea_lantern" => INCANDESCENT,
|
||||
"redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW,
|
||||
// Its flame is a particle.
|
||||
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
|
||||
// A faint glow in the dark, as in SDR.
|
||||
"glow_lichen" => 0,
|
||||
_ if level >= 10 => INCANDESCENT,
|
||||
_ => LOW,
|
||||
}
|
||||
}
|
||||
const UNSET: u32 = u32::MAX;
|
||||
const NO_BIOME: u16 = u16::MAX;
|
||||
@@ -864,6 +935,7 @@ impl Mesher<'_> {
|
||||
state: &Info,
|
||||
) {
|
||||
let q = &face.quad;
|
||||
let tier = state.tier.max(hdr_tier("", q.light_emission));
|
||||
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
|
||||
let directional = lighting::cardinal(self.nether_shading, shade_face);
|
||||
// Grey level per vertex before tinting, and vanilla light coords.
|
||||
@@ -903,7 +975,7 @@ impl Mesher<'_> {
|
||||
} else {
|
||||
face.layer as u16
|
||||
},
|
||||
emitting(pack_rgb(rgb), state.emission.max(q.light_emission)),
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -227,8 +227,11 @@ pub(super) fn tesselate(
|
||||
} else {
|
||||
0xFF_FFFF
|
||||
};
|
||||
// Lava emits light level 15.
|
||||
let emission = if water { 0 } else { 15 };
|
||||
let emission = if water {
|
||||
0
|
||||
} else {
|
||||
crate::mesher::INCANDESCENT
|
||||
};
|
||||
let color = |by: f32| {
|
||||
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
|
||||
crate::mesher::emitting(pack_rgb(rgb), emission)
|
||||
|
||||
@@ -54,3 +54,40 @@ fn terrain_particles_use_the_particle_sprite_and_vanilla_tint_sources() {
|
||||
assert_eq!(tint("minecraft:pink_petals"), None);
|
||||
assert_eq!(tint("minecraft:stone"), None);
|
||||
}
|
||||
|
||||
/// Blocks drawn outside the chunk mesh (moved by a piston) take the chunk
|
||||
/// mesher's per-layer tints: a grass block's top and side overlay are tinted
|
||||
/// as in the world, not left gray like its particles.
|
||||
#[test]
|
||||
fn block_tints_match_the_chunk_mesh_for_grass_blocks() {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("horizon-resources.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap();
|
||||
let center = Chunk::empty(0, 0);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
let tints = |name| {
|
||||
tables.block_tints(
|
||||
state(name),
|
||||
[5, 70, 9],
|
||||
®ion,
|
||||
&biomes,
|
||||
BiomeZoom::new(0),
|
||||
2,
|
||||
)
|
||||
};
|
||||
let grass = tints("minecraft:grass_block");
|
||||
assert!(grass(0).is_some());
|
||||
assert_eq!(grass(-1), None);
|
||||
assert_eq!(tints("minecraft:spruce_leaves")(0), Some(0x61_9961));
|
||||
assert_eq!(tints("minecraft:stone")(0), None);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ fn info(solid: bool) -> Info {
|
||||
permeable: false,
|
||||
shade: 1.0,
|
||||
emission: 0,
|
||||
tier: 0,
|
||||
emissive: false,
|
||||
fluid: FluidState::EMPTY,
|
||||
model: false,
|
||||
|
||||
@@ -87,6 +87,9 @@ const MODEL_PARTS: [&str; 7] = [
|
||||
];
|
||||
const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"];
|
||||
const GRAPHICS_PRESET: &[&str] = &["fast", "fancy", "fabulous", "custom"];
|
||||
/// Horizon's render distance cap (vanilla stops at 32): the server protocol's
|
||||
/// view distance tops out at 127 anyway.
|
||||
pub const MAX_RENDER_DISTANCE: i32 = 128;
|
||||
const INACTIVITY: &[&str] = &["minimized", "afk"];
|
||||
const CLOUDS: &[&str] = &["false", "fast", "true"];
|
||||
const MAIN_HAND: &[&str] = &["left", "right"];
|
||||
@@ -159,7 +162,7 @@ static TABLE: LazyLock<Vec<Def>> = LazyLock::new(|| {
|
||||
b("reducedDebugInfo", false),
|
||||
def("renderClouds", O(Clouds), V::Int(2)),
|
||||
i("cloudRange", 2, 128, 128),
|
||||
i("renderDistance", 2, 64, 12),
|
||||
i("renderDistance", 2, MAX_RENDER_DISTANCE, 12),
|
||||
i("simulationDistance", 5, 32, 12),
|
||||
u("screenEffectScale", 1.0),
|
||||
def("soundDevice", O(AnyStr), V::Str(String::new())),
|
||||
|
||||
@@ -74,6 +74,27 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
|
||||
Opt::HighContrastBlockOutlines,
|
||||
];
|
||||
|
||||
/// `ControlsScreen.options`: the controls added by Java Edition 26.3, in
|
||||
/// their vanilla order. The macOS-only Control-click switch is omitted on
|
||||
/// other platforms just as `InputQuirks.EMULATE_RIGHT_CLICK_WITH_CTRL_KEY`
|
||||
/// does in Java.
|
||||
pub(crate) fn vanilla_control_options() -> Vec<Opt> {
|
||||
let mut options = vec![
|
||||
Opt::ToggleCrouch,
|
||||
Opt::ToggleSprint,
|
||||
Opt::ToggleAttack,
|
||||
Opt::ToggleUse,
|
||||
Opt::AutoJump,
|
||||
Opt::SprintWindow,
|
||||
Opt::QuitShortcuts,
|
||||
];
|
||||
if cfg!(target_os = "macos") {
|
||||
options.push(Opt::CtrlClickEmulatesRightClick);
|
||||
}
|
||||
options.push(Opt::OperatorItemsTab);
|
||||
options
|
||||
}
|
||||
|
||||
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
|
||||
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
|
||||
Opt::Sensitivity,
|
||||
@@ -104,6 +125,11 @@ impl Opt {
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the creative tabs, the quit keys and the mouse
|
||||
// don't read them yet.
|
||||
| Opt::OperatorItemsTab
|
||||
| Opt::QuitShortcuts
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
}
|
||||
fn file_key(self) -> Option<&'static str> {
|
||||
@@ -128,6 +154,9 @@ impl Opt {
|
||||
Opt::RotateWithMinecarts => "rotateWithMinecart",
|
||||
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
|
||||
Opt::MusicFrequency => "musicFrequency",
|
||||
Opt::OperatorItemsTab => "operatorItemsTab",
|
||||
Opt::QuitShortcuts => "quitShortcuts",
|
||||
Opt::CtrlClickEmulatesRightClick => "ctrlClickEmulatesRightClick",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -171,7 +200,13 @@ impl Opt {
|
||||
Opt::MenuBlur => "options.accessibility.menu_background_blurriness",
|
||||
Opt::ToggleCrouch => "key.sneak",
|
||||
Opt::ToggleSprint => "key.sprint",
|
||||
Opt::ToggleAttack => "key.attack",
|
||||
Opt::ToggleUse => "key.use",
|
||||
Opt::AutoJump => "options.autoJump",
|
||||
Opt::SprintWindow => "options.sprintWindow",
|
||||
Opt::QuitShortcuts => "options.quitShortcuts",
|
||||
Opt::CtrlClickEmulatesRightClick => "options.ctrlClickEmulatesRightClick",
|
||||
Opt::OperatorItemsTab => "options.operatorItemsTab",
|
||||
Opt::ChatVisibility => "options.chat.visibility",
|
||||
Opt::ChatColors => "options.chat.color",
|
||||
Opt::ChatLinks => "options.chat.links",
|
||||
@@ -239,7 +274,11 @@ impl Opt {
|
||||
| Opt::GlintStrength
|
||||
| Opt::PanoramaSpeed => Kind::Slider(s.file.double(self.file_key().unwrap()) as f32),
|
||||
Opt::Fov => unit(s.fov, 30.0, 110.0),
|
||||
Opt::RenderDistance => unit(s.render_distance as f32, 2.0, 64.0),
|
||||
Opt::RenderDistance => unit(
|
||||
s.render_distance as f32,
|
||||
2.0,
|
||||
crate::options_file::MAX_RENDER_DISTANCE as f32,
|
||||
),
|
||||
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
|
||||
Opt::Sensitivity => Kind::Slider(s.sensitivity),
|
||||
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
|
||||
@@ -269,6 +308,7 @@ impl Opt {
|
||||
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
|
||||
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
@@ -317,6 +357,14 @@ impl Opt {
|
||||
}
|
||||
}
|
||||
Opt::PanoramaSpeed => percent(c, s.file.double("panoramaScrollSpeed") as f32),
|
||||
Opt::SprintWindow => {
|
||||
let value = if s.sprint_window == 0 {
|
||||
tr("options.off")
|
||||
} else {
|
||||
Text::tr("options.value", &[&s.sprint_window])
|
||||
};
|
||||
generic(c, value)
|
||||
}
|
||||
Opt::Fov => match s.fov as i32 {
|
||||
70 => generic(c, tr("options.fov.min")),
|
||||
110 => generic(c, tr("options.fov.max")),
|
||||
@@ -514,7 +562,20 @@ impl Opt {
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::ToggleAttack => tr(if s.toggle_attack {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::ToggleUse => tr(if s.toggle_use {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::AutoJump => on_off(s.auto_jump),
|
||||
Opt::QuitShortcuts => on_off(s.quit_shortcuts),
|
||||
Opt::CtrlClickEmulatesRightClick => on_off(s.ctrl_click_emulates_right_click),
|
||||
Opt::OperatorItemsTab => on_off(s.operator_items_tab),
|
||||
Opt::ChatVisibility => tr([
|
||||
"options.chat.visibility.full",
|
||||
"options.chat.visibility.system",
|
||||
@@ -586,7 +647,10 @@ impl Opt {
|
||||
)
|
||||
}
|
||||
Opt::Fov => s.fov = step(30.0, 110.0),
|
||||
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
|
||||
Opt::RenderDistance => {
|
||||
s.render_distance =
|
||||
step(2.0, crate::options_file::MAX_RENDER_DISTANCE as f32) as i32
|
||||
}
|
||||
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
|
||||
Opt::Sensitivity => s.sensitivity = v,
|
||||
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = 0.01 + v * 9.99,
|
||||
@@ -613,6 +677,7 @@ impl Opt {
|
||||
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
|
||||
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -704,7 +769,14 @@ impl Opt {
|
||||
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
|
||||
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
|
||||
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
|
||||
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
|
||||
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
|
||||
Opt::AutoJump => s.auto_jump = !s.auto_jump,
|
||||
Opt::QuitShortcuts => s.quit_shortcuts = !s.quit_shortcuts,
|
||||
Opt::CtrlClickEmulatesRightClick => {
|
||||
s.ctrl_click_emulates_right_click = !s.ctrl_click_emulates_right_click
|
||||
}
|
||||
Opt::OperatorItemsTab => s.operator_items_tab = !s.operator_items_tab,
|
||||
Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3),
|
||||
Opt::ChatColors => s.chat_colors = !s.chat_colors,
|
||||
Opt::ChatLinks => s.chat_links = !s.chat_links,
|
||||
@@ -923,13 +995,19 @@ pub enum Event {
|
||||
ButtonClick,
|
||||
/// Close the options screens (back to the pause menu or title).
|
||||
Close,
|
||||
/// Settings changed: apply them.
|
||||
/// Settings changed: apply and save them.
|
||||
Changed,
|
||||
/// A slider is being dragged: apply settings live but defer saving.
|
||||
SliderDrag,
|
||||
/// The language changed (reload translations).
|
||||
Language(String),
|
||||
/// The pack selection changed (reload resources).
|
||||
Packs(Vec<String>),
|
||||
/// Create World's Data Packs screen closed with a new selection.
|
||||
DataPacks(Vec<String>),
|
||||
OpenPackFolder,
|
||||
/// Data Packs' Open Pack Folder: create the folder if needed and open it.
|
||||
OpenDataPackFolder(std::path::PathBuf),
|
||||
OpenModFolder,
|
||||
/// The Shader Packs page opened: list `shaderpacks/` into `shader_packs`.
|
||||
ListShaderPacks,
|
||||
@@ -992,6 +1070,13 @@ pub struct OptionsUi {
|
||||
pub pack_selection: Vec<String>,
|
||||
/// The open pack screen, made at its first frame.
|
||||
pub pack_screen: Option<crate::pack_screen::PackScreen>,
|
||||
/// The Packs page is Create World's Data Packs screen on this folder.
|
||||
data_pack_dir: Option<std::path::PathBuf>,
|
||||
/// The game's resources, for the built-in data pack's icon.
|
||||
data_pack_resources: std::path::PathBuf,
|
||||
/// The folder's entries when its packs were listed, and when they were
|
||||
/// last compared with it.
|
||||
data_pack_stamp: (Vec<(String, Option<std::time::SystemTime>)>, Instant),
|
||||
/// A reload failure, dismissed before returning to the current page.
|
||||
pub resource_error: Option<String>,
|
||||
/// When the pack screen's search box took the focus (its cursor blink).
|
||||
@@ -1026,6 +1111,9 @@ impl Default for OptionsUi {
|
||||
pack_infos: Vec::new(),
|
||||
pack_selection: Vec::new(),
|
||||
pack_screen: None,
|
||||
data_pack_dir: None,
|
||||
data_pack_resources: Default::default(),
|
||||
data_pack_stamp: (Vec::new(), Instant::now()),
|
||||
resource_error: None,
|
||||
pack_focus: Instant::now(),
|
||||
in_world: false,
|
||||
@@ -1133,9 +1221,48 @@ impl OptionsUi {
|
||||
pub fn open(&mut self, in_world: bool) {
|
||||
self.open_at(in_world, Page::Main);
|
||||
}
|
||||
/// Create World's Data Packs (`openDataPackSelectionScreen`): the Packs
|
||||
/// page on the new world's pack folder `dir`, `selected` chosen;
|
||||
/// `resources` holds the built-in pack's icon.
|
||||
pub fn open_data_packs(
|
||||
&mut self,
|
||||
dir: std::path::PathBuf,
|
||||
selected: Vec<String>,
|
||||
resources: std::path::PathBuf,
|
||||
) {
|
||||
self.open_at(false, Page::Packs);
|
||||
self.pack_infos = crate::datapack::screen_packs(&dir, &resources);
|
||||
self.data_pack_resources = resources;
|
||||
self.pack_selection = selected;
|
||||
self.data_pack_stamp = (crate::datapack::folder_stamp(&dir), Instant::now());
|
||||
self.data_pack_dir = Some(dir);
|
||||
}
|
||||
/// `PackSelectionScreen.tick`'s watcher: once a second, the Data Packs
|
||||
/// folder's packs are listed again if it changed, the selection kept
|
||||
/// (the search text and scroll start over).
|
||||
fn watch_data_packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) {
|
||||
let Some(dir) = &self.data_pack_dir else {
|
||||
return;
|
||||
};
|
||||
if self.data_pack_stamp.1.elapsed().as_secs() < 1 {
|
||||
return;
|
||||
}
|
||||
let stamp = crate::datapack::folder_stamp(dir);
|
||||
let changed = stamp != self.data_pack_stamp.0;
|
||||
self.data_pack_stamp = (stamp, Instant::now());
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
self.pack_infos = crate::datapack::screen_packs(dir, &self.data_pack_resources);
|
||||
if let Some(p) = self.pack_screen.take() {
|
||||
self.pack_selection = p.commit();
|
||||
self.packs(atlas, size).changed = p.changed;
|
||||
}
|
||||
}
|
||||
/// Open a page whose Done/Escape returns directly to the calling screen.
|
||||
pub fn open_at(&mut self, in_world: bool, page: Page) {
|
||||
self.stack.clear();
|
||||
self.data_pack_dir = None;
|
||||
self.push(page);
|
||||
self.in_world = in_world;
|
||||
self.selected_key = None;
|
||||
@@ -1458,11 +1585,8 @@ impl OptionsUi {
|
||||
Act::Push(Page::KeyBinds),
|
||||
)),
|
||||
)];
|
||||
rows.extend(pairs(&[
|
||||
Opt::ToggleCrouch,
|
||||
Opt::ToggleSprint,
|
||||
Opt::AutoJump,
|
||||
]));
|
||||
let controls = vanilla_control_options();
|
||||
rows.extend(pairs(&controls));
|
||||
// Horizon's controller page, after vanilla's rows.
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
@@ -2169,6 +2293,7 @@ impl OptionsUi {
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
self.watch_data_packs(atlas, (w, h));
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
|
||||
let blink = self.pack_focus.elapsed().as_millis() as u64;
|
||||
let m = crate::screen_draw::Measure(&atlas.font);
|
||||
@@ -2427,6 +2552,7 @@ impl OptionsUi {
|
||||
match action {
|
||||
Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
@@ -2463,7 +2589,10 @@ impl OptionsUi {
|
||||
self.pack_focus = Instant::now();
|
||||
}
|
||||
match action {
|
||||
crate::pack_screen::Action::OpenFolder => events.push(Event::OpenPackFolder),
|
||||
crate::pack_screen::Action::OpenFolder => events.push(match &self.data_pack_dir {
|
||||
Some(dir) => Event::OpenDataPackFolder(dir.clone()),
|
||||
None => Event::OpenPackFolder,
|
||||
}),
|
||||
crate::pack_screen::Action::Done => events.extend(self.back(s)),
|
||||
crate::pack_screen::Action::None => {}
|
||||
}
|
||||
@@ -2507,7 +2636,7 @@ impl OptionsUi {
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.slider_value = Some((opt, v, Instant::now()));
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::Changed);
|
||||
events.push(Event::SliderDrag);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -2643,7 +2772,7 @@ impl OptionsUi {
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.slider_value = Some((opt, v, Instant::now()));
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::Changed);
|
||||
events.push(Event::SliderDrag);
|
||||
}
|
||||
WKind::LanguageRow { code, .. } => {
|
||||
if self.language_choice.as_deref() == Some(code.as_str()) {
|
||||
@@ -2676,7 +2805,7 @@ impl OptionsUi {
|
||||
let slider = self.dragging.take().is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
if slider || graphics_slider {
|
||||
vec![Event::ButtonClick]
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -2714,8 +2843,14 @@ impl OptionsUi {
|
||||
}
|
||||
None => {
|
||||
self.pack_focus = Instant::now();
|
||||
let title = if self.data_pack_dir.is_some() {
|
||||
"dataPack.title"
|
||||
} else {
|
||||
"resourcePack.title"
|
||||
};
|
||||
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
|
||||
&m,
|
||||
crate::text::Text::translate(title, &[]),
|
||||
w,
|
||||
h,
|
||||
self.in_world,
|
||||
@@ -2747,13 +2882,18 @@ impl OptionsUi {
|
||||
}
|
||||
Page::Packs => {
|
||||
// PackSelectionScreen.onClose: PackSelectionModel.commit.
|
||||
let data = self.data_pack_dir.take();
|
||||
if let Some(p) = self.pack_screen.take()
|
||||
&& p.changed
|
||||
{
|
||||
let ids = p.commit();
|
||||
s.resource_packs = ids.clone();
|
||||
self.pack_selection = ids.clone();
|
||||
events.push(Event::Packs(ids));
|
||||
if data.is_some() {
|
||||
events.push(Event::DataPacks(ids));
|
||||
} else {
|
||||
s.resource_packs = ids.clone();
|
||||
self.pack_selection = ids.clone();
|
||||
events.push(Event::Packs(ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -3049,10 +3189,48 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_match_java_26_3_option_order() {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Controls);
|
||||
let options =
|
||||
ui.list_rows(&Settings::default())
|
||||
.into_iter()
|
||||
.fold(Vec::new(), |mut options, row| {
|
||||
match row {
|
||||
Row::Big(opt) => options.push(opt),
|
||||
Row::Pair(a, b) => {
|
||||
options.push(a);
|
||||
options.extend(b);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
options
|
||||
});
|
||||
assert_eq!(options, vanilla_control_options());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprint_window_and_new_control_switches_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
set_slider(Opt::SprintWindow, &mut settings, 0.5);
|
||||
settings.operator_items_tab = true;
|
||||
settings.quit_shortcuts = false;
|
||||
settings.ctrl_click_emulates_right_click = true;
|
||||
settings.toggle_attack = true;
|
||||
settings.toggle_use = true;
|
||||
let loaded = Settings::parse(&settings.serialize());
|
||||
assert_eq!(loaded.sprint_window, 5);
|
||||
assert!(loaded.operator_items_tab);
|
||||
assert!(!loaded.quit_shortcuts);
|
||||
assert!(loaded.ctrl_click_emulates_right_click);
|
||||
assert!(loaded.toggle_attack && loaded.toggle_use);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_distance_slider_round_trips_through_extended_options_range() {
|
||||
let mut s = Settings::default();
|
||||
for (position, distance) in [(0.0, 2), (0.5, 33), (1.0, 64)] {
|
||||
for (position, distance) in [(0.0, 2), (0.5, 65), (1.0, 128)] {
|
||||
set_slider(Opt::RenderDistance, &mut s, position);
|
||||
assert_eq!(s.render_distance, distance);
|
||||
let reloaded = Settings::parse(&s.serialize());
|
||||
@@ -3063,10 +3241,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
let mut file = crate::options_file::OptionsFile::default();
|
||||
file.set_text("renderDistance", "64").unwrap();
|
||||
assert!(file.set_text("renderDistance", "65").is_err());
|
||||
file.set_text("renderDistance", "128").unwrap();
|
||||
assert!(file.set_text("renderDistance", "129").is_err());
|
||||
assert!(file.set_text("renderDistance", "1").is_err());
|
||||
assert_eq!(file.int("renderDistance"), 64);
|
||||
assert_eq!(file.int("renderDistance"), 128);
|
||||
}
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
@@ -3666,22 +3844,22 @@ mod tests {
|
||||
let (x, y) = (slider.x + slider.w - 1.0, slider.y + 1.0);
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
|
||||
vec![Event::Changed]
|
||||
vec![Event::SliderDrag]
|
||||
);
|
||||
assert_eq!(s.master_volume, 1.0);
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick]);
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
|
||||
assert!(ui.release().is_empty());
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
|
||||
vec![Event::Changed]
|
||||
vec![Event::SliderDrag]
|
||||
);
|
||||
for cx in [-100.0, 250.0, 1000.0] {
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
|
||||
vec![Event::Changed]
|
||||
vec![Event::SliderDrag]
|
||||
);
|
||||
}
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick]);
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
|
||||
assert!(
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], true, false)
|
||||
.is_empty()
|
||||
|
||||
@@ -318,6 +318,8 @@ pub enum Action {
|
||||
/// The pack screen and its state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PackScreen {
|
||||
/// `resourcePack.title`, or `dataPack.title` from Create World.
|
||||
pub title: Text,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub in_world: bool,
|
||||
@@ -342,6 +344,7 @@ impl PackScreen {
|
||||
/// `PackSelectionScreen.init`: the lists filled, the search box focused.
|
||||
pub fn new(
|
||||
m: &Measure,
|
||||
title: Text,
|
||||
width: i32,
|
||||
height: i32,
|
||||
in_world: bool,
|
||||
@@ -360,6 +363,7 @@ impl PackScreen {
|
||||
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
|
||||
.collect();
|
||||
let mut s = Self {
|
||||
title,
|
||||
width,
|
||||
height,
|
||||
in_world,
|
||||
@@ -382,7 +386,7 @@ impl PackScreen {
|
||||
}
|
||||
/// The header layout: title, drop info and search box, centred.
|
||||
fn header(&self, m: &Measure) -> [(i32, i32); 3] {
|
||||
let tw = m.width(&tr("resourcePack.title"));
|
||||
let tw = m.width(&self.title);
|
||||
let dw = m.width(&tr("pack.dropInfo"));
|
||||
let col = tw.max(dw).max(200);
|
||||
let frame = self.width.max(col);
|
||||
@@ -837,7 +841,7 @@ impl PackScreen {
|
||||
return out;
|
||||
}
|
||||
let [(tx, ty), (dx, dy), _] = self.header(m);
|
||||
d::string_widget(&mut out, m, &tr("resourcePack.title"), tx, ty, 0);
|
||||
d::string_widget(&mut out, m, &self.title, tx, ty, 0);
|
||||
d::string_widget(
|
||||
&mut out,
|
||||
m,
|
||||
@@ -1105,7 +1109,7 @@ mod icon_tests {
|
||||
pack("high_contrast", Some(contrast.clone())),
|
||||
pack("file/Iconless", None),
|
||||
];
|
||||
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
|
||||
let screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
|
||||
let mut draws = screen.draw(&m, (0, 0), 0);
|
||||
let mut patches = Vec::new();
|
||||
screen.bind_icons(&mut draws, &atlas, &mut patches);
|
||||
@@ -1152,7 +1156,8 @@ mod click_sound_tests {
|
||||
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
|
||||
.unwrap();
|
||||
let m = Measure(&atlas.font);
|
||||
let mut screen = PackScreen::new(&m, 480, 320, false, Vec::new(), &[]);
|
||||
let title = tr("resourcePack.title");
|
||||
let mut screen = PackScreen::new(&m, title, 480, 320, false, Vec::new(), &[]);
|
||||
assert!(!screen.button_at(&m, 0.0, 0.0));
|
||||
assert!(!screen.button_at(
|
||||
&m,
|
||||
|
||||
@@ -181,9 +181,15 @@ pub fn compatible(meta: &serde_json::Value) -> bool {
|
||||
/// `PackCompatibility.forVersion`: too old, too new, compatible, or unknown
|
||||
/// when the metadata names no format.
|
||||
pub fn compatibility(meta: &serde_json::Value) -> crate::pack_screen::Compat {
|
||||
compatibility_for(meta, RESOURCE_FORMAT)
|
||||
}
|
||||
/// `compatibility` against `current`, the resource or the data pack format.
|
||||
pub fn compatibility_for(
|
||||
meta: &serde_json::Value,
|
||||
current: (i64, i64),
|
||||
) -> crate::pack_screen::Compat {
|
||||
use crate::pack_screen::Compat;
|
||||
let pack = &meta["pack"];
|
||||
let current = RESOURCE_FORMAT;
|
||||
let range = if let (Some(min), Some(max)) = (
|
||||
format_of(&pack["min_format"]),
|
||||
format_of(&pack["max_format"]),
|
||||
@@ -275,6 +281,10 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
}
|
||||
/// A pack whose compatibility is against `format`.
|
||||
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
|
||||
let meta = read_meta(&source)?;
|
||||
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
|
||||
Some(Pack {
|
||||
@@ -284,7 +294,7 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
raw_description,
|
||||
description_style,
|
||||
compatible: compatible(&meta),
|
||||
compatibility: compatibility(&meta),
|
||||
compatibility: compatibility_for(&meta, format),
|
||||
overlays: overlays_of(&meta),
|
||||
filter: filter_of(&meta),
|
||||
source,
|
||||
@@ -305,7 +315,13 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
let mut user: Vec<Pack> = fs::read_dir(packs_dir)
|
||||
out.extend(folder(packs_dir, RESOURCE_FORMAT));
|
||||
out
|
||||
}
|
||||
/// The packs (folders and zips) in `dir`, by name, as `FolderRepositorySource`
|
||||
/// finds them; their compatibility is against `format`.
|
||||
pub fn folder(dir: &Path, format: (i64, i64)) -> Vec<Pack> {
|
||||
let mut user: Vec<Pack> = fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
@@ -319,12 +335,11 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
pack(format!("file/{name}"), Text::literal(name), source)
|
||||
pack_for(format!("file/{name}"), Text::literal(name), source, format)
|
||||
})
|
||||
.collect();
|
||||
user.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
out.extend(user);
|
||||
out
|
||||
user
|
||||
}
|
||||
/// A file a pack provides: its path (folder packs) and its name inside the pack.
|
||||
type Provided = (Option<PathBuf>, String);
|
||||
@@ -380,7 +395,11 @@ fn link(target: &Path, at: &Path) -> io::Result<()> {
|
||||
if target.is_dir() {
|
||||
Ok(())
|
||||
} else {
|
||||
fs::copy(target, at).map(|_| ())
|
||||
// The mirror only reads these files, and unix links them: a hardlink gives
|
||||
// the same view without writing the 639 MB prepared root again, which was
|
||||
// 33 s of a cold Windows launch with a pack on. Another volume (or a
|
||||
// filesystem without hardlinks) still copies.
|
||||
fs::hard_link(target, at).or_else(|_| fs::copy(target, at).map(|_| ()))
|
||||
}
|
||||
}
|
||||
/// Build `out` as `vanilla` with the packs' files layered on top (packs in
|
||||
@@ -417,6 +436,22 @@ fn build_overlay_uncached(
|
||||
files.insert(name, (i, provided));
|
||||
}
|
||||
}
|
||||
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
|
||||
// supplies the file or one above it; vanilla's own sits below every pack.
|
||||
let hidden: BTreeSet<String> = files
|
||||
.iter()
|
||||
.filter(|(name, _)| !name.ends_with(".mcmeta"))
|
||||
.filter_map(|(name, (owner, _))| {
|
||||
let meta = format!("{name}.mcmeta");
|
||||
match files.get(&meta) {
|
||||
Some((meta_owner, _)) if meta_owner >= owner => None,
|
||||
_ => Some(meta),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for name in &hidden {
|
||||
files.remove(name);
|
||||
}
|
||||
// Vanilla sits below every pack, so filters hide its files too.
|
||||
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
|
||||
let touched_dirs: BTreeSet<String> = files
|
||||
@@ -468,6 +503,7 @@ fn build_overlay_uncached(
|
||||
vanilla: &Path,
|
||||
out: &Path,
|
||||
touched: &BTreeSet<String>,
|
||||
hidden: &BTreeSet<String>,
|
||||
files: &BTreeMap<String, (usize, Provided)>,
|
||||
merged: &BTreeMap<String, Vec<u8>>,
|
||||
filters: &[&Filter],
|
||||
@@ -504,6 +540,9 @@ fn build_overlay_uncached(
|
||||
}
|
||||
for name in names {
|
||||
let child = format!("{prefix}{name}");
|
||||
if hidden.contains(&child) {
|
||||
continue;
|
||||
}
|
||||
if let Some(bytes) = merged.get(&child) {
|
||||
fs::write(out.join(&child), bytes)?;
|
||||
} else if let Some((owner, (path, inner))) = files.get(&child) {
|
||||
@@ -528,6 +567,7 @@ fn build_overlay_uncached(
|
||||
vanilla,
|
||||
out,
|
||||
touched,
|
||||
hidden,
|
||||
files,
|
||||
merged,
|
||||
filters,
|
||||
@@ -551,6 +591,7 @@ fn build_overlay_uncached(
|
||||
vanilla,
|
||||
out,
|
||||
&touched_dirs,
|
||||
&hidden,
|
||||
&files,
|
||||
&merged,
|
||||
&filters,
|
||||
@@ -741,7 +782,9 @@ impl SelectionModel {
|
||||
/// `available` in any order; `selected` as options.txt lists them,
|
||||
/// bottom first.
|
||||
pub fn new(mut available: Vec<Selectable>, selected: &[String]) -> Self {
|
||||
// PackRepository.available is a TreeMap by id (String.compareTo).
|
||||
// PackRepository.discoverAvailable: every source's packs go into one
|
||||
// TreeMap by id (String.compareTo), so `file/` packs sort among the
|
||||
// built-in ones.
|
||||
available.sort_by(|a, b| a.id.encode_utf16().cmp(b.id.encode_utf16()));
|
||||
available.dedup_by(|a, b| a.id == b.id);
|
||||
let mut m = Self {
|
||||
|
||||
@@ -2212,11 +2212,7 @@ impl ParticleEngine {
|
||||
};
|
||||
q.gravity = 3.0e-6;
|
||||
q.velocity = [v[0], v[1] + f64::from(r.next_float() / 500.0), v[2]];
|
||||
q.rgba[3] = if ty == T::CampfireSignalSmoke {
|
||||
0.95
|
||||
} else {
|
||||
0.9
|
||||
};
|
||||
// Alpha stays at SingleQuadParticle's default of 1.0; the tick fades it.
|
||||
q.behaviour = Behaviour::Campfire;
|
||||
q
|
||||
}
|
||||
@@ -3818,7 +3814,7 @@ impl ParticleEngine {
|
||||
layer: if layer {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
u32::from(hdr_tier(p.ty)) << 16
|
||||
},
|
||||
color,
|
||||
light,
|
||||
@@ -3830,6 +3826,27 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames and
|
||||
/// glowing drops, not their smoke. Only opaque-layer particles are marked.
|
||||
fn hdr_tier(ty: ParticleType) -> u8 {
|
||||
use ParticleType as P;
|
||||
match ty {
|
||||
P::Flame
|
||||
| P::SoulFireFlame
|
||||
| P::CopperFireFlame
|
||||
| P::SmallFlame
|
||||
| P::Lava
|
||||
| P::DrippingLava
|
||||
| P::FallingLava
|
||||
| P::LandingLava
|
||||
| P::DrippingDripstoneLava
|
||||
| P::FallingDripstoneLava => crate::mesher::INCANDESCENT,
|
||||
P::DrippingObsidianTear | P::FallingObsidianTear | P::LandingObsidianTear => {
|
||||
crate::mesher::LOW
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
|
||||
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
|
||||
if (a[0] - a[1]).abs() < 1.0e-5 {
|
||||
|
||||
@@ -49,6 +49,9 @@ pub fn sound_source(event: &str) -> usize {
|
||||
index("ambient")
|
||||
} else if e.starts_with("music_disc.") {
|
||||
index("record")
|
||||
} else if e.starts_with("item.goat_horn.") {
|
||||
// InstrumentItem.play: SoundSource.RECORDS.
|
||||
index("record")
|
||||
} else if e.starts_with("music.") {
|
||||
index("music")
|
||||
} else if e.starts_with("weather.") || e.starts_with("entity.lightning_bolt") {
|
||||
@@ -173,6 +176,12 @@ pub struct Settings {
|
||||
pub toggle_use: bool,
|
||||
/// Taps of the sprint key counted as double taps (sprintWindow).
|
||||
pub sprint_window: i32,
|
||||
/// Show the operator items tab in a creative inventory.
|
||||
pub operator_items_tab: bool,
|
||||
/// Allow platform quit shortcuts to close the game window.
|
||||
pub quit_shortcuts: bool,
|
||||
/// On macOS, turn Control-click into a right click.
|
||||
pub ctrl_click_emulates_right_click: bool,
|
||||
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
|
||||
pub horizon: crate::graphics::Graphics,
|
||||
/// Controller settings (`horizon-controller.txt`, Horizon's own).
|
||||
@@ -255,6 +264,9 @@ impl Default for Settings {
|
||||
toggle_attack: false,
|
||||
toggle_use: false,
|
||||
sprint_window: 7,
|
||||
operator_items_tab: false,
|
||||
quit_shortcuts: true,
|
||||
ctrl_click_emulates_right_click: false,
|
||||
horizon: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
@@ -417,6 +429,9 @@ impl Settings {
|
||||
toggle_attack: f.bool("toggleAttack"),
|
||||
toggle_use: f.bool("toggleUse"),
|
||||
sprint_window: f.int("sprintWindow"),
|
||||
operator_items_tab: f.bool("operatorItemsTab"),
|
||||
quit_shortcuts: f.bool("quitShortcuts"),
|
||||
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
|
||||
horizon: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
@@ -507,6 +522,13 @@ impl Settings {
|
||||
put_b(&mut f, "toggleAttack", self.toggle_attack);
|
||||
put_b(&mut f, "toggleUse", self.toggle_use);
|
||||
put_i(&mut f, "sprintWindow", self.sprint_window);
|
||||
put_b(&mut f, "operatorItemsTab", self.operator_items_tab);
|
||||
put_b(&mut f, "quitShortcuts", self.quit_shortcuts);
|
||||
put_b(
|
||||
&mut f,
|
||||
"ctrlClickEmulatesRightClick",
|
||||
self.ctrl_click_emulates_right_click,
|
||||
);
|
||||
put_b(&mut f, "autoJump", self.auto_jump);
|
||||
put_s(&mut f, "lang", &self.language);
|
||||
put_b(&mut f, "forceUnicodeFont", self.force_unicode);
|
||||
@@ -653,7 +675,13 @@ pub enum Opt {
|
||||
MenuBlur,
|
||||
ToggleCrouch,
|
||||
ToggleSprint,
|
||||
ToggleAttack,
|
||||
ToggleUse,
|
||||
AutoJump,
|
||||
SprintWindow,
|
||||
QuitShortcuts,
|
||||
CtrlClickEmulatesRightClick,
|
||||
OperatorItemsTab,
|
||||
ChatVisibility,
|
||||
ChatColors,
|
||||
ChatLinks,
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
//! instance's random.
|
||||
use horizon_core::random::RandomSource;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// `SoundManager.EMPTY_SOUND_LOCATION`.
|
||||
pub const EMPTY: &str = "minecraft:empty";
|
||||
@@ -224,6 +228,34 @@ fn parse_sound(v: &Value) -> Option<Sound> {
|
||||
sentinel: Sentinel::None,
|
||||
})
|
||||
}
|
||||
/// The files under a namespace's `sounds/`, as `sounds/<path>` with `/`.
|
||||
fn sound_files(namespace: &Path) -> HashSet<String> {
|
||||
let mut files = HashSet::new();
|
||||
let mut stack = vec![namespace.join("sounds")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
// The listing's own type; a link is followed, as `is_file` does.
|
||||
let Ok(kind) = e.file_type() else {
|
||||
continue;
|
||||
};
|
||||
let (dir, file) = if kind.is_symlink() {
|
||||
(path.is_dir(), path.is_file())
|
||||
} else {
|
||||
(kind.is_dir(), kind.is_file())
|
||||
};
|
||||
if dir {
|
||||
stack.push(path);
|
||||
} else if file && let Ok(rel) = path.strip_prefix(namespace) {
|
||||
files.insert(rel.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
impl SoundRegistry {
|
||||
/// Every `assets/<namespace>/sounds.json` under a resource root, files
|
||||
/// validated against `assets/<namespace>/sounds/`.
|
||||
@@ -236,13 +268,19 @@ impl SoundRegistry {
|
||||
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
|
||||
.collect();
|
||||
namespaces.sort();
|
||||
// Each namespace's sound files are listed once, as the resource manager
|
||||
// lists a pack, instead of one file lookup per sound (thousands).
|
||||
let files: HashMap<&str, HashSet<String>> = namespaces
|
||||
.iter()
|
||||
.map(|ns| (ns.as_str(), sound_files(&assets.join(ns))))
|
||||
.collect();
|
||||
let exists = |ns: &str, path: &str| files.get(ns).is_some_and(|f| f.contains(path));
|
||||
let mut registry = Self::default();
|
||||
for namespace in namespaces {
|
||||
let Ok(text) = fs::read(assets.join(&namespace).join("sounds.json")) else {
|
||||
for namespace in &namespaces {
|
||||
let Ok(text) = fs::read(assets.join(namespace).join("sounds.json")) else {
|
||||
continue;
|
||||
};
|
||||
let exists = |ns: &str, path: &str| assets.join(ns).join(path).is_file();
|
||||
registry.add_file(&namespace, &text, &exists);
|
||||
registry.add_file(namespace, &text, &exists);
|
||||
}
|
||||
registry
|
||||
}
|
||||
@@ -369,6 +407,24 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
#[test]
|
||||
fn load_keeps_the_sound_files_a_root_holds() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sounds-{}", std::process::id()));
|
||||
let ns = root.join("assets/minecraft");
|
||||
fs::create_dir_all(ns.join("sounds/block/note")).unwrap();
|
||||
fs::write(ns.join("sounds/block/note/harp.ogg"), b"").unwrap();
|
||||
fs::write(
|
||||
ns.join("sounds.json"),
|
||||
br#"{"note": {"sounds": ["block/note/harp", "block/note/gone"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let r = SoundRegistry::load(&root);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let sounds = &r.get("minecraft:note").unwrap().sounds;
|
||||
let kept: Vec<&str> = sounds.iter().map(|s| s.location.as_str()).collect();
|
||||
assert_eq!(kept, ["minecraft:block/note/harp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_weigh_as_much_as_their_event() {
|
||||
let mut r = SoundRegistry::default();
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::{
|
||||
chunk::{Chunk, SECTIONS},
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
light::{
|
||||
LightChanges, LightScratch, LightTables, apply_lighting, apply_relighting_masked,
|
||||
light_chunk_masked_with,
|
||||
},
|
||||
mesher::{MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
world::World,
|
||||
};
|
||||
@@ -99,6 +102,9 @@ pub trait LodSampler: Send {
|
||||
fn drain_details(&mut self) -> Vec<LodDetail> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
|
||||
/// approximate wide cells.
|
||||
fn set_approximate(&mut self, _approximate: bool) {}
|
||||
fn sample(&mut self, x: i32, z: i32) -> Option<crate::lod::Surface>;
|
||||
fn column(&mut self, x: i32, z: i32) -> crate::lod::Column {
|
||||
crate::lod::Column::from_surface(self.sample(x, z))
|
||||
@@ -164,7 +170,7 @@ enum Job {
|
||||
Decorate(i32, i32, Box<[Proto; 9]>),
|
||||
Finish(Proto),
|
||||
Light(i32, i32, Box<Region>, u64, bool),
|
||||
Mesh(i32, i32, i32, Box<Region>, u64, MeshSettings),
|
||||
Mesh(i32, i32, i32, Arc<Region>, u64, MeshSettings),
|
||||
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
|
||||
/// off the main thread like vanilla's IOWorker.
|
||||
Save(Vec<((i32, i32), Proto)>),
|
||||
@@ -174,7 +180,7 @@ pub enum Done {
|
||||
/// A proto chunk, and whether it was decorated (loaded so from the save).
|
||||
Proto(i32, i32, Proto, bool),
|
||||
Decorated(i32, i32, Box<[Proto; 9]>),
|
||||
Lit(i32, i32, Vec<crate::chunk::Light>, u64),
|
||||
Lit(i32, i32, Vec<crate::chunk::Light>, u32, u64),
|
||||
Meshed(i32, i32, i32, SectionMesh, u64),
|
||||
Saved(Vec<((i32, i32), Proto)>),
|
||||
}
|
||||
@@ -262,6 +268,9 @@ pub struct Streaming {
|
||||
pub radius: i32,
|
||||
/// `/forceload`ed chunks: kept loaded whatever the distance.
|
||||
pub forced: HashSet<(i32, i32)>,
|
||||
/// `forced`'s bounding box, refreshed each `update`: one compare rejects
|
||||
/// most columns before the per-forced-chunk scan.
|
||||
forced_bounds: Option<(i32, i32, i32, i32)>,
|
||||
/// No work is scheduled while paused (title screen).
|
||||
pub paused: bool,
|
||||
/// The level loading screen is up: work goes ring by ring (`Ranges::rings`).
|
||||
@@ -421,6 +430,7 @@ impl GenerationScan {
|
||||
radius: input.ranges.radius,
|
||||
extra: input.ranges.extra,
|
||||
forced: input.ranges.forced,
|
||||
forced_bounds: input.ranges.forced_bounds,
|
||||
cover: input.ranges.cover,
|
||||
band: input.ranges.band,
|
||||
};
|
||||
@@ -475,6 +485,8 @@ impl Streaming {
|
||||
// the worker across mesh jobs, like vanilla's per-thread cache:
|
||||
// cloning it per job copied every state and face shape.
|
||||
let mut faces: Option<(Arc<MeshTables>, FaceVisibility)> = None;
|
||||
// Light scratch stays with the worker across jobs.
|
||||
let mut light_scratch = LightScratch::default();
|
||||
loop {
|
||||
let job = {
|
||||
let mut queue = shared.queue.lock().unwrap();
|
||||
@@ -511,7 +523,13 @@ impl Streaming {
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
Done::Lit(x, z, light_chunk(&borrow(®ion), &light, sky), seq)
|
||||
let (sections, changed) = light_chunk_masked_with(
|
||||
&borrow(®ion),
|
||||
&light,
|
||||
sky,
|
||||
&mut light_scratch,
|
||||
);
|
||||
Done::Lit(x, z, sections, changed, seq)
|
||||
}
|
||||
Job::Mesh(x, sy, z, region, seq, settings) => {
|
||||
let context = shared.context.read().unwrap().clone();
|
||||
@@ -585,6 +603,7 @@ impl Streaming {
|
||||
center: (0, 0),
|
||||
radius: 8,
|
||||
forced: HashSet::new(),
|
||||
forced_bounds: None,
|
||||
paused: false,
|
||||
loading: false,
|
||||
sky_light: true,
|
||||
@@ -700,12 +719,14 @@ impl Streaming {
|
||||
/// been generated.
|
||||
fn requeue(&mut self) {
|
||||
let basis = (self.center, self.loading, self.band);
|
||||
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
|
||||
if self.queue_basis.0 == basis && same_areas(&self.queue_basis.1, &self.extra) {
|
||||
return;
|
||||
}
|
||||
self.queue_basis = (basis, self.extra.clone());
|
||||
let mut queue = self.shared.queue.lock().unwrap();
|
||||
rank_again(&mut queue, |x, z| self.distance(x, z));
|
||||
self.queue_basis = (basis, canonical_areas(&self.extra));
|
||||
rerank_queue(&self.shared.queue, |x, z| self.distance(x, z));
|
||||
// Workers that found the heap empty while it was swapped out must
|
||||
// pick the restored jobs up even if nothing new is submitted.
|
||||
self.shared.ready.notify_all();
|
||||
}
|
||||
fn receive_decorated(&mut self, x: i32, z: i32, region: [Proto; 9]) {
|
||||
self.generation_scan.exhausted = false;
|
||||
@@ -783,14 +804,7 @@ impl Streaming {
|
||||
world.dirty_sections.insert((key.0, sy, key.1));
|
||||
}
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let n = (key.0 + dx, key.1 + dz);
|
||||
if !loaded || world.chunks.get(&n).is_some_and(|c| !c.light_loaded) {
|
||||
world.dirty_light.insert(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
mark_dirty_light(world, key, loaded, &self.lit);
|
||||
}
|
||||
/// The order jobs around the player run in, lowest first.
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
@@ -816,7 +830,7 @@ impl Streaming {
|
||||
}
|
||||
Some(Box::new(region))
|
||||
}
|
||||
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
fn region(world: &World, x: i32, z: i32) -> Option<Arc<Region>> {
|
||||
let mut region: Region = Default::default();
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
@@ -828,7 +842,7 @@ impl Streaming {
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Box::new(region))
|
||||
Some(Arc::new(region))
|
||||
}
|
||||
pub fn is_lit(&self, x: i32, z: i32) -> bool {
|
||||
self.lit.contains(&(x, z))
|
||||
@@ -898,6 +912,7 @@ impl Streaming {
|
||||
if self.paused {
|
||||
return (uploads, Vec::new());
|
||||
}
|
||||
self.forced_bounds = forced_bounds(&self.forced);
|
||||
ExtraCover::refresh(&mut self.cover, &self.extra);
|
||||
// The sweeps below walk every loaded column and proto chunk. Run them
|
||||
// at once when the view moves and every eighth tick otherwise: a
|
||||
@@ -925,7 +940,7 @@ impl Streaming {
|
||||
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
Done::Saved(protos) => self.receive_saved(protos),
|
||||
Done::Lit(x, z, sections, seq) => {
|
||||
Done::Lit(x, z, sections, mask, seq) => {
|
||||
if self.lighting.get(&(x, z)) != Some(&seq) {
|
||||
continue;
|
||||
}
|
||||
@@ -942,7 +957,7 @@ impl Streaming {
|
||||
if first {
|
||||
apply_lighting(chunk, sections);
|
||||
} else {
|
||||
apply_relighting(chunk, sections, &mut changed);
|
||||
apply_relighting_masked(chunk, sections, mask, &mut changed);
|
||||
}
|
||||
// Saved unlit (at the edge of what was loaded), or its light
|
||||
// changed: saved again.
|
||||
@@ -1077,7 +1092,10 @@ impl Streaming {
|
||||
// vanilla's order: decoration is order dependent where write regions
|
||||
// overlap (see `DecorationOrder`).
|
||||
let workers = self.threads.len();
|
||||
if self.decorates && self.order_area != Some((self.center, self.radius)) {
|
||||
// Re-ranking thousands of chunks buys nothing while no proto can be
|
||||
// decorated: the next proto triggers it.
|
||||
let may_decorate = !self.protos.is_empty() || self.decorating > 0;
|
||||
if self.decorates && may_decorate && self.order_area != Some((self.center, self.radius)) {
|
||||
let (decorated, chunks) = (&self.decorated, &world.chunks);
|
||||
// The render distance is the server view distance in singleplayer.
|
||||
self.decoration_order
|
||||
@@ -1090,6 +1108,7 @@ impl Streaming {
|
||||
radius: self.radius,
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
forced_bounds: self.forced_bounds,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
band: self.band.as_ref(),
|
||||
};
|
||||
@@ -1102,36 +1121,30 @@ impl Streaming {
|
||||
});
|
||||
}
|
||||
if self.decorates && self.decorating < workers * 2 {
|
||||
let wants = |s: &Self, x: i32, z: i32| {
|
||||
!s.decorated.contains(&(x, z))
|
||||
&& !world.chunks.contains_key(&(x, z))
|
||||
&& (s.in_range(x, z, 2) || s.near_finished(x, z, 1))
|
||||
};
|
||||
// A wanted chunk holds back higher-ranked ones only if it can be
|
||||
// decorated: its 3x3 neighbourhood is (or will be) generated as
|
||||
// protos, none of them already final.
|
||||
let waits_for = |s: &Self, x: i32, z: i32| {
|
||||
wants(s, x, z)
|
||||
&& (-1..=1).all(|dz| {
|
||||
(-1..=1).all(|dx| {
|
||||
let n = (x + dx, z + dz);
|
||||
!world.chunks.contains_key(&n)
|
||||
&& (s.protos.contains_key(&n)
|
||||
|| s.generating.contains(&n)
|
||||
|| s.ranges().requested(n.0, n.1, true))
|
||||
})
|
||||
let mut wanted: Vec<(u64, i64, i32, i32)> = {
|
||||
let ranges = Ranges {
|
||||
center: self.center,
|
||||
radius: self.radius,
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
forced_bounds: self.forced_bounds,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
band: self.band.as_ref(),
|
||||
};
|
||||
self.protos
|
||||
.iter()
|
||||
.filter(|(k, p)| {
|
||||
p.is_some()
|
||||
&& !self.decorated.contains(k)
|
||||
&& !world.chunks.contains_key(k)
|
||||
&& (ranges.in_range(k.0, k.1, 2) || ranges.near_finished(k.0, k.1, 1))
|
||||
})
|
||||
.map(|(k, _)| (0, ranges.priority(k.0, k.1, self.loading), k.0, k.1))
|
||||
.collect()
|
||||
};
|
||||
let keys: Vec<(i32, i32)> = self
|
||||
.protos
|
||||
.iter()
|
||||
.filter(|(k, p)| p.is_some() && wants(self, k.0, k.1))
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
let mut wanted: Vec<(u64, i64, i32, i32)> = keys
|
||||
.into_iter()
|
||||
.map(|(x, z)| (0, self.distance(x, z), x, z))
|
||||
.collect();
|
||||
// Chunks no ticket reached (forced and extra areas) go last, nearest first.
|
||||
wanted.sort_unstable_by_key(|w| w.1);
|
||||
for w in &mut wanted {
|
||||
@@ -1142,10 +1155,32 @@ impl Streaming {
|
||||
if self.decorating >= workers * 2 {
|
||||
break;
|
||||
}
|
||||
if self
|
||||
.decoration_order
|
||||
.blocked(x, z, |nx, nz| waits_for(self, nx, nz))
|
||||
{
|
||||
// One `Ranges` per candidate: `blocked` asks about up to 24
|
||||
// neighbours, each of their 3x3.
|
||||
let ranges = Ranges {
|
||||
center: self.center,
|
||||
radius: self.radius,
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
forced_bounds: self.forced_bounds,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
band: self.band.as_ref(),
|
||||
};
|
||||
let waits_for = |nx: i32, nz: i32| {
|
||||
!self.decorated.contains(&(nx, nz))
|
||||
&& !world.chunks.contains_key(&(nx, nz))
|
||||
&& (ranges.in_range(nx, nz, 2) || ranges.near_finished(nx, nz, 1))
|
||||
&& (-1..=1).all(|dz| {
|
||||
(-1..=1).all(|dx| {
|
||||
let n = (nx + dx, nz + dz);
|
||||
!world.chunks.contains_key(&n)
|
||||
&& (self.protos.contains_key(&n)
|
||||
|| self.generating.contains(&n)
|
||||
|| ranges.requested(n.0, n.1, true))
|
||||
})
|
||||
})
|
||||
};
|
||||
if self.decoration_order.blocked(x, z, waits_for) {
|
||||
continue;
|
||||
}
|
||||
let keys: Vec<(i32, i32)> =
|
||||
@@ -1220,6 +1255,7 @@ impl Streaming {
|
||||
radius: self.radius,
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
forced_bounds: self.forced_bounds,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
band: self.band.as_ref(),
|
||||
};
|
||||
@@ -1298,6 +1334,7 @@ impl Streaming {
|
||||
radius: self.radius,
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
forced_bounds: self.forced_bounds,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
band: self.band.as_ref(),
|
||||
},
|
||||
@@ -1395,6 +1432,7 @@ impl Streaming {
|
||||
radius: self.radius,
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
forced_bounds: self.forced_bounds,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
band: self.band.as_ref(),
|
||||
}
|
||||
@@ -1550,6 +1588,8 @@ struct Ranges<'a> {
|
||||
radius: i32,
|
||||
extra: &'a [((i32, i32), i32)],
|
||||
forced: &'a HashSet<(i32, i32)>,
|
||||
/// `forced`'s bounding box, when known.
|
||||
forced_bounds: Option<(i32, i32, i32, i32)>,
|
||||
/// `extra` as a grid, when one was made for it: `in_extra` without a
|
||||
/// scan of every area.
|
||||
cover: Option<&'a ExtraCover>,
|
||||
@@ -1623,12 +1663,17 @@ impl ExtraCover {
|
||||
cover: &'a Option<ExtraCover>,
|
||||
extra: &[((i32, i32), i32)],
|
||||
) -> Option<&'a ExtraCover> {
|
||||
cover.as_ref().filter(|c| c.extra == extra)
|
||||
cover
|
||||
.as_ref()
|
||||
.filter(|c| c.extra == extra || same_areas(&c.extra, extra))
|
||||
}
|
||||
/// Make the grid again if `extra` changed; none for one area or none
|
||||
/// (a scan is as quick), or areas too far apart.
|
||||
fn refresh(cover: &mut Option<ExtraCover>, extra: &[((i32, i32), i32)]) {
|
||||
if cover.as_ref().is_some_and(|c| c.extra == extra) {
|
||||
if cover
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.extra == extra || same_areas(&c.extra, extra))
|
||||
{
|
||||
return;
|
||||
}
|
||||
*cover = Self::new(extra);
|
||||
@@ -1672,7 +1717,7 @@ impl ExtraCover {
|
||||
}
|
||||
}
|
||||
Some(Self {
|
||||
extra: extra.to_vec(),
|
||||
extra: canonical_areas(extra),
|
||||
x0,
|
||||
z0,
|
||||
width,
|
||||
@@ -1800,6 +1845,15 @@ impl Ranges<'_> {
|
||||
return true;
|
||||
}
|
||||
let reach = (margin - 1).max(0);
|
||||
// Outside the forced box, no scan can find a forced chunk near enough.
|
||||
if let Some((x0, z0, x1, z1)) = self.forced_bounds
|
||||
&& (x.saturating_add(reach) < x0
|
||||
|| x.saturating_sub(reach) > x1
|
||||
|| z.saturating_add(reach) < z0
|
||||
|| z.saturating_sub(reach) > z1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.forced
|
||||
.iter()
|
||||
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
|
||||
@@ -1824,6 +1878,20 @@ impl Drop for Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Mark the columns whose light a newly resident column at `key` can change:
|
||||
/// itself and its face neighbours, plus any neighbour still unlit. A diagonal
|
||||
/// column's light can only change through a face neighbour, and a lit diagonal
|
||||
/// had all nine columns around it, this one included, so it is not relit.
|
||||
fn mark_dirty_light(world: &mut World, key: (i32, i32), loaded: bool, lit: &FxHashSet<(i32, i32)>) {
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let n = (key.0 + dx, key.1 + dz);
|
||||
if !lit.contains(&n) || (!loaded && (dx == 0 || dz == 0)) {
|
||||
world.dirty_light.insert(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The section indexes set in a column's bits, lowest first.
|
||||
fn section_indexes(mut bits: u32) -> impl Iterator<Item = i32> {
|
||||
std::iter::from_fn(move || {
|
||||
@@ -1848,6 +1916,36 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
|
||||
}
|
||||
*queue = BinaryHeap::from(jobs);
|
||||
}
|
||||
/// Re-rank the queue without holding its mutex while distances are computed:
|
||||
/// workers block on that mutex before every pop. Jobs pushed meanwhile keep
|
||||
/// their own priorities and are merged back in.
|
||||
fn rerank_queue(queue: &Mutex<BinaryHeap<Queued>>, distance: impl Fn(i32, i32) -> i64) {
|
||||
let mut heap = std::mem::take(&mut *queue.lock().unwrap());
|
||||
rank_again(&mut heap, distance);
|
||||
let mut queue = queue.lock().unwrap();
|
||||
while let Some(job) = queue.pop() {
|
||||
heap.push(job);
|
||||
}
|
||||
*queue = heap;
|
||||
}
|
||||
/// `extra` as a set: the order areas are stored in must not matter.
|
||||
fn canonical_areas(areas: &[((i32, i32), i32)]) -> Vec<((i32, i32), i32)> {
|
||||
let mut areas = areas.to_vec();
|
||||
areas.sort_unstable();
|
||||
areas.dedup();
|
||||
areas
|
||||
}
|
||||
fn same_areas(canonical: &[((i32, i32), i32)], areas: &[((i32, i32), i32)]) -> bool {
|
||||
canonical == areas || canonical_areas(areas) == canonical
|
||||
}
|
||||
/// The bounding box of the forced chunks, or `None` when there are none.
|
||||
fn forced_bounds(forced: &HashSet<(i32, i32)>) -> Option<(i32, i32, i32, i32)> {
|
||||
let mut forced = forced.iter();
|
||||
let &(x, z) = forced.next()?;
|
||||
Some(forced.fold((x, z, x, z), |(x0, z0, x1, z1), &(x, z)| {
|
||||
(x0.min(x), z0.min(z), x1.max(x), z1.max(z))
|
||||
}))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1860,6 +1958,7 @@ mod tests {
|
||||
radius: 32,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
forced_bounds: None,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
@@ -1906,6 +2005,115 @@ mod tests {
|
||||
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_column_relights_itself_and_its_faces_not_lit_diagonals() {
|
||||
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
let mut lit = FxHashSet::default();
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let key = (dx, dz);
|
||||
world.chunks.insert(key, Arc::new(Chunk::empty(dx, dz)));
|
||||
lit.insert(key);
|
||||
}
|
||||
}
|
||||
// A fresh column queued every lit face and itself, but no lit diagonal.
|
||||
mark_dirty_light(&mut world, (0, 0), false, &lit);
|
||||
let expected = [(0, 0), (-1, 0), (1, 0), (0, -1), (0, 1)]
|
||||
.into_iter()
|
||||
.collect::<FxHashSet<_>>();
|
||||
assert_eq!(world.dirty_light, expected);
|
||||
|
||||
// A diagonal still unlit stays queued.
|
||||
lit.remove(&(1, 1));
|
||||
world.dirty_light.clear();
|
||||
mark_dirty_light(&mut world, (0, 0), false, &lit);
|
||||
assert!(world.dirty_light.contains(&(1, 1)));
|
||||
assert!(!world.dirty_light.contains(&(-1, -1)));
|
||||
|
||||
// A column loaded with light only queues its unlit neighbours.
|
||||
lit.remove(&(0, -1));
|
||||
world.dirty_light.clear();
|
||||
mark_dirty_light(&mut world, (0, 0), true, &lit);
|
||||
assert_eq!(
|
||||
world.dirty_light,
|
||||
[(0, -1), (1, 1)].into_iter().collect::<FxHashSet<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reranking_keeps_jobs_submitted_meanwhile() {
|
||||
let generate = |x, z, priority, order| Queued {
|
||||
priority,
|
||||
at: Some((x, z)),
|
||||
bias: 0,
|
||||
order,
|
||||
job: Job::Generate(x, z),
|
||||
};
|
||||
let queue = Mutex::new(BinaryHeap::from(vec![
|
||||
generate(5, 0, 500, 1),
|
||||
generate(20, 0, 100, 2),
|
||||
]));
|
||||
let late = AtomicBool::new(false);
|
||||
rerank_queue(&queue, |x, z| {
|
||||
if !late.swap(true, Ordering::Relaxed) {
|
||||
// `submit` pushes while the re-rank runs.
|
||||
queue.lock().unwrap().push(generate(9, 0, 324, 3));
|
||||
}
|
||||
i64::from(x * x + z * z)
|
||||
});
|
||||
let pops: Vec<_> = std::iter::from_fn(|| queue.lock().unwrap().pop())
|
||||
.map(|q| q.at)
|
||||
.collect();
|
||||
assert_eq!(pops, [Some((5, 0)), Some((9, 0)), Some((20, 0))]);
|
||||
assert!(same_areas(&[], &[]));
|
||||
let canonical = canonical_areas(&[((1, 2), 3), ((0, 0), 1), ((1, 2), 3)]);
|
||||
assert_eq!(canonical, [((0, 0), 1), ((1, 2), 3)]);
|
||||
assert!(same_areas(&canonical, &[((1, 2), 3), ((0, 0), 1)]));
|
||||
assert!(!same_areas(&canonical, &[((1, 2), 3), ((0, 0), 2)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_bounds_answers_as_the_scan() {
|
||||
for forced in [
|
||||
HashSet::new(),
|
||||
[(40, -40)].into(),
|
||||
[(40, -40), (-10, 5), (41, -39)].into(),
|
||||
[(-500, 200), (500, -200)].into(),
|
||||
] {
|
||||
let bounds = forced_bounds(&forced);
|
||||
let ranges = |bounds| Ranges {
|
||||
center: (3, -2),
|
||||
radius: 10,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
forced_bounds: bounds,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
let (scan, quick) = (ranges(None), ranges(bounds));
|
||||
for x in -520..=520 {
|
||||
for z in -220..=220 {
|
||||
for margin in 0..5 {
|
||||
assert_eq!(
|
||||
scan.in_main_range(x, z, margin),
|
||||
quick.in_main_range(x, z, margin),
|
||||
"({x}, {z}) margin {margin}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reordered_area_set_keeps_the_grid() {
|
||||
let extra = [((0, 0), 5), ((30, 30), 5)];
|
||||
let mut cover = None;
|
||||
ExtraCover::refresh(&mut cover, &extra);
|
||||
assert!(ExtraCover::matching(&cover, &[((30, 30), 5), ((0, 0), 5)]).is_some());
|
||||
assert!(ExtraCover::matching(&cover, &[((30, 30), 5), ((0, 1), 5)]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_distance_is_vanillas_shape() {
|
||||
// ChunkTrackingView.isInViewDistance at render distance 12: a column
|
||||
@@ -1965,6 +2173,7 @@ mod tests {
|
||||
radius: self.radius,
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
forced_bounds: None,
|
||||
cover: None,
|
||||
band: None,
|
||||
},
|
||||
@@ -2279,6 +2488,7 @@ mod tests {
|
||||
radius,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
forced_bounds: None,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
@@ -2335,6 +2545,7 @@ mod tests {
|
||||
radius: 12,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
forced_bounds: None,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
@@ -2362,6 +2573,7 @@ mod tests {
|
||||
radius,
|
||||
extra: &[((radius * 2 + 9, 3), 4)],
|
||||
forced: &forced,
|
||||
forced_bounds: None,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
@@ -2403,6 +2615,7 @@ mod tests {
|
||||
radius: 2,
|
||||
extra: &extra,
|
||||
forced: &forced,
|
||||
forced_bounds: None,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
@@ -2477,6 +2690,7 @@ mod cover_tests {
|
||||
radius: 10,
|
||||
extra: &extra,
|
||||
forced: &forced,
|
||||
forced_bounds: None,
|
||||
cover,
|
||||
band: None,
|
||||
};
|
||||
@@ -2536,6 +2750,7 @@ mod cover_tests {
|
||||
radius: 10,
|
||||
extra: &extra,
|
||||
forced: &forced,
|
||||
forced_bounds: None,
|
||||
cover,
|
||||
band: None,
|
||||
};
|
||||
|
||||
@@ -143,9 +143,10 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
// Alpha 0 tells HDR output (hdr.wgsl) a light-emitting block's bright pixels
|
||||
// may shine. Nothing else reads the frame's alpha.
|
||||
let emissive = select(1.0, 0.0, in.emission > 0u);
|
||||
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's bright
|
||||
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
|
||||
// lowest, 0 at the brightest. Nothing else reads the frame's alpha.
|
||||
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
|
||||
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
|
||||
}
|
||||
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
|
||||
@@ -79,27 +79,31 @@ pub fn load_metadata(root: &Path) {
|
||||
}
|
||||
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map);
|
||||
}
|
||||
/// The sprite's nine-slice metadata; without any (no resources loaded), its
|
||||
/// GUI size and the widgets' usual borders.
|
||||
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
|
||||
/// The sprite's nine-slice metadata, when its `.png.mcmeta` has any.
|
||||
fn nine_slice_metadata(sprite: &str) -> Option<NineSlice> {
|
||||
NINE_SLICE
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(sprite).copied())
|
||||
.unwrap_or(NineSlice {
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
border: match sprite {
|
||||
"gui/sprites/widget/button"
|
||||
| "gui/sprites/widget/button_highlighted"
|
||||
| "gui/sprites/widget/button_disabled" => [3; 4],
|
||||
"gui/sprites/widget/slider_handle"
|
||||
| "gui/sprites/widget/slider_handle_highlighted" => [2, 2, 2, 3],
|
||||
_ => [1; 4],
|
||||
},
|
||||
stretch_inner: false,
|
||||
})
|
||||
}
|
||||
/// The sprite's nine-slice metadata; without any (no resources loaded), its
|
||||
/// GUI size and the widgets' usual borders.
|
||||
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
|
||||
nine_slice_metadata(sprite).unwrap_or(NineSlice {
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
border: match sprite {
|
||||
"gui/sprites/widget/button"
|
||||
| "gui/sprites/widget/button_highlighted"
|
||||
| "gui/sprites/widget/button_disabled" => [3; 4],
|
||||
"gui/sprites/widget/slider_handle" | "gui/sprites/widget/slider_handle_highlighted" => {
|
||||
[2, 2, 2, 3]
|
||||
}
|
||||
_ => [1; 4],
|
||||
},
|
||||
stretch_inner: false,
|
||||
})
|
||||
}
|
||||
/// The region `[x, y, w, h]` of a sprite `size` GUI pixels large, in its
|
||||
/// own units (`TextureAtlasSprite.getU(x / width)`).
|
||||
@@ -152,6 +156,35 @@ pub fn nine_slice(
|
||||
draw_nine_slice(batch, *r, scaling(sprite, r), [x, y, w, h], color);
|
||||
}
|
||||
}
|
||||
/// `GuiGraphicsExtractor.blitSprite`: a sprite over `w` × `h` as its
|
||||
/// `gui.scaling` says, nine-sliced, or stretched when it has no metadata.
|
||||
/// (`tile`, which no vanilla sprite uses, isn't read yet: it stretches.)
|
||||
pub fn blit_sprite(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
sprite: &str,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
color: u32,
|
||||
) {
|
||||
if let Some(r) = atlas.sprites.get(sprite) {
|
||||
draw_sprite(batch, *r, nine_slice_metadata(sprite), [x, y, w, h], color);
|
||||
}
|
||||
}
|
||||
fn draw_sprite(
|
||||
batch: &mut HudBatch,
|
||||
r: Rect,
|
||||
nine_slice: Option<NineSlice>,
|
||||
[x, y, w, h]: [f32; 4],
|
||||
color: u32,
|
||||
) {
|
||||
match nine_slice {
|
||||
Some(n) => draw_nine_slice(batch, r, n, [x, y, w, h], color),
|
||||
None => batch.rect_uv(x, y, w, h, r, color),
|
||||
}
|
||||
}
|
||||
fn draw_nine_slice(
|
||||
batch: &mut HudBatch,
|
||||
r: Rect,
|
||||
@@ -227,28 +260,33 @@ pub fn tiled(
|
||||
h: f32,
|
||||
size: f32,
|
||||
color: u32,
|
||||
) {
|
||||
tiled_sized(batch, atlas, sprite, x, y, w, h, [size, size], color);
|
||||
}
|
||||
/// Repeat a texture using its width and height in GUI pixels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn tiled_sized(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
sprite: &str,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
size: [f32; 2],
|
||||
color: u32,
|
||||
) {
|
||||
let Some(r) = atlas.sprites.get(sprite).copied() else {
|
||||
return;
|
||||
};
|
||||
let mut yy = 0.0;
|
||||
while yy < h {
|
||||
let th = size.min(h - yy);
|
||||
let mut xx = 0.0;
|
||||
while xx < w {
|
||||
let tw = size.min(w - xx);
|
||||
batch.rect_uv(
|
||||
x + xx,
|
||||
y + yy,
|
||||
tw,
|
||||
th,
|
||||
sub(r, [size, size], [0.0, 0.0, tw, th]),
|
||||
color,
|
||||
);
|
||||
xx += size;
|
||||
}
|
||||
yy += size;
|
||||
}
|
||||
tile(
|
||||
batch,
|
||||
r,
|
||||
size,
|
||||
[0.0, 0.0, size[0], size[1]],
|
||||
[x, y, w, h],
|
||||
color,
|
||||
);
|
||||
}
|
||||
/// Text drawn at `scale` times its size (a scaled pose).
|
||||
pub fn text_scaled(
|
||||
@@ -434,7 +472,7 @@ pub fn separators(
|
||||
("gui/header_separator", "gui/footer_separator")
|
||||
};
|
||||
let w = batch.width;
|
||||
tiled(
|
||||
tiled_sized(
|
||||
batch,
|
||||
atlas,
|
||||
header,
|
||||
@@ -442,10 +480,10 @@ pub fn separators(
|
||||
header_bottom - 2.0,
|
||||
w,
|
||||
2.0,
|
||||
32.0,
|
||||
[32.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
tiled(
|
||||
tiled_sized(
|
||||
batch,
|
||||
atlas,
|
||||
footer,
|
||||
@@ -453,7 +491,7 @@ pub fn separators(
|
||||
footer_top,
|
||||
w,
|
||||
2.0,
|
||||
32.0,
|
||||
[32.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
@@ -1291,6 +1329,22 @@ mod tests {
|
||||
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn separator_tiles_sample_both_rows_and_clip_the_last_column() {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
tile(
|
||||
&mut batch,
|
||||
sprite(32, 2),
|
||||
[32.0, 2.0],
|
||||
[0.0, 0.0, 32.0, 2.0],
|
||||
[0.0, 5.0, 40.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 2);
|
||||
assert_quad(q[0], ([0.0, 5.0, 32.0, 7.0], [0.0, 0.0, 1.0, 1.0]));
|
||||
assert_quad(q[1], ([32.0, 5.0, 40.0, 7.0], [0.0, 0.0, 0.25, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn a_high_resolution_button_slices_in_its_gui_units() {
|
||||
// Faithful 32x's widget/button: 400x40 texels, gui.scaling 200x20.
|
||||
let n = faithful(
|
||||
@@ -1357,6 +1411,40 @@ mod tests {
|
||||
assert_quad(q[0], ([4.0, 4.0, 54.0, 54.0], [0.0, 0.0, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn blit_sprite_nine_slices_a_sprite_whose_metadata_says_so() {
|
||||
// Vanilla's tooltip/frame: 100x100, border 10, stretched centre.
|
||||
let n = faithful(
|
||||
r#"{"gui":{"scaling":{"type":"nine_slice","width":100,"height":100,"border":10,"stretch_inner":true}}}"#,
|
||||
);
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_sprite(
|
||||
&mut batch,
|
||||
sprite(100, 100),
|
||||
Some(n),
|
||||
[10.0, 10.0, 160.0, 26.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 9);
|
||||
// The corners keep their 10 pixels instead of stretching with the box.
|
||||
assert_quad(q[0], ([10.0, 10.0, 20.0, 20.0], [0.0, 0.0, 0.1, 0.1]));
|
||||
assert_quad(q[5], ([160.0, 26.0, 170.0, 36.0], [0.9, 0.9, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn blit_sprite_stretches_a_sprite_without_metadata() {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_sprite(
|
||||
&mut batch,
|
||||
sprite(9, 9),
|
||||
None,
|
||||
[0.0, 0.0, 27.0, 18.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 1);
|
||||
assert_quad(q[0], ([0.0, 0.0, 27.0, 18.0], [0.0, 0.0, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn borders_shrink_to_half_the_drawn_size() {
|
||||
let n = NineSlice {
|
||||
width: 20,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! Resident client world: streamed chunk columns plus the bookkeeping the game
|
||||
//! loop needs to relight and remesh after edits.
|
||||
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Sections (x, section index, z) waiting to be remeshed, kept by column
|
||||
/// (a bit per section index), so the mesh scheduler looks at each column once
|
||||
@@ -65,14 +63,14 @@ pub struct World {
|
||||
/// Saved entities of chunks that just loaded, spawned by the game.
|
||||
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
|
||||
/// Chunks changed since the last save.
|
||||
pub modified: HashSet<(i32, i32)>,
|
||||
pub modified: FxHashSet<(i32, i32)>,
|
||||
/// Block entities, and the order they tick in.
|
||||
pub block_entities: crate::block_entities::BlockEntities,
|
||||
/// Each chest's `ContainerOpenersCounter`: opener count and largest
|
||||
/// interaction range.
|
||||
pub chest_openers: HashMap<(i32, i32, i32), (i32, f64)>,
|
||||
pub chest_openers: FxHashMap<(i32, i32, i32), (i32, f64)>,
|
||||
/// `BaseContainerBlockEntity.name`: custom names of container block entities.
|
||||
pub block_names: HashMap<(i32, i32, i32), String>,
|
||||
pub block_names: FxHashMap<(i32, i32, i32), String>,
|
||||
/// States (by id) whose writes are recorded in `watched_writes` (sculk
|
||||
/// blocks, which need a block entity).
|
||||
pub watched: Vec<bool>,
|
||||
@@ -80,7 +78,7 @@ pub struct World {
|
||||
pub watched_writes: Vec<((i32, i32, i32), u32, u32)>,
|
||||
/// `ChunkAccess.inhabitedTime` of resident chunks: ticks a player spent
|
||||
/// within spawning range (local difficulty).
|
||||
pub inhabited: HashMap<(i32, i32), i64>,
|
||||
pub inhabited: FxHashMap<(i32, i32), i64>,
|
||||
pub game_time: i64,
|
||||
pub day_time: i64,
|
||||
pub weather: Weather,
|
||||
@@ -177,13 +175,13 @@ impl World {
|
||||
dirty_light: Default::default(),
|
||||
light_edits: Vec::new(),
|
||||
loaded_entities: Vec::new(),
|
||||
modified: HashSet::new(),
|
||||
modified: FxHashSet::default(),
|
||||
block_entities: Default::default(),
|
||||
chest_openers: HashMap::new(),
|
||||
block_names: HashMap::new(),
|
||||
chest_openers: FxHashMap::default(),
|
||||
block_names: FxHashMap::default(),
|
||||
watched: Vec::new(),
|
||||
watched_writes: Vec::new(),
|
||||
inhabited: HashMap::new(),
|
||||
inhabited: FxHashMap::default(),
|
||||
game_time: 0,
|
||||
day_time: 1000,
|
||||
weather: Weather::default(),
|
||||
@@ -274,20 +272,40 @@ impl World {
|
||||
log.push((x, y, z));
|
||||
}
|
||||
self.modified.insert((x >> 4, z >> 4));
|
||||
// The mesher reads a 3x3x3 neighbourhood for AO, so a write marks
|
||||
// the sections it can reach: this section, the one above/below
|
||||
// only on a section face, and a neighbour column only on a chunk
|
||||
// border (the diagonal only where both borders meet).
|
||||
let sy = (y - MIN_Y) >> 4;
|
||||
for dz in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let (bx, by, bz) = (x + dx, y + dy, z + dz);
|
||||
let s = (by - MIN_Y) >> 4;
|
||||
if !(0..SECTIONS as i32).contains(&s) {
|
||||
continue;
|
||||
}
|
||||
self.dirty_sections.insert((bx >> 4, s, bz >> 4));
|
||||
}
|
||||
let local_y = y & 15;
|
||||
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
|
||||
dirty.insert((bx >> 4, sy, bz >> 4));
|
||||
if local_y == 0 && sy > 0 {
|
||||
dirty.insert((bx >> 4, sy - 1, bz >> 4));
|
||||
}
|
||||
if local_y == 15 && sy + 1 < SECTIONS as i32 {
|
||||
dirty.insert((bx >> 4, sy + 1, bz >> 4));
|
||||
}
|
||||
};
|
||||
let (bx, bz) = (x & 15, z & 15);
|
||||
mark(&mut self.dirty_sections, x, z);
|
||||
if bx == 0 {
|
||||
mark(&mut self.dirty_sections, x - 1, z);
|
||||
} else if bx == 15 {
|
||||
mark(&mut self.dirty_sections, x + 1, z);
|
||||
}
|
||||
if bz == 0 {
|
||||
mark(&mut self.dirty_sections, x, z - 1);
|
||||
} else if bz == 15 {
|
||||
mark(&mut self.dirty_sections, x, z + 1);
|
||||
}
|
||||
if (bx == 0 || bx == 15) && (bz == 0 || bz == 15) {
|
||||
mark(
|
||||
&mut self.dirty_sections,
|
||||
if bx == 0 { x - 1 } else { x + 1 },
|
||||
if bz == 0 { z - 1 } else { z + 1 },
|
||||
);
|
||||
}
|
||||
let _ = sy;
|
||||
if self.chunks.get(&(x >> 4, z >> 4)).is_some_and(|c| c.lit) {
|
||||
self.light_edits.push(((x, y, z), previous, state.0));
|
||||
} else {
|
||||
@@ -407,11 +425,59 @@ impl Default for HealthRules {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::World;
|
||||
use crate::chunk::Chunk;
|
||||
use super::{DirtySections, World};
|
||||
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Every block write marks the same section set the 27-cell loop did, but
|
||||
/// only the distinct keys: one in the interior, a neighbour section on a
|
||||
/// section face, a neighbour column on a chunk border and the diagonal at
|
||||
/// a corner (eight keys at a corner on a section face).
|
||||
#[test]
|
||||
fn writes_mark_exactly_the_reachable_sections() {
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
for (x, y, z) in [
|
||||
(3, 70, 3),
|
||||
(0, 70, 3),
|
||||
(15, 70, 3),
|
||||
(3, 70, 0),
|
||||
(0, 70, 0),
|
||||
(15, 70, 15),
|
||||
(0, MIN_Y, 0),
|
||||
(0, 80, 0),
|
||||
(15, MIN_Y + SECTIONS as i32 * 16 - 1, 15),
|
||||
] {
|
||||
world.dirty_sections.clear();
|
||||
world.set(x, y, z, BlockStateId(1)).unwrap();
|
||||
let mut expected = DirtySections::default();
|
||||
for dz in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let (bx, by, bz) = (x + dx, y + dy, z + dz);
|
||||
let s = (by - MIN_Y) >> 4;
|
||||
if (0..SECTIONS as i32).contains(&s) {
|
||||
expected.insert((bx >> 4, s, bz >> 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut got: Vec<_> = world.dirty_sections.columns().collect();
|
||||
let mut want: Vec<_> = expected.columns().collect();
|
||||
got.sort_unstable();
|
||||
want.sort_unstable();
|
||||
assert_eq!(got, want, "write at {x} {y} {z}");
|
||||
if (x & 15, z & 15) == (3, 3) {
|
||||
assert_eq!(world.dirty_sections.len(), 1);
|
||||
}
|
||||
if (x & 15, z & 15, y & 15) == (0, 0, 0) && y > MIN_Y {
|
||||
// Four columns, each with the section below and this one.
|
||||
assert_eq!(world.dirty_sections.len(), 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A box misses a state only where no section it reaches lists it, and
|
||||
/// unloaded columns or heights past the world count as their air.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Container menu titles for tools/test_container_title_parity.py: reads one
|
||||
//! JSON case per line (see VanillaContainerTitleOracle) and prints the title
|
||||
//! the server opens the container's menu with, as canonical NBT. A single
|
||||
//! block entity is placed as the game places it (`block_entity_item::place`,
|
||||
//! whose `CustomName` the game keeps in `World::block_names`), then named by
|
||||
//! `containers::display_name`; a double chest's halves by
|
||||
//! `containers::double_chest_name`.
|
||||
//!
|
||||
//! usage: container_title_fixture INPUT OUTPUT
|
||||
use horizon_client::block_entity_item::place;
|
||||
use horizon_client::block_entity_nbt::canonical;
|
||||
use horizon_client::containers::{default_name_key, display_name, double_chest_name};
|
||||
use horizon_client::save::text;
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
/// ParityData.canon of a text component: sorted keys.
|
||||
fn canon(tag: &Tag, out: &mut String) {
|
||||
match tag {
|
||||
Tag::Compound(map) => {
|
||||
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
|
||||
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out.push('{');
|
||||
for (i, (k, v)) in keys.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&k);
|
||||
out.push(':');
|
||||
canon(v, out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Tag::List(items) => {
|
||||
out.push('[');
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
canon(item, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn snbt(value: &Value) -> Option<Tag> {
|
||||
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
|
||||
}
|
||||
|
||||
/// The custom name the game keeps for a block entity saved as `value`.
|
||||
fn custom_name(value: &Value) -> Option<Option<String>> {
|
||||
let saved = canonical(&snbt(value)?)?;
|
||||
Some(text(saved.get("CustomName")))
|
||||
}
|
||||
|
||||
fn case(line: &str) -> Option<String> {
|
||||
let case: Value = serde_json::from_str(line).ok()?;
|
||||
let title = if let Some(first) = case.get("first") {
|
||||
let first = custom_name(first)?;
|
||||
let second = custom_name(&case["second"])?;
|
||||
double_chest_name(first.as_deref(), second.as_deref())
|
||||
} else {
|
||||
let saved = canonical(&snbt(&case["nbt"])?)?;
|
||||
// BlockItem.updateBlockEntityComponents, as Game::apply_placed_item runs it.
|
||||
let placed = match case.get("patch") {
|
||||
Some(p) => match snbt(p)? {
|
||||
Tag::Compound(patch) => place(&saved, &patch, true),
|
||||
_ => return None,
|
||||
},
|
||||
None => saved,
|
||||
};
|
||||
let key = default_name_key(case["block"].as_str()?)?;
|
||||
display_name(text(placed.get("CustomName")).as_deref(), key)
|
||||
};
|
||||
let mut out = String::new();
|
||||
canon(&title, &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: container_title_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -41,7 +41,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let mut arrays: HashMap<(i32, i32), Arrays> = HashMap::new();
|
||||
for entry in std::fs::read_dir(dim.join("region"))? {
|
||||
let path = entry?.path();
|
||||
let mut region = RegionFile::open(&path)?;
|
||||
let region = RegionFile::open(&path)?;
|
||||
let positions: Vec<ChunkPos> = region.chunks().map(|(p, _)| p).collect();
|
||||
for p in positions {
|
||||
let Some(record) = region.read(p)? else {
|
||||
|
||||
@@ -27,6 +27,7 @@ mod chat_screen_fixture;
|
||||
mod cloud_fixture;
|
||||
mod command_parse_fixture;
|
||||
mod container_screen_probe;
|
||||
mod container_title_fixture;
|
||||
mod creative_mining_probe;
|
||||
mod creative_search_fixture;
|
||||
mod cushion_placement_probe;
|
||||
@@ -118,6 +119,7 @@ probes! {
|
||||
cloud_fixture,
|
||||
command_parse_fixture,
|
||||
container_screen_probe,
|
||||
container_title_fixture,
|
||||
creative_search_fixture,
|
||||
emitter_particles_probe,
|
||||
enchanting_fixture,
|
||||
|
||||
@@ -126,6 +126,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.collect();
|
||||
let mut screen = PackScreen::new(
|
||||
&m,
|
||||
Text::translate(c["title"].as_str().unwrap_or("resourcePack.title"), &[]),
|
||||
int(&c["width"]),
|
||||
int(&c["height"]),
|
||||
c["inWorld"] == true,
|
||||
|
||||
@@ -552,7 +552,14 @@ impl Audio {
|
||||
Self::with_output(root, false)
|
||||
}
|
||||
fn with_output(root: &Path, output: bool) -> Self {
|
||||
let registry = SoundRegistry::load(root);
|
||||
// A server sends sound ids to its clients but plays nothing, so it
|
||||
// never reads the registry (a client with no output still does, for
|
||||
// subtitles).
|
||||
let registry = if output {
|
||||
SoundRegistry::load(root)
|
||||
} else {
|
||||
SoundRegistry::default()
|
||||
};
|
||||
if output && registry.is_empty() {
|
||||
// The game then plays nothing on a working output (#1035, #1076).
|
||||
log::warn!(
|
||||
@@ -920,6 +927,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The server role, not the output flag, decides whether the sound
|
||||
/// registry is read: `Audio::silent` is only a server's.
|
||||
#[test]
|
||||
fn a_server_does_not_read_the_sound_registry() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-silent-audio-test-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("assets/minecraft/sounds/gui")).unwrap();
|
||||
fs::write(
|
||||
root.join("assets/minecraft/sounds.json"),
|
||||
br#"{"ui.button.click":{"sounds":["gui/button"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("assets/minecraft/sounds/gui/button.ogg"), b"").unwrap();
|
||||
assert_eq!(SoundRegistry::load(&root).names().count(), 1);
|
||||
assert!(Audio::silent(&root).event_names().is_empty());
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
|
||||
let mut cache = SoundCache::default();
|
||||
|
||||
@@ -425,7 +425,7 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
|
||||
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..128", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
|
||||
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
|
||||
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
|
||||
|
||||
@@ -1175,11 +1175,16 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
|
||||
augment(i, ingredients, stacks, &mut owner, &mut seen)
|
||||
})
|
||||
}
|
||||
/// Recipe files are independent reads: use the loader's budget, leaving the
|
||||
/// caller its own core, and no more than eight workers.
|
||||
fn recipe_threads() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map_or(1, |n| n.get().saturating_sub(1))
|
||||
.clamp(1, 8)
|
||||
}
|
||||
|
||||
impl Recipes {
|
||||
pub fn load(root: &Path, items: &Items) -> Result<Self> {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut meta = HashMap::new();
|
||||
let json_files = |dir: &Path| -> Result<Vec<PathBuf>> {
|
||||
let mut files: Vec<_> = fs::read_dir(dir)?
|
||||
.flatten()
|
||||
@@ -1204,15 +1209,120 @@ impl Recipes {
|
||||
);
|
||||
}
|
||||
}
|
||||
for (prefix, path) in files {
|
||||
let (crafting, cooking, meta) = Self::load_files(&files, items, recipe_threads())?;
|
||||
let facts = items
|
||||
.items
|
||||
.iter()
|
||||
.map(|(id, info)| {
|
||||
(
|
||||
id.clone(),
|
||||
Facts {
|
||||
dye: info.dye.as_deref().and_then(|d| {
|
||||
crate::block_entity_render::DYES
|
||||
.iter()
|
||||
.position(|(name, _)| *name == d)
|
||||
}),
|
||||
max_damage: info.max_damage,
|
||||
max_stack: info.max_stack,
|
||||
remainder: info.crafting_remainder.as_deref().map(Arc::from),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let curses = crate::items::load_tags(root, "enchantment")?
|
||||
.remove("minecraft:curse")
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
crafting,
|
||||
cooking,
|
||||
meta,
|
||||
facts,
|
||||
curses,
|
||||
cook_index: Default::default(),
|
||||
})
|
||||
}
|
||||
/// Parse a sorted file list in `threads` contiguous ranges. Ranges are
|
||||
/// concatenated in file order, so the parallel result equals the serial one.
|
||||
fn load_files(
|
||||
files: &[(String, PathBuf)],
|
||||
items: &Items,
|
||||
threads: usize,
|
||||
) -> std::result::Result<
|
||||
(
|
||||
Vec<(String, Recipe)>,
|
||||
Vec<Cooking>,
|
||||
HashMap<String, (String, String)>,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let threads = threads.clamp(1, files.len().max(1));
|
||||
if threads == 1 {
|
||||
let mut warnings = Vec::new();
|
||||
let out = Self::load_range(files, items, 0..files.len(), &mut warnings)?;
|
||||
for (name, error) in warnings {
|
||||
log::warn!("Horizon: recipe {name} not loaded: {error}");
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let chunk = files.len().div_ceil(threads);
|
||||
let parts = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..threads)
|
||||
.map(|index| {
|
||||
let range =
|
||||
(index * chunk).min(files.len())..((index + 1) * chunk).min(files.len());
|
||||
scope.spawn(move || {
|
||||
let mut warnings = Vec::new();
|
||||
let result = Self::load_range(files, items, range, &mut warnings);
|
||||
(result, warnings)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut meta = HashMap::new();
|
||||
for (part, warnings) in parts {
|
||||
for (name, error) in warnings {
|
||||
log::warn!("Horizon: recipe {name} not loaded: {error}");
|
||||
}
|
||||
let (part_crafting, part_cooking, part_meta) = part?;
|
||||
crafting.extend(part_crafting);
|
||||
cooking.extend(part_cooking);
|
||||
meta.extend(part_meta);
|
||||
}
|
||||
Ok((crafting, cooking, meta))
|
||||
}
|
||||
|
||||
fn load_range(
|
||||
files: &[(String, PathBuf)],
|
||||
items: &Items,
|
||||
range: std::ops::Range<usize>,
|
||||
warnings: &mut Vec<(String, serde_json::Error)>,
|
||||
) -> std::result::Result<
|
||||
(
|
||||
Vec<(String, Recipe)>,
|
||||
Vec<Cooking>,
|
||||
HashMap<String, (String, String)>,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut meta = HashMap::new();
|
||||
for (prefix, path) in &files[range] {
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let name = format!("{prefix}{stem}");
|
||||
// Vanilla's RecipeManager logs a file that does not parse and
|
||||
// goes on ("Couldn't parse data file").
|
||||
let v: Value = match serde_json::from_slice(&fs::read(&path)?) {
|
||||
let bytes = fs::read(path).map_err(|e| e.to_string())?;
|
||||
let v: Value = match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: recipe {name} not loaded: {e}");
|
||||
warnings.push((name, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -1397,37 +1507,9 @@ impl Recipes {
|
||||
crafting.push((name, recipe));
|
||||
}
|
||||
}
|
||||
let facts = items
|
||||
.items
|
||||
.iter()
|
||||
.map(|(id, info)| {
|
||||
(
|
||||
id.clone(),
|
||||
Facts {
|
||||
dye: info.dye.as_deref().and_then(|d| {
|
||||
crate::block_entity_render::DYES
|
||||
.iter()
|
||||
.position(|(name, _)| *name == d)
|
||||
}),
|
||||
max_damage: info.max_damage,
|
||||
max_stack: info.max_stack,
|
||||
remainder: info.crafting_remainder.as_deref().map(Arc::from),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let curses = crate::items::load_tags(root, "enchantment")?
|
||||
.remove("minecraft:curse")
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
crafting,
|
||||
cooking,
|
||||
meta,
|
||||
facts,
|
||||
curses,
|
||||
cook_index: Default::default(),
|
||||
})
|
||||
Ok((crafting, cooking, meta))
|
||||
}
|
||||
|
||||
/// Match a `size`x`size` grid (row-major) against the crafting recipes.
|
||||
pub fn craft(&self, grid: &[Option<ItemStack>], size: usize) -> Option<ItemStack> {
|
||||
self.find(grid, size).map(|(_, result)| result)
|
||||
@@ -1799,4 +1881,56 @@ mod tests {
|
||||
// One red dye on an undyed item gives the dye's own colour.
|
||||
assert_eq!(apply_dyes(None, &[14]), 0xB02E26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_recipe_load_matches_serial_in_file_order() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-recipes-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let recipes = dir.join("data/minecraft/recipe");
|
||||
std::fs::create_dir_all(&recipes).unwrap();
|
||||
std::fs::create_dir_all(dir.join("data/minecraft/loot_table")).unwrap();
|
||||
std::fs::write(dir.join("items.json"), "{}").unwrap();
|
||||
let mut files = Vec::new();
|
||||
for i in 0..25 {
|
||||
let path = recipes.join(format!("{i:03}.json"));
|
||||
let text = if i % 5 == 0 {
|
||||
format!(
|
||||
r#"{{"type":"minecraft:smelting","ingredient":"minecraft:item_{i}","result":{{"id":"minecraft:out_{i}"}},"cookingtime":200}}"#
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"{{"type":"minecraft:crafting_shapeless","ingredients":["minecraft:item_{i}"],"result":{{"id":"minecraft:out_{i}"}},"category":"misc","group":"g{i}"}}"#
|
||||
)
|
||||
};
|
||||
std::fs::write(&path, text).unwrap();
|
||||
files.push((String::new(), path));
|
||||
}
|
||||
// A file that does not parse is skipped in both, at its place in order.
|
||||
let bad = recipes.join("zm-bad.json");
|
||||
std::fs::write(&bad, "{").unwrap();
|
||||
files.push((String::new(), bad));
|
||||
let items = Items::catalog(&dir).unwrap();
|
||||
let serial = Recipes::load_files(&files, &items, 1).unwrap();
|
||||
let parallel = Recipes::load_files(&files, &items, 4).unwrap();
|
||||
let expected: Vec<String> = (0..25)
|
||||
.filter(|i| i % 5 != 0)
|
||||
.map(|i| format!("{i:03}"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
serial
|
||||
.0
|
||||
.iter()
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(format!("{:?}", serial.0), format!("{:?}", parallel.0));
|
||||
assert_eq!(format!("{:?}", serial.1), format!("{:?}", parallel.1));
|
||||
let mut serial_meta: Vec<_> = serial.2.iter().collect();
|
||||
let mut parallel_meta: Vec<_> = parallel.2.iter().collect();
|
||||
serial_meta.sort();
|
||||
parallel_meta.sort();
|
||||
assert_eq!(serial_meta, parallel_meta);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,11 @@ pub enum Kind {
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
},
|
||||
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
|
||||
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs),
|
||||
/// and one button per dialog they resolve to (`set_list`).
|
||||
DialogList {
|
||||
dialogs: Tag,
|
||||
entries: Vec<Button>,
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
@@ -325,29 +327,36 @@ impl Control {
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let label = || t.get("label").map(text);
|
||||
Some(match path(&string(t.get("type"))?) {
|
||||
"text" => Self::Text {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
initial: string(t.get("initial")).unwrap_or_default(),
|
||||
max_length: int(t, "max_length", 32),
|
||||
multiline: t.get("multiline").map(|m| {
|
||||
let get = |key| number(m.get(key)).map(|v| v as i32);
|
||||
(get("max_lines"), get("height"))
|
||||
}),
|
||||
},
|
||||
"text" => {
|
||||
let initial = string(t.get("initial")).unwrap_or_default();
|
||||
let max_length = int(t, "max_length", 32);
|
||||
// TextInput: the default text may not be longer than the length.
|
||||
if initial.encode_utf16().count() > max_length.max(0) as usize {
|
||||
return None;
|
||||
}
|
||||
Self::Text {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
initial,
|
||||
max_length,
|
||||
multiline: t.get("multiline").map(|m| {
|
||||
let get = |key| number(m.get(key)).map(|v| v as i32);
|
||||
(get("max_lines"), get("height"))
|
||||
}),
|
||||
}
|
||||
}
|
||||
"boolean" => Self::Boolean {
|
||||
label: label()?,
|
||||
initial: boolean(t, "initial", false),
|
||||
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
|
||||
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
|
||||
},
|
||||
"single_option" => Self::SingleOption {
|
||||
width: int(t, "width", 200),
|
||||
"single_option" => {
|
||||
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
|
||||
options: list(t.get("options"))
|
||||
let options: Vec<_> = list(t.get("options"))
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
.map(|o| match o {
|
||||
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
|
||||
o => Some((
|
||||
string(o.get("id"))?,
|
||||
@@ -355,10 +364,18 @@ impl Control {
|
||||
boolean(o, "initial", false),
|
||||
)),
|
||||
})
|
||||
.collect(),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
},
|
||||
.collect::<Option<_>>()?;
|
||||
// A non-empty list with at most one initial entry.
|
||||
if options.is_empty() || options.iter().filter(|o| o.2).count() > 1 {
|
||||
return None;
|
||||
}
|
||||
Self::SingleOption {
|
||||
width: int(t, "width", 200),
|
||||
options,
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
}
|
||||
}
|
||||
"number_range" => {
|
||||
let float = |key| number(t.get(key)).map(|v| v as f32);
|
||||
Self::NumberRange {
|
||||
@@ -395,6 +412,44 @@ impl Control {
|
||||
)),
|
||||
}
|
||||
}
|
||||
/// The text on a cycle button or slider holding `value`
|
||||
/// (`CycleButton.createFullName`, `NumberRangeInput.computeLabel`).
|
||||
pub fn message(&self, value: &InputValue) -> Option<Text> {
|
||||
match (self, value) {
|
||||
(
|
||||
Self::SingleOption {
|
||||
options,
|
||||
label,
|
||||
label_visible,
|
||||
..
|
||||
},
|
||||
InputValue::Option(i),
|
||||
) => {
|
||||
let (id, display, _) = options.get(*i)?;
|
||||
let shown = display.clone().unwrap_or_else(|| Text::literal(id));
|
||||
Some(if *label_visible {
|
||||
Text::translate("options.generic_value", &[label.clone(), shown])
|
||||
} else {
|
||||
shown
|
||||
})
|
||||
}
|
||||
(
|
||||
Self::NumberRange {
|
||||
label,
|
||||
label_format,
|
||||
..
|
||||
},
|
||||
InputValue::Slider(_),
|
||||
) => {
|
||||
let shown = number_string(self.scaled(value)?);
|
||||
Some(Text::translate(
|
||||
label_format,
|
||||
&[label.clone(), Text::literal(shown)],
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `ValueGetter.asTemplateSubstitution`.
|
||||
pub fn substitution(&self, value: &InputValue) -> String {
|
||||
match (self, value) {
|
||||
@@ -536,6 +591,7 @@ impl Dialog {
|
||||
},
|
||||
"dialog_list" => Kind::DialogList {
|
||||
dialogs: t.get("dialogs")?.clone(),
|
||||
entries: Vec::new(),
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
button_width: int(t, "button_width", 150),
|
||||
@@ -543,20 +599,21 @@ impl Dialog {
|
||||
_ => return None,
|
||||
};
|
||||
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
|
||||
// A body or input it cannot read fails the whole dialog.
|
||||
let body = match t.get("body") {
|
||||
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
|
||||
Some(one) => Body::parse(one).into_iter().collect(),
|
||||
Some(Tag::List(items)) => items.iter().map(Body::parse).collect::<Option<_>>()?,
|
||||
Some(one) => vec![Body::parse(one)?],
|
||||
None => Vec::new(),
|
||||
};
|
||||
let inputs = list(t.get("inputs"))
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
.map(|i| {
|
||||
Some(Input {
|
||||
key: string(i.get("key"))?,
|
||||
control: Control::parse(i)?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect::<Option<_>>()?;
|
||||
Some(Self {
|
||||
kind,
|
||||
title: text(t.get("title")?),
|
||||
@@ -588,7 +645,51 @@ impl Dialog {
|
||||
Kind::Notice(b) => vec![b],
|
||||
Kind::Confirmation { yes, no } => vec![yes, no],
|
||||
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
|
||||
Kind::ServerLinks { exit, .. } => exit.iter().collect(),
|
||||
Kind::DialogList { entries, exit, .. } => entries.iter().chain(exit).collect(),
|
||||
}
|
||||
}
|
||||
/// `DialogListDialogScreen.createListActions`: a button for each of `dialogs`
|
||||
/// (what the list names), labelled by its external title, opening it.
|
||||
pub fn set_list(&mut self, dialogs: &[Tag]) {
|
||||
let Kind::DialogList {
|
||||
entries,
|
||||
button_width,
|
||||
..
|
||||
} = &mut self.kind
|
||||
else {
|
||||
return;
|
||||
};
|
||||
*entries = dialogs
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
let sub = Dialog::parse(tag)?;
|
||||
Some(Button {
|
||||
label: sub.external_title.unwrap_or(sub.title),
|
||||
tooltip: None,
|
||||
width: *button_width,
|
||||
action: Some(Action::Static(Click::ShowDialog(tag.clone()))),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
/// The dialogs a `dialog_list`'s `dialogs` names: the entries of a `#tag`
|
||||
/// (by `tag`), registry ids (by `registry`) and dialogs written inline.
|
||||
pub fn list_entries(
|
||||
dialogs: &Tag,
|
||||
tag: &dyn Fn(&str) -> Vec<Tag>,
|
||||
registry: &dyn Fn(&str) -> Option<Tag>,
|
||||
) -> Vec<Tag> {
|
||||
let one = |t: &Tag| match t {
|
||||
Tag::String(id) => registry(&id.to_string_lossy()),
|
||||
inline => Some(inline.clone()),
|
||||
};
|
||||
match dialogs {
|
||||
Tag::String(id) if id.to_string_lossy().starts_with('#') => {
|
||||
tag(&id.to_string_lossy()[1..])
|
||||
}
|
||||
Tag::List(items) => items.iter().filter_map(one).collect(),
|
||||
single => one(single).into_iter().collect(),
|
||||
}
|
||||
}
|
||||
/// What `status` reports of it.
|
||||
|
||||
@@ -31,6 +31,44 @@ pub enum Part {
|
||||
Check { input: usize, rect: [f32; 4] },
|
||||
/// A button of `Dialog::buttons`.
|
||||
Button { index: usize, rect: [f32; 4] },
|
||||
/// A single option input's cycle button.
|
||||
Cycle { input: usize, rect: [f32; 4] },
|
||||
/// A number range input's slider.
|
||||
Slider { input: usize, rect: [f32; 4] },
|
||||
/// An item body's widget; `body` indexes `Dialog::body`.
|
||||
Item { body: usize, rect: [f32; 4] },
|
||||
}
|
||||
impl Part {
|
||||
/// The part moved by `(dx, dy)`.
|
||||
fn moved(self, dx: f32, dy: f32) -> Self {
|
||||
match self {
|
||||
Self::Line(t, x, y) => Self::Line(t, x + dx, y + dy),
|
||||
Self::Field { input, rect } => Self::Field {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Check { input, rect } => Self::Check {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Button { index, rect } => Self::Button {
|
||||
index,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Cycle { input, rect } => Self::Cycle {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Slider { input, rect } => Self::Slider {
|
||||
input,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
Self::Item { body, rect } => Self::Item {
|
||||
body,
|
||||
rect: shift(rect, dx, dy),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -55,21 +93,7 @@ struct Element {
|
||||
impl Element {
|
||||
/// Its parts moved to `(x, y)`.
|
||||
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
|
||||
self.parts.into_iter().map(move |p| match p {
|
||||
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
})
|
||||
self.parts.into_iter().map(move |p| p.moved(x, y))
|
||||
}
|
||||
}
|
||||
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
|
||||
@@ -100,9 +124,43 @@ fn message(font: &Font, contents: &Text, width: f32) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`, if Horizon has it.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
Some(match control {
|
||||
/// `DialogBodyHandlers.ItemHandler`: the item widget, with its description 2
|
||||
/// to the right of it, both centred on each other vertically.
|
||||
fn item(
|
||||
font: &Font,
|
||||
body: usize,
|
||||
description: Option<&(Text, i32)>,
|
||||
(w, h): (f32, f32),
|
||||
) -> Element {
|
||||
let widget = |rect_h: f32, y: f32| Part::Item {
|
||||
body,
|
||||
rect: [0.0, y, w, rect_h],
|
||||
};
|
||||
let Some((contents, width)) = description else {
|
||||
return Element {
|
||||
w,
|
||||
h,
|
||||
parts: vec![widget(h, 0.0)],
|
||||
};
|
||||
};
|
||||
let text = message(font, contents, *width as f32);
|
||||
let height = h.max(text.h);
|
||||
let mut parts = vec![widget(h, centre(height, h))];
|
||||
parts.extend(
|
||||
text.parts
|
||||
.into_iter()
|
||||
.map(|p| p.moved(w + 2.0, centre(height, text.h))),
|
||||
);
|
||||
Element {
|
||||
w: w + 2.0 + text.w,
|
||||
h: height,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
match control {
|
||||
Control::Text {
|
||||
width,
|
||||
label,
|
||||
@@ -111,14 +169,14 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
} => {
|
||||
let w = *width as f32;
|
||||
if !label_visible {
|
||||
return Some(Element {
|
||||
return Element {
|
||||
w,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, w, 20.0],
|
||||
}],
|
||||
});
|
||||
};
|
||||
}
|
||||
// CommonLayouts.labeledElement: the label, 4 below it the box,
|
||||
// both on the left.
|
||||
@@ -145,8 +203,25 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
}],
|
||||
}
|
||||
}
|
||||
Control::SingleOption { .. } | Control::NumberRange { .. } => return None,
|
||||
})
|
||||
// CycleButton and AbstractSliderButton: one 20 high button whose
|
||||
// message holds the label.
|
||||
Control::SingleOption { width, .. } => Element {
|
||||
w: *width as f32,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Cycle {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, *width as f32, 20.0],
|
||||
}],
|
||||
},
|
||||
Control::NumberRange { width, .. } => Element {
|
||||
w: *width as f32,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Slider {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, *width as f32, 20.0],
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
|
||||
@@ -210,9 +285,20 @@ impl Layout {
|
||||
let mut elements: Vec<Element> = dialog
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
|
||||
Body::Item { .. } => None,
|
||||
.enumerate()
|
||||
.map(|(i, b)| match b {
|
||||
Body::Message { contents, width } => message(font, contents, *width as f32),
|
||||
Body::Item {
|
||||
description,
|
||||
width,
|
||||
height,
|
||||
..
|
||||
} => item(
|
||||
font,
|
||||
i,
|
||||
description.as_ref(),
|
||||
(*width as f32, *height as f32),
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
elements.extend(
|
||||
@@ -220,26 +306,24 @@ impl Layout {
|
||||
.inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, input_)| input(font, i, &input_.control)),
|
||||
.map(|(i, input_)| input(font, i, &input_.control)),
|
||||
);
|
||||
let buttons = dialog.buttons();
|
||||
let (listed, footer_buttons, footer_h) = match &dialog.kind {
|
||||
Kind::MultiAction {
|
||||
actions,
|
||||
exit,
|
||||
columns: n,
|
||||
exit, columns: n, ..
|
||||
}
|
||||
| Kind::DialogList {
|
||||
exit, columns: n, ..
|
||||
} => {
|
||||
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
|
||||
let listed = buttons.len() - usize::from(exit.is_some());
|
||||
let widths: Vec<f32> = buttons[..listed].iter().map(|b| b.width as f32).collect();
|
||||
elements.push(columns(&widths, *n as usize, 0));
|
||||
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
|
||||
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
|
||||
(
|
||||
actions.len(),
|
||||
footer,
|
||||
if exit.is_some() { BAR } else { 5.0 },
|
||||
)
|
||||
let footer = exit.as_ref().map(|_| listed).into_iter().collect();
|
||||
(listed, footer, if exit.is_some() { BAR } else { 5.0 })
|
||||
}
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
|
||||
Kind::ServerLinks { exit, .. } => {
|
||||
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
|
||||
(0, footer, if exit.is_some() { BAR } else { 5.0 })
|
||||
}
|
||||
@@ -303,24 +387,10 @@ impl Layout {
|
||||
/// whether it is in the body (clipped to its area).
|
||||
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
|
||||
let dy = self.area[1] - scroll;
|
||||
let body = self.body.iter().map(move |p| {
|
||||
let p = match p.clone() {
|
||||
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
};
|
||||
(p, true)
|
||||
});
|
||||
let body = self
|
||||
.body
|
||||
.iter()
|
||||
.map(move |p| (p.clone().moved(0.0, dy), true));
|
||||
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,11 @@
|
||||
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
|
||||
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
|
||||
//! entity (like a decorated pot's) until the first counted stroke rolls it.
|
||||
use super::{Game, Hit, decorated_pot, spill, view_vector};
|
||||
use super::{Game, Hit, decorated_pot, view_vector};
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light,
|
||||
containers::BlockEntity,
|
||||
mesher::Vertex,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
save,
|
||||
};
|
||||
@@ -114,6 +117,20 @@ impl Game<'_> {
|
||||
let time = self.world.game_time;
|
||||
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
|
||||
brush.hit_face.get_or_insert(face);
|
||||
let direction = match brush.hit_face.unwrap() {
|
||||
[0, -1, 0] => 0,
|
||||
[0, 1, 0] => 1,
|
||||
[0, 0, -1] => 2,
|
||||
[0, 0, 1] => 3,
|
||||
[-1, 0, 0] => 4,
|
||||
[1, 0, 0] => 5,
|
||||
_ => return false,
|
||||
};
|
||||
if let Some(BlockEntity::Brushable(b)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
b.hit_direction = Some(direction);
|
||||
}
|
||||
brush.resets_at = time + 40;
|
||||
if time < brush.cooldown_ends_at {
|
||||
self.brushing.insert(pos, brush);
|
||||
@@ -139,12 +156,18 @@ impl Game<'_> {
|
||||
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
|
||||
/// block entity's item.
|
||||
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
|
||||
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
if tag.get("LootTable").is_none() {
|
||||
if b.loot_table.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut tag = save::encode_block_entity(
|
||||
key,
|
||||
&BlockEntity::Brushable(b.clone()),
|
||||
"minecraft:brushable_block",
|
||||
);
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
decorated_pot::unpack_loot(
|
||||
&mut tag,
|
||||
@@ -153,7 +176,12 @@ impl Game<'_> {
|
||||
&mut self.rand_sequences,
|
||||
self.level.seed,
|
||||
);
|
||||
spill::put_saved_entity(&mut self.world, pos, tag);
|
||||
if let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get_mut(&key) {
|
||||
b.loot_table = tag.get("LootTable").cloned();
|
||||
b.loot_seed = tag.get("LootTableSeed").cloned();
|
||||
b.item = tag.get("item").and_then(save::item_from_nbt);
|
||||
}
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
|
||||
@@ -166,8 +194,10 @@ impl Game<'_> {
|
||||
_ => "minecraft:air",
|
||||
};
|
||||
// dropContent: the item at the middle of the block beside that face.
|
||||
let item = spill::saved_entity(&self.world, pos)
|
||||
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
|
||||
let item = match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
|
||||
Some(BlockEntity::Brushable(b)) => b.item.clone(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(mut item) = item {
|
||||
let face = face.unwrap_or([0, 1, 0]);
|
||||
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
|
||||
@@ -221,12 +251,70 @@ impl Game<'_> {
|
||||
if brush.count != 0 {
|
||||
self.brushing.insert(pos, brush);
|
||||
self.schedule_brushable_tick(pos);
|
||||
} else if let Some(BlockEntity::Brushable(b)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
b.hit_direction = None;
|
||||
}
|
||||
if before != after {
|
||||
self.set_dusted(pos, after);
|
||||
}
|
||||
}
|
||||
|
||||
/// `BrushableBlockRenderer`: reveal the item from the first brushed face.
|
||||
pub(super) fn brushable_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
for (key, entity) in &self.world.block_entities {
|
||||
let BlockEntity::Brushable(b) = entity else {
|
||||
continue;
|
||||
};
|
||||
let (Some(item), Some(face)) = (&b.item, b.hit_direction) else {
|
||||
continue;
|
||||
};
|
||||
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
if at.distance_squared(camera) >= 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
let state = self.world.block(key.0, key.1, key.2);
|
||||
let progress = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("dusted"))
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
if progress == 0 {
|
||||
continue;
|
||||
}
|
||||
let offset = f32::from(progress) * 0.075;
|
||||
let (translation, adjacent, east_west) = match face {
|
||||
0 => (glam::Vec3::new(0.5, -0.23 - offset, 0.5), [0, -1, 0], false),
|
||||
1 => (glam::Vec3::new(0.5, 0.25 + offset, 0.5), [0, 1, 0], false),
|
||||
2 => (glam::Vec3::new(0.5, 0.0, 0.25 - offset), [0, 0, -1], false),
|
||||
3 => (glam::Vec3::new(0.5, 0.0, 0.73 + offset), [0, 0, 1], false),
|
||||
4 => (glam::Vec3::new(0.25 - offset, 0.0, 0.5), [-1, 0, 0], true),
|
||||
5 => (glam::Vec3::new(0.73 + offset, 0.0, 0.5), [1, 0, 0], true),
|
||||
_ => continue,
|
||||
};
|
||||
let item = self.resolve_item(item, DisplayContext::Fixed, None, 0);
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let pose = glam::Mat4::from_translation(
|
||||
(at - camera).as_vec3() + glam::Vec3::new(0.0, 0.5, 0.0) + translation,
|
||||
) * glam::Mat4::from_rotation_y(
|
||||
(86.0_f32 + if east_west { 90.0 } else { 0.0 }).to_radians(),
|
||||
) * glam::Mat4::from_scale(glam::Vec3::splat(0.5));
|
||||
let light = self.world.light(
|
||||
key.0 + adjacent[0],
|
||||
key.1 + adjacent[1],
|
||||
key.2 + adjacent[2],
|
||||
);
|
||||
self.submit_item_world(&item, pose, vertex_light(light), out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
|
||||
/// and break particles.
|
||||
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
|
||||
|
||||
@@ -1149,7 +1149,10 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let path = dir.join(name);
|
||||
renderer.screenshot = Some(path.clone());
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
});
|
||||
self.ui.screenshot_message = Some(path);
|
||||
}
|
||||
/// After a frame: report a finished F2 screenshot in chat.
|
||||
|
||||
@@ -22,7 +22,7 @@ const HELP: &[&str] = &[
|
||||
"tp X Y Z teleport the player",
|
||||
"hotbar 1-9 select a hotbar slot",
|
||||
"give ITEM [COUNT] add items to the inventory",
|
||||
"render-distance N chunks, 2..64",
|
||||
"render-distance N chunks, 2..128",
|
||||
"lod-distance N far surface chunks, 0 disables, up to1024",
|
||||
"time TICKS set the time of day",
|
||||
"summon KIND [X Y Z] [baby] spawn a mob (in front of the player without a position)",
|
||||
@@ -516,8 +516,9 @@ impl Game<'_> {
|
||||
}
|
||||
"render-distance" => {
|
||||
let n: i32 = arg(1)?.parse()?;
|
||||
if !(2..=64).contains(&n) {
|
||||
return Err("render distance must be 2..64".into());
|
||||
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
|
||||
if !(2..=max).contains(&n) {
|
||||
return Err(format!("render distance must be 2..{max}").into());
|
||||
}
|
||||
self.settings.render_distance = n;
|
||||
self.apply_settings();
|
||||
@@ -587,7 +588,10 @@ impl Game<'_> {
|
||||
arg(1)?;
|
||||
// The rest of the line: a path may hold spaces.
|
||||
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
|
||||
renderer.screenshot = Some(path.trim().into());
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.trim().into(),
|
||||
size: None,
|
||||
});
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -416,6 +416,13 @@ impl Game<'_> {
|
||||
],
|
||||
);
|
||||
}
|
||||
// Horizon's own: HDR's output while the setting is on, under the GPU (a group of its
|
||||
// own would move others to the other column).
|
||||
if self.settings.horizon.hdr
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
{
|
||||
d.group("system", [r.hdr_line()]);
|
||||
}
|
||||
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
|
||||
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
|
||||
debug_overlay::shader_pass_lines(&r.shader_pass_times())
|
||||
|
||||
@@ -26,12 +26,105 @@ use horizon_world::nbt::{JavaString, Tag};
|
||||
use std::{
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex, OnceLock, mpsc},
|
||||
sync::{
|
||||
Arc, Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
mod status;
|
||||
|
||||
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
|
||||
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
|
||||
struct Watchdog {
|
||||
base: Instant,
|
||||
next_nanos: AtomicU64,
|
||||
tick_nanos: AtomicU64,
|
||||
running: AtomicBool,
|
||||
}
|
||||
|
||||
impl Watchdog {
|
||||
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
|
||||
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
|
||||
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
|
||||
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
|
||||
let watchdog = Arc::new(Self {
|
||||
base: Instant::now(),
|
||||
next_nanos: AtomicU64::new(0),
|
||||
tick_nanos: AtomicU64::new(50_000_000),
|
||||
running: AtomicBool::new(true),
|
||||
});
|
||||
let shared = watchdog.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("Server Watchdog".into())
|
||||
.spawn(move || shared.run(max))
|
||||
.ok()?;
|
||||
Some(watchdog)
|
||||
}
|
||||
/// The tick loop is about to run the tick due at `next`.
|
||||
fn tick_due(&self, next: Instant, tick: Duration) {
|
||||
let nanos = next.saturating_duration_since(self.base).as_nanos();
|
||||
self.next_nanos
|
||||
.store(u64::try_from(nanos).unwrap_or(u64::MAX), Ordering::Relaxed);
|
||||
let tick = u64::try_from(tick.as_nanos()).unwrap_or(u64::MAX);
|
||||
self.tick_nanos.store(tick, Ordering::Relaxed);
|
||||
}
|
||||
/// `ServerWatchdog.run`.
|
||||
fn run(&self, max: Duration) {
|
||||
let max = u64::try_from(max.as_nanos()).unwrap_or(u64::MAX);
|
||||
while self.running.load(Ordering::Relaxed) {
|
||||
let next = self.next_nanos.load(Ordering::Relaxed);
|
||||
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
|
||||
let behind = now.saturating_sub(next);
|
||||
if behind > max {
|
||||
let took = format!("{:.2}", behind as f32 / 1e9);
|
||||
let should = format!(
|
||||
"{:.2}",
|
||||
self.tick_nanos.load(Ordering::Relaxed) as f32 / 1e9
|
||||
);
|
||||
log_from(
|
||||
"Server Watchdog",
|
||||
"ERROR",
|
||||
&format!("A single server tick took {took} seconds (should be max {should})"),
|
||||
);
|
||||
log_from(
|
||||
"Server Watchdog",
|
||||
"ERROR",
|
||||
"Considering it to be crashed, server will forcibly shutdown.",
|
||||
);
|
||||
let message = format!(
|
||||
"ServerWatchdog detected that a single server tick took {took} seconds (should be max {should})"
|
||||
);
|
||||
match horizon_client::crash::report_watchdog(
|
||||
std::path::Path::new("."),
|
||||
"server",
|
||||
message,
|
||||
) {
|
||||
Some(path) => log_from(
|
||||
"Server Watchdog",
|
||||
"ERROR",
|
||||
&format!(
|
||||
"This crash report has been saved to: {}",
|
||||
path.canonicalize().unwrap_or(path).display()
|
||||
),
|
||||
),
|
||||
None => log_from(
|
||||
"Server Watchdog",
|
||||
"ERROR",
|
||||
"We were unable to save this crash report to disk.",
|
||||
),
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Thread.sleep(nextTickTime + maxTickTime - now).
|
||||
let wait = (next + max).saturating_sub(now).max(1_000_000);
|
||||
std::thread::sleep(Duration::from_nanos(wait));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Double.toString for the magnitudes positions have (no exponent form
|
||||
/// between 1e-3 and 1e7).
|
||||
pub(super) fn java_double(v: f64) -> String {
|
||||
@@ -703,8 +796,13 @@ impl Game<'_> {
|
||||
let pause_after = props.pause_when_empty_seconds * 20;
|
||||
let idle_timeout = props.player_idle_timeout;
|
||||
let mut next = Instant::now();
|
||||
self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8);
|
||||
let watchdog = Watchdog::start(props.max_tick_time);
|
||||
while !state.stop {
|
||||
let tick_start = Instant::now();
|
||||
if let Some(watchdog) = &watchdog {
|
||||
watchdog.tick_due(next, self.clock.tick_duration());
|
||||
}
|
||||
while let Ok(line) = state.console.try_recv() {
|
||||
self.console_line(&mut state, &line);
|
||||
}
|
||||
@@ -745,6 +843,9 @@ impl Game<'_> {
|
||||
state.ticked(Instant::now());
|
||||
self.wait_for_next_server_tick(&mut next, paused);
|
||||
}
|
||||
if let Some(watchdog) = &watchdog {
|
||||
watchdog.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
self.stop_dedicated()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
Result,
|
||||
clipboard::EditShortcut,
|
||||
confirm_screen::Confirm,
|
||||
dialog::{AfterAction, Click, Control, Dialog, InputValue},
|
||||
dialog::{AfterAction, Body, Click, Control, Dialog, InputValue, Kind},
|
||||
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
|
||||
hud::{HudBatch, rgba},
|
||||
text::Text,
|
||||
@@ -26,6 +26,8 @@ struct Open {
|
||||
fields: Vec<Option<ui::EditBox>>,
|
||||
focus: Option<usize>,
|
||||
scroll: ui::Scroll,
|
||||
/// The number range input whose slider is held.
|
||||
dragging: Option<usize>,
|
||||
}
|
||||
|
||||
/// The dialog screens a server can open.
|
||||
@@ -38,6 +40,17 @@ pub(super) struct DialogState {
|
||||
warning: bool,
|
||||
}
|
||||
|
||||
/// `CycleButton.cycleValue`: the next option (`delta` 1) or the one before
|
||||
/// it, wrapping.
|
||||
fn cycle(open: &mut Open, input: usize, delta: i32) {
|
||||
let Control::SingleOption { options, .. } = &open.dialog.inputs[input].control else {
|
||||
return;
|
||||
};
|
||||
if let Some(InputValue::Option(i)) = open.values.get_mut(input) {
|
||||
*i = (*i as i32 + delta).rem_euclid(options.len().max(1) as i32) as usize;
|
||||
}
|
||||
}
|
||||
|
||||
fn warning_texts() -> (Text, Text, [Text; 2]) {
|
||||
let t = |key: &str| Text::translate(key, &[]);
|
||||
(
|
||||
@@ -57,10 +70,35 @@ impl Game<'_> {
|
||||
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
|
||||
/// under the warning screen, it is what Back returns to.
|
||||
pub(super) fn show_dialog(&mut self, tag: &Tag) {
|
||||
let Some(dialog) = Dialog::parse(tag) else {
|
||||
let Some(mut dialog) = Dialog::parse(tag) else {
|
||||
log::warn!("Horizon: failed to show dialog for data {tag:?}");
|
||||
return;
|
||||
};
|
||||
if let Kind::DialogList { dialogs, .. } = &dialog.kind {
|
||||
let entries = self.remote.as_ref().map(|r| {
|
||||
let list = r.registries.registries.get("minecraft:dialog");
|
||||
let by_id = |id: &str| {
|
||||
let name = if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
list?.iter().find(|(n, _)| *n == name)?.1.clone()
|
||||
};
|
||||
let by_tag = |name: &str| {
|
||||
let name = if name.contains(':') {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let ids = r.registries.dialog_tags.get(&name).into_iter().flatten();
|
||||
ids.filter_map(|&i| list?.get(usize::try_from(i).ok()?)?.1.clone())
|
||||
.collect()
|
||||
};
|
||||
Dialog::list_entries(dialogs, &by_tag, &by_id)
|
||||
});
|
||||
dialog.set_list(&entries.unwrap_or_default());
|
||||
}
|
||||
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
|
||||
let fields = dialog
|
||||
.inputs
|
||||
@@ -87,6 +125,7 @@ impl Game<'_> {
|
||||
fields,
|
||||
focus: None,
|
||||
scroll: ui::Scroll::default(),
|
||||
dragging: None,
|
||||
});
|
||||
self.dialogs.waiting = None;
|
||||
self.capture(false);
|
||||
@@ -123,6 +162,17 @@ impl Game<'_> {
|
||||
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
|
||||
/// `after_action` leaves.
|
||||
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
|
||||
// defaultHandleClickEvent: an open_url never sets the screen itself, so
|
||||
// the dialog stays unless the confirmation takes over (Web Links off,
|
||||
// or Prompt on Links off, opens nothing or the browser behind it).
|
||||
if let Some(Click::OpenUrl(url)) = &click
|
||||
&& !(self.settings.chat_links && self.settings.file.bool("chatLinksPrompt"))
|
||||
{
|
||||
if self.settings.chat_links {
|
||||
super::packs::open_uri(url);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
match after {
|
||||
AfterAction::None => {}
|
||||
AfterAction::Close => self.dialogs.open = None,
|
||||
@@ -153,7 +203,11 @@ impl Game<'_> {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"),
|
||||
// ConfirmLinkScreen over the screen the dialog leaves.
|
||||
Some(Click::OpenUrl(url)) => self.click_url(url),
|
||||
// Screen.insertText: the screen after the dialog takes no text.
|
||||
Some(Click::SuggestCommand(_)) => {}
|
||||
Some(other) => log::error!("Don't know how to handle {other:?}"),
|
||||
None => {}
|
||||
}
|
||||
if !self.dialog_open() {
|
||||
@@ -262,7 +316,8 @@ impl Game<'_> {
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
// Out of `self` while it draws: an item body draws through `self`.
|
||||
let Some(mut open) = self.dialogs.open.take() else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -291,6 +346,7 @@ impl Game<'_> {
|
||||
let buttons = open.dialog.buttons();
|
||||
let in_area = |y: f32| y >= ay && y < ay + ah;
|
||||
let mut tooltip = None;
|
||||
let mut item_tip = None;
|
||||
let mut start = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && start.is_none() {
|
||||
@@ -333,6 +389,64 @@ impl Game<'_> {
|
||||
true,
|
||||
);
|
||||
}
|
||||
Part::Cycle { input, rect } => {
|
||||
let [x, y, cw, ch] = rect;
|
||||
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, cw, ch);
|
||||
let Some(label) = open
|
||||
.values
|
||||
.get(input)
|
||||
.and_then(|v| open.dialog.inputs[input].control.message(v))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
ui::button(batch, atlas, x, y, cw, ch, &label, true, hover);
|
||||
}
|
||||
Part::Slider { input, rect } => {
|
||||
let [x, y, sw, sh] = rect;
|
||||
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, sw, sh);
|
||||
let Some((InputValue::Slider(position), Some(label))) = open
|
||||
.values
|
||||
.get(input)
|
||||
.map(|v| (v.clone(), open.dialog.inputs[input].control.message(v)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let held = open.dragging == Some(input);
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
sw,
|
||||
&label,
|
||||
position,
|
||||
hover || held,
|
||||
true,
|
||||
);
|
||||
}
|
||||
Part::Item { body, rect } => {
|
||||
let [x, y, iw, ih] = rect;
|
||||
let Some(Body::Item {
|
||||
item,
|
||||
show_decorations,
|
||||
show_tooltip,
|
||||
..
|
||||
}) = open.dialog.body.get(body)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(stack) = horizon_items::item_nbt::item_from_nbt(item) else {
|
||||
continue;
|
||||
};
|
||||
// ItemDisplayWidget: the item at its corner, then its decorations.
|
||||
self.draw_item_model(batch, &stack, x, y);
|
||||
if *show_decorations {
|
||||
self.draw_item_decorations(batch, &stack, x, y, None);
|
||||
}
|
||||
if *show_tooltip && in_area(cursor.1) && ui::hovered(cursor, x, y, iw, ih) {
|
||||
item_tip = Some(stack);
|
||||
}
|
||||
}
|
||||
Part::Button { index, rect } => {
|
||||
let [x, y, bw, bh] = rect;
|
||||
let hover =
|
||||
@@ -371,6 +485,10 @@ impl Game<'_> {
|
||||
.collect();
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
}
|
||||
self.dialogs.open = Some(open);
|
||||
if let Some(stack) = item_tip {
|
||||
self.draw_stack_tooltip(batch, &stack, cursor.0, cursor.1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A left click on a dialog screen.
|
||||
@@ -420,6 +538,7 @@ impl Game<'_> {
|
||||
self.dialogs.warning = true;
|
||||
return Ok(());
|
||||
}
|
||||
let shift = self.modifiers.shift_key();
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -450,6 +569,23 @@ impl Game<'_> {
|
||||
}
|
||||
action = Some(None);
|
||||
}
|
||||
Part::Cycle {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
// CycleButton.onPress: Shift goes back.
|
||||
cycle(open, input, if shift { -1 } else { 1 });
|
||||
action = Some(None);
|
||||
}
|
||||
Part::Slider {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
open.dragging = Some(input);
|
||||
if let Some(v) = open.values.get_mut(input) {
|
||||
*v = InputValue::Slider(ui::slider_value(x, w, cursor.0));
|
||||
}
|
||||
}
|
||||
Part::Button {
|
||||
index,
|
||||
rect: [x, y, w, h],
|
||||
@@ -479,17 +615,65 @@ impl Game<'_> {
|
||||
self.run_dialog_action(click, after)
|
||||
}
|
||||
|
||||
/// The mouse wheel over a dialog: its body scrolls 10 a step.
|
||||
/// The mouse wheel over a dialog: a cycle button under it takes the
|
||||
/// step, else the body scrolls 10 a step.
|
||||
pub(super) fn dialog_scroll(&mut self, delta: f32) {
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let cursor = self.cursor;
|
||||
if let Some(open) = self.dialogs.open.as_mut() {
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
if delta != 0.0 && cursor.1 >= ay && cursor.1 < ay + ah {
|
||||
let hovered = layout
|
||||
.placed(open.scroll.amount)
|
||||
.find_map(|(part, _)| match part {
|
||||
Part::Cycle {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => Some(input),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(input) = hovered {
|
||||
// CycleButton.mouseScrolled: up goes back.
|
||||
cycle(open, input, if delta > 0.0 { -1 } else { 1 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer moved with a slider held (`AbstractSliderButton.onDrag`).
|
||||
pub(super) fn dialog_drag(&mut self) {
|
||||
let Some(input) = self.dialogs.open.as_ref().and_then(|o| o.dragging) else {
|
||||
return;
|
||||
};
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let x = self.cursor.0;
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let slider = layout.body.iter().find_map(|p| match p {
|
||||
Part::Slider { input: i, rect } if *i == input => Some(*rect),
|
||||
_ => None,
|
||||
});
|
||||
if let (Some([sx, _, sw, _]), Some(value)) = (slider, open.values.get_mut(input)) {
|
||||
*value = InputValue::Slider(ui::slider_value(sx, sw, x));
|
||||
}
|
||||
}
|
||||
|
||||
/// The left button released: a held slider lets go and clicks.
|
||||
pub(super) fn dialog_release(&mut self) {
|
||||
if let Some(open) = self.dialogs.open.as_mut()
|
||||
&& open.dragging.take().is_some()
|
||||
{
|
||||
self.audio.play_button_click();
|
||||
}
|
||||
}
|
||||
|
||||
/// A key pressed on a dialog screen: Escape closes what allows it, Tab
|
||||
/// moves between the text inputs, the rest goes to the focused one
|
||||
/// (`shortcut`: the press as an edit shortcut; `text`: what it types).
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 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},
|
||||
@@ -16,10 +17,18 @@ 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.
|
||||
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
/// EnderDragonFight gateway ring radius and height.
|
||||
@@ -38,6 +47,18 @@ pub(super) enum GatewayJob {
|
||||
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(
|
||||
@@ -94,6 +115,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.
|
||||
@@ -937,6 +963,17 @@ impl Game<'_> {
|
||||
self.save_gateway_exits()
|
||||
}
|
||||
|
||||
/// The exit a gateway already leads to: its block entity, or the side file
|
||||
/// that kept it for a gateway whose chunk is not loaded.
|
||||
fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let saved = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
|
||||
_ => None,
|
||||
};
|
||||
saved.or_else(|| self.gateway_exits().get(&gateway).copied())
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
@@ -1033,13 +1070,7 @@ 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]]
|
||||
@@ -1049,6 +1080,14 @@ impl Game<'_> {
|
||||
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.
|
||||
let known = self.known_gateway_exit(gateway);
|
||||
if let Some((exit, exact)) = known {
|
||||
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();
|
||||
@@ -1410,6 +1449,190 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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 (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
|
||||
let at = match self.resolve_gateway(job, target) {
|
||||
Ok(at) => at,
|
||||
Err(e) => {
|
||||
// The traveller waits for the exit: the failure is retried.
|
||||
log::warn!("End gateway: {e}");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut tag = self.end.arrivals.remove(i).tag;
|
||||
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)]
|
||||
|
||||
@@ -36,6 +36,8 @@ pub(super) struct FnState {
|
||||
load_pending: bool,
|
||||
pub schedule: Vec<Scheduled>,
|
||||
next_seq: u64,
|
||||
/// A dedicated server's `function-permission-level`.
|
||||
pub permission: Option<u8>,
|
||||
}
|
||||
/// What a function reported through `return`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -45,6 +47,16 @@ pub(super) enum Outcome {
|
||||
Fail,
|
||||
}
|
||||
|
||||
impl FnState {
|
||||
/// The level function files compile at (`ServerFunctionLibrary`'s
|
||||
/// `functionCompilationPermissions`): `function-permission-level` on a
|
||||
/// dedicated server, game master (2) otherwise. Whoever runs a function,
|
||||
/// its commands need no more than this.
|
||||
fn command_level(&self) -> u8 {
|
||||
self.permission.unwrap_or(2)
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
// ---- loading -----------------------------------------------------------
|
||||
|
||||
@@ -278,6 +290,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.exec.function_depth += 1;
|
||||
let outer = self.exec.returned.take();
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, self.fns.command_level());
|
||||
let inner = Source {
|
||||
silent: true,
|
||||
store: None,
|
||||
@@ -292,6 +305,7 @@ impl Game<'_> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.ui.registries.permission = level;
|
||||
let returned = std::mem::replace(&mut self.exec.returned, outer);
|
||||
self.exec.function_depth -= 1;
|
||||
Ok(match returned {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! escape hatch. LAN guests and dedicated servers use the same code either
|
||||
//! way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
Game, Options, PackResources, Resources, UiResources, WorldIcon,
|
||||
execution_role::ExecutionRole,
|
||||
lan::{LanSettings, Owner},
|
||||
world_loading::WorldRequest,
|
||||
@@ -19,7 +19,6 @@ use super::{
|
||||
use horizon_client::{
|
||||
Result,
|
||||
audio::Audio,
|
||||
hud::HudAtlas,
|
||||
lan::{Lan, MAX_PLAYERS, NetConfig, hub::HubConfig},
|
||||
settings::Settings,
|
||||
title::WorldOptions,
|
||||
@@ -325,6 +324,15 @@ impl Game<'_> {
|
||||
};
|
||||
handle.world_name = world_name;
|
||||
self.connect_local(pipe, &player)?;
|
||||
// IntegratedServer.getWorldScreenshotFile: the client grabs the frame
|
||||
// the Select World list shows, and needs the save folder for it.
|
||||
self.world_icon = Some(WorldIcon {
|
||||
path: std::env::current_dir()?
|
||||
.join("saves")
|
||||
.join(folder)
|
||||
.join("icon.png"),
|
||||
last_attempt: None,
|
||||
});
|
||||
self.integrated = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
@@ -810,14 +818,7 @@ fn server_game(
|
||||
settings: Settings,
|
||||
) -> Result<Game<'_>> {
|
||||
// It draws nothing: only the font, which books and chat measure with.
|
||||
let atlas = HudAtlas::fonts(&options.resources)?;
|
||||
let gui = UiResources {
|
||||
atlas,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces {
|
||||
size: 1,
|
||||
pixels: [0u8; 4].repeat(6),
|
||||
}),
|
||||
};
|
||||
let gui = UiResources::server(&options.resources)?;
|
||||
let audio = Audio::silent(&options.resources);
|
||||
Game::new(res, pack, gui, options, settings, audio, None)
|
||||
}
|
||||
|
||||
@@ -826,35 +826,23 @@ impl Game<'_> {
|
||||
.facts()
|
||||
.get(self.world.block(pos.0, pos.1, pos.2))
|
||||
.name;
|
||||
let (menu, title, slots) = if &**name == "minecraft:ender_chest" {
|
||||
let (menu, slots) = if &**name == "minecraft:ender_chest" {
|
||||
// EnderChestBlock.useWithoutItem: the player's own inventory.
|
||||
(
|
||||
"minecraft:generic_9x3",
|
||||
"container.enderchest",
|
||||
Slots::Ender,
|
||||
)
|
||||
("minecraft:generic_9x3", Slots::Ender)
|
||||
} else if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get(&pos) {
|
||||
// AbstractFurnaceBlock.openContainer: FurnaceMenu, BlastFurnaceMenu, SmokerMenu.
|
||||
let (menu, title) = match f.kind.recipe_type() {
|
||||
"minecraft:blasting" => ("minecraft:blast_furnace", "container.blast_furnace"),
|
||||
"minecraft:smoking" => ("minecraft:smoker", "container.smoker"),
|
||||
_ => ("minecraft:furnace", "container.furnace"),
|
||||
let menu = match f.kind.recipe_type() {
|
||||
"minecraft:blasting" => "minecraft:blast_furnace",
|
||||
"minecraft:smoking" => "minecraft:smoker",
|
||||
_ => "minecraft:furnace",
|
||||
};
|
||||
(menu, title, Slots::Block(pos))
|
||||
(menu, Slots::Block(pos))
|
||||
} else if let Some(BlockEntity::Crafter(_)) = self.world.block_entities.get(&pos) {
|
||||
// CrafterBlock.useWithoutItem: CrafterMenu over the block entity.
|
||||
(
|
||||
"minecraft:crafter_3x3",
|
||||
"container.crafter",
|
||||
Slots::Block(pos),
|
||||
)
|
||||
("minecraft:crafter_3x3", Slots::Block(pos))
|
||||
} else if let Some(BlockEntity::Brewing(_)) = self.world.block_entities.get(&pos) {
|
||||
// BrewingStandBlock.useWithoutItem: BrewingStandMenu.
|
||||
(
|
||||
"minecraft:brewing_stand",
|
||||
"container.brewing",
|
||||
Slots::Block(pos),
|
||||
)
|
||||
("minecraft:brewing_stand", Slots::Block(pos))
|
||||
} else if let Some((other, right)) = self.chest_partner(pos) {
|
||||
// ChestBlock.combine: the right half's slots come first.
|
||||
let halves = if right {
|
||||
@@ -862,24 +850,33 @@ impl Game<'_> {
|
||||
} else {
|
||||
Slots::Double(other, pos)
|
||||
};
|
||||
("minecraft:generic_9x6", "container.chestDouble", halves)
|
||||
("minecraft:generic_9x6", halves)
|
||||
} else {
|
||||
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else {
|
||||
return false;
|
||||
};
|
||||
let (menu, title) = match items.len() {
|
||||
27 => ("minecraft:generic_9x3", "container.chest"),
|
||||
9 => ("minecraft:generic_3x3", "container.dispenser"),
|
||||
5 => ("minecraft:hopper", "container.hopper"),
|
||||
let menu = match items.len() {
|
||||
27 => "minecraft:generic_9x3",
|
||||
9 => "minecraft:generic_3x3",
|
||||
5 => "minecraft:hopper",
|
||||
_ => return false,
|
||||
};
|
||||
let title = match &**name {
|
||||
"minecraft:barrel" => "container.barrel",
|
||||
"minecraft:dropper" => "container.dropper",
|
||||
n if n.ends_with("shulker_box") => "container.shulkerBox",
|
||||
_ => title,
|
||||
};
|
||||
(menu, title, Slots::Block(pos))
|
||||
(menu, Slots::Block(pos))
|
||||
};
|
||||
// The menu provider's getDisplayName: a container's custom name
|
||||
// (BaseContainerBlockEntity.getName) or its default name.
|
||||
let custom = |k: (i32, i32, i32)| self.world.block_names.get(&k).map(String::as_str);
|
||||
let title = match slots {
|
||||
Slots::Ender => horizon_protocol::text::translatable("container.enderchest", &[]),
|
||||
Slots::Double(first, second) => {
|
||||
horizon_client::containers::double_chest_name(custom(first), custom(second))
|
||||
}
|
||||
Slots::Block(k) => {
|
||||
let Some(key) = horizon_client::containers::default_name_key(name) else {
|
||||
return false;
|
||||
};
|
||||
horizon_client::containers::display_name(custom(k), key)
|
||||
}
|
||||
};
|
||||
let Some(items) = host
|
||||
.hub
|
||||
@@ -891,14 +888,8 @@ impl Game<'_> {
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
host.hub.open_container(
|
||||
conn,
|
||||
menu,
|
||||
horizon_protocol::text::translatable(title, &[]),
|
||||
pos,
|
||||
slots,
|
||||
items,
|
||||
);
|
||||
host.hub
|
||||
.open_container(conn, menu, title, pos, slots, items);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1246,14 +1237,25 @@ impl Game<'_> {
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.yaw, g.pitch) = (p.yaw, p.pitch);
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let used = self.as_guest(host, conn, |g| {
|
||||
// A horn's sound, inline instruments included.
|
||||
let horn = g.with_hand(hand, |g| {
|
||||
let held = g.held()?;
|
||||
g.res.items().instrument(held).map(|i| i.sound)
|
||||
});
|
||||
g.guest_use = Some(GuestUse::Item(hand));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
g.guest_use = None;
|
||||
g.swings_started != swings
|
||||
(g.swings_started != swings, horn)
|
||||
});
|
||||
let (swung, horn) = match used {
|
||||
Some((swung, horn)) => (Some(swung), horn),
|
||||
None => (None, None),
|
||||
};
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
self.guest_instrument_sound(host, conn, heard, horn);
|
||||
}
|
||||
S::Attack(p) => self.guest_attack(host, conn, p.id),
|
||||
S::Interact(p) => {
|
||||
@@ -2029,8 +2031,11 @@ impl Game<'_> {
|
||||
let d = vanilla::game_dispatcher();
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
|
||||
let parse = d.parse(command, &self.ui.registries);
|
||||
let ((start, end), suggestions) = d.suggestions(&parse, command.len(), &self.ui.registries);
|
||||
let ((start, end), mut suggestions) =
|
||||
d.suggestions(&parse, command.len(), &self.ui.registries);
|
||||
self.ui.registries.permission = level;
|
||||
// ServerCommandSuggestionsProvider.limitSuggestionCount.
|
||||
suggestions.truncate(1000);
|
||||
let utf16 = |byte: usize| command[..byte.min(command.len())].encode_utf16().count() as i32;
|
||||
let mut w = horizon_protocol::buf::Writer::new();
|
||||
w.varint(id);
|
||||
@@ -3232,6 +3237,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `InstrumentItem.play`'s `level.playSound(player, ...)`: the others hear
|
||||
/// a guest's horn now, the guest's own client played it as it used it.
|
||||
fn guest_instrument_sound(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
heard: usize,
|
||||
horn: Option<String>,
|
||||
) {
|
||||
let (Some(horn), Some(capture)) = (horn, self.audio.capture.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let mut i = heard.min(capture.len());
|
||||
while i < capture.len() {
|
||||
if capture[i].0 == horn {
|
||||
let (name, at, volume, pitch) = capture.remove(i);
|
||||
let source = horizon_client::settings::sound_source(&name) as i32;
|
||||
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Positional sounds and level events of the tick, for the guests.
|
||||
pub(super) fn lan_broadcast_effects(&mut self, host: &mut LanHost) {
|
||||
let sounds = self.audio.capture.take().unwrap_or_default();
|
||||
|
||||
@@ -435,15 +435,17 @@ pub(super) fn shelf_signal(
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
let tag = super::spill::saved_entity(world, [pos.x, pos.y, pos.z]);
|
||||
// Only read from the back: a bit for each of the three slots in use.
|
||||
let facing = info.properties.get("facing").and_then(|f| dir_from_name(f));
|
||||
if facing.map(Direction::opposite) != Some(direction) {
|
||||
return Some(0);
|
||||
}
|
||||
let items = horizon_client::save::decode_items(tag.as_ref().and_then(|t| t.get("Items")), 3);
|
||||
let Some(BlockEntity::Shelf(shelf)) = world.block_entities.get(&(pos.x, pos.y, pos.z)) else {
|
||||
return Some(0);
|
||||
};
|
||||
Some(
|
||||
items
|
||||
shelf
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(slot, item)| i32::from(item.is_some()) << slot)
|
||||
@@ -486,6 +488,12 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.chiseled_bookshelf_synced(pos);
|
||||
if matches!(
|
||||
self.world.block_entities.get(&pos),
|
||||
Some(BlockEntity::Shelf(_))
|
||||
) {
|
||||
self.mark_block_entity_updated(pos);
|
||||
}
|
||||
// A double chest's other half changes with it.
|
||||
let halves: Vec<Pos> = std::iter::once(pos)
|
||||
.chain(self.chest_other_half(pos))
|
||||
|
||||
@@ -246,9 +246,15 @@ impl Game<'_> {
|
||||
self.paint_screen(batch, ox, oy, background, true);
|
||||
let atlas = &self.gui.atlas;
|
||||
let grey = rgba(64, 64, 64, 255);
|
||||
let title = self.container_title(self.translate("container.crafter", "Crafter"));
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(
|
||||
// A server's crafter menu has the title it was given.
|
||||
let title = self.remote_menu_title().unwrap_or_else(|| {
|
||||
horizon_client::text::Text::literal(
|
||||
self.container_title(self.translate("container.crafter", "Crafter")),
|
||||
)
|
||||
.resolve_legacy()
|
||||
});
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&title,
|
||||
ox + ((176.0 - tw) / 2.0).trunc(),
|
||||
|
||||
@@ -1224,6 +1224,21 @@ impl Game<'_> {
|
||||
light: u32,
|
||||
diffuse: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.tinted_block_model_vertices(state, seed, m, light, diffuse, |_| None, out);
|
||||
}
|
||||
/// `block_model_vertices` with each face multiplied by `tint` of its
|
||||
/// tint index (RGB, `None` for untinted faces).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn tinted_block_model_vertices(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
m: glam::Mat4,
|
||||
light: u32,
|
||||
diffuse: bool,
|
||||
tint: impl Fn(i32) -> Option<u32>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
|
||||
return;
|
||||
@@ -1245,11 +1260,17 @@ impl Game<'_> {
|
||||
D::North | D::South => 204,
|
||||
D::West | D::East => 153,
|
||||
};
|
||||
let color = if diffuse {
|
||||
let mut color = if diffuse {
|
||||
u32::MAX
|
||||
} else {
|
||||
0xFF00_0000 | shade << 16 | shade << 8 | shade
|
||||
};
|
||||
if let Some(t) = tint(face.quad.tint_index) {
|
||||
color = horizon_client::hud_layout::multiply(
|
||||
color,
|
||||
horizon_client::mobs::render::abgr(t),
|
||||
);
|
||||
}
|
||||
let normal = if diffuse {
|
||||
use horizon_client::item_render::{direction_vector, transform_normal};
|
||||
let n = transform_normal(m, direction_vector(face.quad.facing));
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::Game;
|
||||
use horizon_client::hud::HudBatch;
|
||||
#[cfg(feature = "mods")]
|
||||
use {
|
||||
horizon_client::{hud::rgba, items::ItemStack, text::Text},
|
||||
horizon_client::{hud::rgba, items::ItemStack, text::Text, ui},
|
||||
horizon_mods::Shape,
|
||||
std::time::Instant,
|
||||
};
|
||||
@@ -108,7 +108,17 @@ impl Game<'_> {
|
||||
let r = s.rect;
|
||||
let name = s.sprite.strip_prefix("minecraft:").unwrap_or(&s.sprite);
|
||||
let name = format!("gui/sprites/{name}");
|
||||
batch.sprite_scaled(atlas, &name, ox + r.x, oy + r.y, r.w, r.h, argb(s.color));
|
||||
// As vanilla's blitSprite: a frame or a button keeps its border.
|
||||
ui::blit_sprite(
|
||||
batch,
|
||||
atlas,
|
||||
&name,
|
||||
ox + r.x,
|
||||
oy + r.y,
|
||||
r.w,
|
||||
r.h,
|
||||
argb(s.color),
|
||||
);
|
||||
}
|
||||
Shape::Item(i) => {
|
||||
if self.res.items().get(&i.item).is_none() {
|
||||
|
||||
@@ -15,7 +15,15 @@ impl Game<'_> {
|
||||
) {
|
||||
let partial = alpha as f32;
|
||||
let mut states = self.world.moving_pistons.clone();
|
||||
states.extend(self.world.moving_piston_fallback.iter().copied());
|
||||
// A piston that just finished bridges the frames until the chunk mesh
|
||||
// shows the block it placed; drawn over that mesh, it z-fights (#3002).
|
||||
states.extend(
|
||||
self.world
|
||||
.moving_piston_fallback
|
||||
.iter()
|
||||
.filter(|(p, _)| self.section_mesh_pending(*p))
|
||||
.copied(),
|
||||
);
|
||||
for (p, piston) in states {
|
||||
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
|
||||
continue;
|
||||
@@ -38,6 +46,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the chunk mesh doesn't show the blocks at `p` yet (edited,
|
||||
/// meshing, or waiting for its upload).
|
||||
pub(super) fn section_mesh_pending(&self, p: [i32; 3]) -> bool {
|
||||
let Some(y) = p[1].checked_sub(horizon_client::chunk::MIN_Y) else {
|
||||
return false;
|
||||
};
|
||||
let section = (p[0] >> 4, y.div_euclid(16), p[2] >> 4);
|
||||
self.world.dirty_sections.contains(§ion)
|
||||
|| self.streaming.is_meshing(section)
|
||||
|| self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
}
|
||||
/// The moved block and, for a retracting piston, its extended base.
|
||||
fn moving_piston_blocks(
|
||||
&self,
|
||||
@@ -81,7 +100,8 @@ impl Game<'_> {
|
||||
Some((piston.moved_state, None))
|
||||
}
|
||||
/// One `MovingBlockRenderState`: the baked model at `at` (camera-relative),
|
||||
/// seeded by its block position and tinted like a falling block.
|
||||
/// seeded by its block position and tinted per face with the block colours
|
||||
/// at that position, as `ModelBlockRenderer` tints it in the chunk mesh.
|
||||
fn moving_block_vertices(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
@@ -90,23 +110,31 @@ impl Game<'_> {
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let tint = self
|
||||
.particle_sprite(state, [seed.x, seed.y, seed.z])
|
||||
.map(|(_, tint)| tint);
|
||||
let start = out.len();
|
||||
self.block_model_vertices(
|
||||
let chunks = &self.world.chunks;
|
||||
let (cx, cz) = (seed.x >> 4, seed.z >> 4);
|
||||
let region: [[Option<&horizon_client::chunk::Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
chunks
|
||||
.get(&(cx + dx as i32 - 1, cz + dz as i32 - 1))
|
||||
.map(|c| &**c)
|
||||
})
|
||||
});
|
||||
let tint = self.pack.context.tables.block_tints(
|
||||
state,
|
||||
[seed.x, seed.y, seed.z],
|
||||
®ion,
|
||||
self.res.biomes(),
|
||||
self.streaming.biome_zoom,
|
||||
self.streaming.biome_blend,
|
||||
);
|
||||
self.tinted_block_model_vertices(
|
||||
state,
|
||||
horizon_core::random::position_seed(seed),
|
||||
glam::Mat4::from_translation(at.as_vec3()),
|
||||
light,
|
||||
false,
|
||||
tint,
|
||||
out,
|
||||
);
|
||||
if let Some(tint) = tint {
|
||||
let tint = horizon_client::mobs::render::abgr(tint);
|
||||
for vertex in &mut out[start..] {
|
||||
vertex.color = horizon_client::hud_layout::multiply(vertex.color, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
//! 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::{Aabb, Axis, Vec3},
|
||||
position::BlockPos,
|
||||
@@ -29,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)]
|
||||
@@ -89,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>,
|
||||
/// The vehicles server players ride that went through a portal this
|
||||
/// tick, in their level, with their entry: they carry their riders
|
||||
@@ -686,7 +684,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()
|
||||
|
||||
@@ -200,6 +200,9 @@ impl Game<'_> {
|
||||
self.sync_agent_control();
|
||||
self.ui.options.agent_status = self.agent_status_lines();
|
||||
}
|
||||
Event::SliderDrag => {
|
||||
self.apply_settings();
|
||||
}
|
||||
Event::CopyAgentDetails => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
let text = self.agent_details();
|
||||
@@ -229,6 +232,15 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::DataPacks(ids) => {
|
||||
if let Some(title) = &mut self.title {
|
||||
title.set_data_packs(ids);
|
||||
}
|
||||
}
|
||||
Event::OpenDataPackFolder(dir) => {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenModFolder => {
|
||||
let dir = Self::options_path().with_file_name(horizon_client::mod_list::DIR);
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
|
||||
@@ -9,8 +9,13 @@ use horizon_client::{
|
||||
mobs::Who,
|
||||
save::{ChunkExtras, random_uuid},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::ChunkPos, random::RandomSource, ticks::ChunkTicks};
|
||||
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
|
||||
use horizon_core::{
|
||||
geometry::{Direction, Vec3},
|
||||
position::{BlockPos, ChunkPos},
|
||||
random::RandomSource,
|
||||
ticks::ChunkTicks,
|
||||
};
|
||||
use horizon_world::{chunk::BlockState, level::MovingPiston, nbt::Tag, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
@@ -66,6 +71,10 @@ fn painting_center(p: &horizon_client::entities::Painting) -> Vec3 {
|
||||
(lo[2] + hi[2]) / 2.0,
|
||||
)
|
||||
}
|
||||
fn block_entity_pos(tag: &Tag) -> [i32; 3] {
|
||||
[tag.get("x"), tag.get("y"), tag.get("z")]
|
||||
.map(|v| horizon_client::save::number(v).unwrap_or(0) as i32)
|
||||
}
|
||||
fn chunk_of(p: Vec3) -> (i32, i32) {
|
||||
((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
|
||||
}
|
||||
@@ -86,6 +95,19 @@ impl Game<'_> {
|
||||
extras.fluid_ticks = t.pack(time);
|
||||
}
|
||||
extras.last_update = time;
|
||||
// PistonMovingBlockEntity.saveAdditional: a block caught mid-move is
|
||||
// saved with its chunk (#3310).
|
||||
for (p, piston) in &self.block_ticks.moving_pistons {
|
||||
if (p.x >> 4, p.z >> 4) != (chunk.x, chunk.z) {
|
||||
continue;
|
||||
}
|
||||
extras
|
||||
.block_entities
|
||||
.retain(|tag| block_entity_pos(tag) != [p.x, p.y, p.z]);
|
||||
extras
|
||||
.block_entities
|
||||
.push(self.moving_piston_to_nbt(*p, piston));
|
||||
}
|
||||
// TheEndGatewayBlockEntity saves its Age (the server copy's).
|
||||
for tag in &mut extras.block_entities {
|
||||
let pos = [tag.get("x"), tag.get("y"), tag.get("z")]
|
||||
@@ -142,6 +164,82 @@ impl Game<'_> {
|
||||
s.fluid_ticks.clear();
|
||||
}
|
||||
}
|
||||
// PistonMovingBlockEntity.loadAdditional: blocks saved mid-move carry
|
||||
// on moving; later saves take them from the level (#3310).
|
||||
let pistons: Vec<_> = saved.as_deref().map_or_else(Vec::new, |s| {
|
||||
s.block_entities
|
||||
.iter()
|
||||
.filter_map(|tag| self.moving_piston_from_nbt(tag))
|
||||
.collect()
|
||||
});
|
||||
if pistons.is_empty() {
|
||||
return;
|
||||
}
|
||||
for &(pos, piston) in &pistons {
|
||||
self.block_ticks.load_moving_piston(pos, piston);
|
||||
}
|
||||
if let Some(c) = self.world.chunks.get_mut(&key) {
|
||||
let c = Arc::make_mut(c);
|
||||
if let Some(s) = c.saved.as_mut() {
|
||||
Arc::make_mut(s).block_entities.retain(|tag| {
|
||||
let at = block_entity_pos(tag);
|
||||
!pistons.iter().any(|(p, _)| [p.x, p.y, p.z] == at)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PistonMovingBlockEntity.saveAdditional`.
|
||||
fn moving_piston_to_nbt(&self, pos: BlockPos, piston: &MovingPiston) -> Tag {
|
||||
let flag = |b: bool| Tag::Byte(i8::from(b));
|
||||
Tag::compound([
|
||||
("id", Tag::String("minecraft:piston".into())),
|
||||
("x", Tag::Int(pos.x)),
|
||||
("y", Tag::Int(pos.y)),
|
||||
("z", Tag::Int(pos.z)),
|
||||
("blockState", self.state_to_nbt(piston.moved_state)),
|
||||
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
|
||||
("facing", Tag::Byte(piston.direction as i8)),
|
||||
("progress", Tag::Float(piston.progress_o.to_bits())),
|
||||
("extending", flag(piston.extending)),
|
||||
("source", flag(piston.source)),
|
||||
])
|
||||
}
|
||||
/// `PistonMovingBlockEntity.loadAdditional`, for a `minecraft:piston`
|
||||
/// entity whose block is still a moving piston.
|
||||
fn moving_piston_from_nbt(&self, tag: &Tag) -> Option<(BlockPos, MovingPiston)> {
|
||||
use horizon_client::save::{float, number, text};
|
||||
if text(tag.get("id")).as_deref() != Some("minecraft:piston") {
|
||||
return None;
|
||||
}
|
||||
let [x, y, z] = block_entity_pos(tag);
|
||||
let here = self.world.block(x, y, z);
|
||||
if &*self.res.facts().get(here).name != "minecraft:moving_piston" {
|
||||
return None;
|
||||
}
|
||||
let moved_state = tag
|
||||
.get("blockState")
|
||||
.and_then(|t| self.state_from_nbt(t))
|
||||
.or_else(|| self.pack.assets.registry.default_state("minecraft:air"))?;
|
||||
let direction = usize::try_from(number(tag.get("facing")).unwrap_or(0))
|
||||
.ok()
|
||||
.and_then(|i| Direction::ALL.get(i).copied())
|
||||
.unwrap_or(Direction::Down);
|
||||
let progress = float(tag.get("progress")).unwrap_or(0.0) as f32;
|
||||
let flag = |k: &str| number(tag.get(k)).unwrap_or(0) != 0;
|
||||
Some((
|
||||
BlockPos::new(x, y, z),
|
||||
MovingPiston {
|
||||
moved_state,
|
||||
direction,
|
||||
extending: flag("extending"),
|
||||
source: flag("source"),
|
||||
progress,
|
||||
progress_o: progress,
|
||||
last_ticked: 0,
|
||||
death_ticks: 0,
|
||||
id: 0,
|
||||
},
|
||||
))
|
||||
}
|
||||
/// Spawn the entities of chunks that finished loading.
|
||||
pub(super) fn spawn_loaded_entities(&mut self) {
|
||||
|
||||
@@ -548,6 +548,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.clear_waypoints();
|
||||
self.boss_overlay.reset();
|
||||
// A world is leaving either way: its icon attempt goes with it.
|
||||
self.world_icon = None;
|
||||
if let Some(mut r) = self.remote.take() {
|
||||
r.session.disconnect();
|
||||
}
|
||||
@@ -1325,10 +1327,21 @@ impl Game<'_> {
|
||||
}
|
||||
fn set_block(&mut self, pos: [i32; 3], state: i32) {
|
||||
let state = BlockStateId(state.max(0) as u32);
|
||||
let previous = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if self.world.loaded(pos[0], pos[2])
|
||||
&& self.world.set(pos[0], pos[1], pos[2], state).is_ok()
|
||||
{
|
||||
self.sync_block_entity((pos[0], pos[1], pos[2]), state);
|
||||
// LevelChunk.setBlockState removes the block entity of a block
|
||||
// that changes: the moving piston the client's moveBlocks made
|
||||
// stops when the server places what it moved (#3002).
|
||||
let facts = self.res.facts();
|
||||
let moving = |s: BlockStateId| &*facts.get(s).name == "minecraft:moving_piston";
|
||||
if moving(previous) && !moving(state) {
|
||||
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.block_ticks.moving_pistons.retain(|(p, _)| *p != at);
|
||||
self.sync_moving_pistons();
|
||||
}
|
||||
}
|
||||
// A block we just placed: play its place sound (the server skips the placer).
|
||||
if let Some((target, _)) = self.remote.as_ref().and_then(|r| r.placing)
|
||||
@@ -3699,6 +3712,7 @@ impl Game<'_> {
|
||||
self.tick_subtitles();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let pushed = self.remote_pushes();
|
||||
let sprint_window = self.settings.sprint_window;
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3797,7 +3811,7 @@ impl Game<'_> {
|
||||
collision_binding: self.collision,
|
||||
resources: self.motion,
|
||||
}
|
||||
.controlled_simulation_tick(keys)?;
|
||||
.controlled_simulation_tick_with_sprint_window(keys, sprint_window)?;
|
||||
} else {
|
||||
// LivingEntity.baseTick counts hurtTime down for a dead or riding
|
||||
// player too (controlled_simulation_tick does it otherwise): left
|
||||
@@ -4719,6 +4733,13 @@ impl Game<'_> {
|
||||
.and_then(|h| self.res.items().get(&h.item))
|
||||
.is_some_and(|i| i.block.is_some());
|
||||
if let Some(hit) = self.pick() {
|
||||
// ShelfBlock.useItemOn consumes a nonempty main-hand click client-side
|
||||
// without predicting the inventory swap or swinging.
|
||||
let shelf_reacts = hand == Hand::Main
|
||||
&& held.is_some()
|
||||
&& !(self.keys.contains(&KeyCode::ShiftLeft)
|
||||
&& (self.stack_in(self.selected).is_some() || self.offhand.is_some()))
|
||||
&& self.shelf_hit(&hit).is_some();
|
||||
let (reacts, changes) = if hand == Hand::Main {
|
||||
self.remote_block_reacts(&hit, held.as_ref())
|
||||
} else {
|
||||
@@ -4745,6 +4766,9 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.use_target = Some(hit.pos);
|
||||
}
|
||||
if shelf_reacts {
|
||||
return true;
|
||||
}
|
||||
if reacts {
|
||||
self.predict_use(&changes, seq);
|
||||
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
|
||||
@@ -5063,6 +5087,12 @@ impl Game<'_> {
|
||||
}
|
||||
// SpyglassItem.use (its sound is the server's).
|
||||
"minecraft:spyglass" => true,
|
||||
// InstrumentItem.use: the horn plays here too (`playSound(player, ...)`
|
||||
// skips the player's own client), the server's copy tells the others.
|
||||
"minecraft:goat_horn" => {
|
||||
self.remote_use_instrument(held);
|
||||
return;
|
||||
}
|
||||
// Item.use: a kinetic weapon starts being used.
|
||||
id if horizon_client::spear::Kinetic::of(id).is_some() => true,
|
||||
// ShieldItem.use: anything that blocks attacks, off cooldown.
|
||||
@@ -5075,6 +5105,27 @@ impl Game<'_> {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
}
|
||||
/// `InstrumentItem.use` on this client: unless the horn is on cooldown,
|
||||
/// it is held up, plays its sound at the player and starts its cooldown.
|
||||
fn remote_use_instrument(&mut self, held: &ItemStack) {
|
||||
let Some(instrument) = self.res.items().instrument(held) else {
|
||||
return;
|
||||
};
|
||||
let group = horizon_client::cooldowns::group(held, None);
|
||||
if self.cooldowns.is_on_cooldown(&group) {
|
||||
return;
|
||||
}
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
|
||||
if instrument.use_duration > 0.0 {
|
||||
self.add_cooldown(group, instrument.use_ticks());
|
||||
}
|
||||
}
|
||||
/// Whether `stack` stays in use until the button lets go (bows,
|
||||
/// crossbows, tridents, spyglasses, shields): its use only counts ticks here.
|
||||
fn held_until_released(&self, stack: &ItemStack) -> bool {
|
||||
@@ -5168,6 +5219,15 @@ impl Game<'_> {
|
||||
self.using = in_hand.then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
// A goat horn is held up for its instrument's use duration.
|
||||
if let Some(duration) = stack
|
||||
.as_ref()
|
||||
.and_then(|s| self.res.items().instrument(s))
|
||||
.map(|i| i.use_ticks().max(0) as u32)
|
||||
{
|
||||
self.using = (in_hand && ticks + 1 < duration).then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
let consumable = stack
|
||||
.as_ref()
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
|
||||
@@ -118,9 +118,28 @@ impl Game<'_> {
|
||||
.boss_overlay
|
||||
.darken_amount(self.clock.fraction() as f32),
|
||||
boss_world_fog: self.boss_overlay.should_create_world_fog(),
|
||||
spectator: self.spectator(),
|
||||
fire_resistance: self.potions.effects.get("fire_resistance").is_some(),
|
||||
night_vision: self.fog_night_vision(),
|
||||
},
|
||||
)
|
||||
}
|
||||
/// `FogRenderer.computeFogColor`'s night vision brightenFactor:
|
||||
/// `GameRenderer.nightVisionScale`, but not while Darkness is on.
|
||||
fn fog_night_vision(&self) -> f32 {
|
||||
let partial = self.clock.fraction() as f32;
|
||||
match self.potions.effects.get("night_vision") {
|
||||
Some(e) if self.potions.effects.get("darkness").is_none() => {
|
||||
// MobEffectInstance.endsWithin(200); -1 is infinite.
|
||||
if e.duration < 0 || e.duration > 200 {
|
||||
1.0
|
||||
} else {
|
||||
0.7 + ((e.duration as f32 - partial) * std::f32::consts::PI * 0.2).sin() * 0.3
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
/// `ClientLevelData.isFlat`: on the server thread, the world-wide flag.
|
||||
fn level_flat(&self) -> bool {
|
||||
match self.remote.as_ref() {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! The displayed items of `ShelfBlockEntity`.
|
||||
use super::{Game, Hit, machines::dir_from_name};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{block_entity_render::vertex_light, containers::BlockEntity, mesher::Vertex};
|
||||
use horizon_core::{
|
||||
geometry::{Direction, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::level::hit_slot;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Slot and facing for a click on a shelf's front face.
|
||||
pub(super) fn shelf_hit(&self, hit: &Hit) -> Option<(usize, Direction, bool)> {
|
||||
let p = hit.pos;
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&(p[0], p[1], p[2])),
|
||||
Some(BlockEntity::Shelf(_))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let data = self.pack.assets.registry.state(state)?;
|
||||
let facing = data
|
||||
.properties
|
||||
.get("facing")
|
||||
.and_then(|s| dir_from_name(s))?;
|
||||
let face = Direction::ALL
|
||||
.into_iter()
|
||||
.find(|d| d.normal() == hit.face)?;
|
||||
let slot = hit_slot(
|
||||
facing,
|
||||
face,
|
||||
BlockPos::new(p[0], p[1], p[2]),
|
||||
Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
|
||||
1,
|
||||
3,
|
||||
)?;
|
||||
Some((
|
||||
slot,
|
||||
facing,
|
||||
data.properties.get("powered").is_some_and(|s| s == "true"),
|
||||
))
|
||||
}
|
||||
|
||||
/// `SideChainPartBlock.getAllBlocksConnectedTo`: left to right, at most three.
|
||||
fn shelf_chain(&self, pos: [i32; 3], facing: Direction) -> Vec<[i32; 3]> {
|
||||
let left = match facing {
|
||||
Direction::North => Direction::East,
|
||||
Direction::East => Direction::South,
|
||||
Direction::South => Direction::West,
|
||||
Direction::West => Direction::North,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let behaviours = self.res.behaviours();
|
||||
if !behaviours
|
||||
.tag("minecraft:wooden_shelves")
|
||||
.is_some_and(|tag| tag.contains(behaviours.block(state)))
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let mut result = vec![pos];
|
||||
for (dir, end, prepend) in [(left, "left", true), (left.opposite(), "right", false)] {
|
||||
let normal = dir.normal();
|
||||
for step in 1..3 {
|
||||
let p = [pos[0] + normal[0] * step, pos[1], pos[2] + normal[2] * step];
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let Some(info) = self.pack.assets.registry.state(state) else {
|
||||
break;
|
||||
};
|
||||
let behaviours = self.res.behaviours();
|
||||
let connectable = behaviours
|
||||
.tag("minecraft:wooden_shelves")
|
||||
.is_some_and(|tag| tag.contains(behaviours.block(state)))
|
||||
&& info.properties.get("powered").is_some_and(|s| s == "true")
|
||||
&& info.properties.get("facing").and_then(|s| dir_from_name(s)) == Some(facing);
|
||||
if !connectable {
|
||||
break;
|
||||
}
|
||||
let part = info
|
||||
.properties
|
||||
.get("side_chain")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("unconnected");
|
||||
if part == "center" || part == end {
|
||||
if prepend {
|
||||
result.insert(0, p);
|
||||
} else {
|
||||
result.push(p);
|
||||
}
|
||||
}
|
||||
if part != "center" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// `ShelfBlock.useItemOn`: a single swap, or a connected hotbar swap while powered.
|
||||
pub(super) fn use_shelf(&mut self, hit: &Hit) -> Option<bool> {
|
||||
use horizon_client::first_person::Hand;
|
||||
if self.hand != Hand::Main {
|
||||
return None;
|
||||
}
|
||||
let (slot, facing, powered) = self.shelf_hit(hit)?;
|
||||
let pos = hit.pos;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if powered {
|
||||
let chain = self.shelf_chain(pos, facing);
|
||||
let mut swapped = false;
|
||||
let count = chain.len();
|
||||
for (i, p) in chain.into_iter().enumerate() {
|
||||
let key = (p[0], p[1], p[2]);
|
||||
let Some(BlockEntity::Shelf(shelf)) = self.world.block_entities.get_mut(&key)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for n in 0..3 {
|
||||
let index = 9_i32 - (count as i32 - i as i32) * 3 + n as i32;
|
||||
if !(0..9).contains(&index) {
|
||||
continue;
|
||||
}
|
||||
let index = index as usize;
|
||||
swapped |= self.inventory[index].is_some() || shelf.items[n].is_some();
|
||||
std::mem::swap(&mut self.inventory[index], &mut shelf.items[n]);
|
||||
}
|
||||
self.container_changed(key);
|
||||
let s = self.world.block(p[0], p[1], p[2]);
|
||||
self.post_block_game_event("minecraft:entity_interact", p, None, Some(s));
|
||||
}
|
||||
if !swapped {
|
||||
return Some(false);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:block.shelf.multi_swap",
|
||||
Some(pos.map(|n| f64::from(n) + 0.5)),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
Some(true)
|
||||
} else {
|
||||
let held = self.inventory[self.selected].clone();
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Shelf(shelf)) = self.world.block_entities.get_mut(&key) else {
|
||||
return None;
|
||||
};
|
||||
let removed = std::mem::replace(&mut shelf.items[slot], held.clone());
|
||||
self.inventory[self.selected] = if self.creative && removed.is_none() {
|
||||
held.clone()
|
||||
} else {
|
||||
removed.clone()
|
||||
};
|
||||
self.container_changed(key);
|
||||
let vibrations = self.inventory[self.selected].as_ref().is_none_or(|stack| {
|
||||
let default = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_none_or(|item| item.interact_vibrations);
|
||||
let Some(data) = stack.data() else {
|
||||
return default;
|
||||
};
|
||||
if data
|
||||
.other
|
||||
.iter()
|
||||
.any(|(key, _)| key == "!minecraft:use_effects")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
data.other
|
||||
.iter()
|
||||
.find(|(key, _)| key == "minecraft:use_effects")
|
||||
.map_or(default, |(_, tag)| {
|
||||
horizon_client::save::number(tag.get("interact_vibrations")).unwrap_or(1)
|
||||
!= 0
|
||||
})
|
||||
});
|
||||
if vibrations {
|
||||
self.post_block_game_event(
|
||||
"minecraft:item_interact_finish",
|
||||
pos,
|
||||
None,
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
let sound = match (held.is_some(), removed.is_some()) {
|
||||
(false, false) => return None,
|
||||
(true, false) => "minecraft:block.shelf.place_item",
|
||||
(false, true) => "minecraft:block.shelf.take_item",
|
||||
(true, true) => "minecraft:block.shelf.single_swap",
|
||||
};
|
||||
self.audio
|
||||
.play(sound, Some(pos.map(|n| f64::from(n) + 0.5)), 1.0, 1.0);
|
||||
Some(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// `ShelfRenderer`: three items face out, aligned to the bottom or centre.
|
||||
pub(super) fn shelf_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
for (key, entity) in &self.world.block_entities {
|
||||
let BlockEntity::Shelf(shelf) = entity else {
|
||||
continue;
|
||||
};
|
||||
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
if at.distance_squared(camera) >= 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
let block = self.world.block(key.0, key.1, key.2);
|
||||
let facing = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(block)
|
||||
.and_then(|s| s.properties.get("facing"))
|
||||
.map(String::as_str)
|
||||
.unwrap_or("north");
|
||||
let yaw: f32 = match facing {
|
||||
"south" => 0.0,
|
||||
"west" => -90.0,
|
||||
"north" => -180.0,
|
||||
"east" => -270.0,
|
||||
_ => 180.0,
|
||||
};
|
||||
let packed = BlockPos::new(key.0, key.1, key.2).pack() as u64;
|
||||
let seed = (packed ^ (packed >> 32)) as i32;
|
||||
let light = vertex_light(self.world.light(key.0, key.1, key.2));
|
||||
for (slot, stack) in shelf.items.iter().enumerate() {
|
||||
let Some(stack) = stack else { continue };
|
||||
let item = self.resolve_item(
|
||||
stack,
|
||||
DisplayContext::OnShelf,
|
||||
None,
|
||||
seed.wrapping_add(slot as i32),
|
||||
);
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = item.bounding_box();
|
||||
let offset_y = -lo.y
|
||||
- if shelf.align_to_bottom {
|
||||
0.0
|
||||
} else {
|
||||
(hi.y - lo.y) / 2.0
|
||||
};
|
||||
let pose =
|
||||
glam::Mat4::from_translation((at - camera).as_vec3() + glam::Vec3::splat(0.5))
|
||||
* glam::Mat4::from_rotation_y(yaw.to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(
|
||||
(slot as f32 - 1.0) * 0.3125,
|
||||
if shelf.align_to_bottom { -0.25 } else { 0.0 },
|
||||
-0.25,
|
||||
))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(0.25))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, offset_y, 0.0));
|
||||
self.submit_item_world(&item, pose, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,7 @@ pub(super) fn spawn_loader(
|
||||
&settings,
|
||||
started,
|
||||
true,
|
||||
true,
|
||||
&progress,
|
||||
&mut |gui, root| {
|
||||
let _ = sender.send(LoaderMsg::Ui(gui, root.to_path_buf()));
|
||||
@@ -340,6 +341,9 @@ impl Loading {
|
||||
self.save_menu_settings();
|
||||
self.apply_menu_settings();
|
||||
}
|
||||
Event::SliderDrag => {
|
||||
self.apply_menu_settings();
|
||||
}
|
||||
Event::Close if self.menu.options.resource_error.is_some() => {}
|
||||
Event::Close => {
|
||||
self.menu.options_open = false;
|
||||
@@ -362,6 +366,15 @@ impl Loading {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::DataPacks(ids) => {
|
||||
if let Some(title) = &mut self.menu.title {
|
||||
title.set_data_packs(ids);
|
||||
}
|
||||
}
|
||||
Event::OpenDataPackFolder(dir) => {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenModFolder => {
|
||||
let dir = Game::options_path().with_file_name(horizon_client::mod_list::DIR);
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
@@ -423,6 +436,11 @@ impl Loading {
|
||||
Action::Options => self.open_menu_options(Page::Main),
|
||||
Action::Accessibility => self.open_menu_options(Page::Accessibility),
|
||||
Action::Language => self.open_menu_options(Page::Language),
|
||||
Action::DataPacks { dir, selected } => {
|
||||
let resources = self.vanilla_root.clone();
|
||||
self.menu.options.open_data_packs(dir, selected, resources);
|
||||
self.menu.options_open = true;
|
||||
}
|
||||
action => {
|
||||
let name = self
|
||||
.options
|
||||
@@ -611,7 +629,13 @@ impl Loading {
|
||||
}
|
||||
|
||||
pub(super) fn draw_menu(&mut self, batch: &mut HudBatch) {
|
||||
let Some(gui) = &self.gui else { return };
|
||||
let logs = horizon_client::logging::recent_lines();
|
||||
let Some(gui) = &self.gui else {
|
||||
// The pack's screens do not exist yet: the boot screen holds the
|
||||
// window, the wordmark and the console's lines while they load.
|
||||
horizon_client::ui_theme::draw_boot(&self.boot, batch, &logs);
|
||||
return;
|
||||
};
|
||||
if self.menu.pending.is_some() {
|
||||
horizon_client::level_loading::draw_progress(
|
||||
batch,
|
||||
@@ -698,6 +722,7 @@ mod tests {
|
||||
Loading {
|
||||
window: None,
|
||||
renderer: None,
|
||||
boot: horizon_client::hud::HudAtlas::boot(),
|
||||
gui: None,
|
||||
settings: Settings::default(),
|
||||
fullscreen: horizon_client::window::FullscreenState::new(false),
|
||||
|
||||
@@ -1997,6 +1997,16 @@ impl Game<'_> {
|
||||
{
|
||||
push(&mut lines, text, rgba(85, 85, 255, 255));
|
||||
}
|
||||
// SuspiciousStewEffects.addToTooltip: the creative flag, which vanilla
|
||||
// sets for the creative screen's own slots, as `name_visible` above.
|
||||
for (text, c) in horizon_client::item_tooltip::stew_lines(
|
||||
stack,
|
||||
!nested && self.screen == Screen::Creative,
|
||||
&display,
|
||||
&translate,
|
||||
) {
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
// BlockItemStateProperties.addToTooltip.
|
||||
if let Some(text) = horizon_client::item_tooltip::honey_line(stack, &display, &translate) {
|
||||
push(&mut lines, text, gray);
|
||||
|
||||
@@ -394,8 +394,15 @@ impl Game<'_> {
|
||||
256.0,
|
||||
256.0,
|
||||
);
|
||||
let title = self.translate("container.shulkerBox", "Shulker Box");
|
||||
batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
|
||||
// ShulkerBoxScreen draws the menu's title: the box's custom name
|
||||
// (BaseContainerBlockEntity.getDisplayName) or its default name.
|
||||
let title = self.remote_menu_title().unwrap_or_else(|| {
|
||||
Text::literal(
|
||||
self.container_title(self.translate("container.shulkerBox", "Shulker Box")),
|
||||
)
|
||||
.resolve_legacy()
|
||||
});
|
||||
batch.text_styled(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 73.0, grey, false);
|
||||
return;
|
||||
|
||||
@@ -157,6 +157,18 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
|
||||
("mob_factor", Tag::Float(v[5].to_bits())),
|
||||
]),
|
||||
Component::Profile(p) => profile_tag(p),
|
||||
// SuspiciousStewEffects' NBT: each effect by name and its duration.
|
||||
Component::StewEffects(list) => Tag::List(
|
||||
list.iter()
|
||||
.filter_map(|(id, duration)| {
|
||||
let name = builtin.get("minecraft:mob_effect")?.name(*id)?;
|
||||
Some(Tag::compound([
|
||||
("id", Tag::String(name.into())),
|
||||
("duration", Tag::Int(*duration)),
|
||||
]))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
// PotionContents' NBT: the potion by name and the custom colour.
|
||||
Component::Potion { potion, color, .. } => {
|
||||
let potion = potion.and_then(|id| builtin.get("minecraft:potion")?.name(id));
|
||||
@@ -393,6 +405,7 @@ mod tests {
|
||||
};
|
||||
let dynamic = Dynamic {
|
||||
registries: Default::default(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut bundle = ItemStack::new("minecraft:bundle", 1);
|
||||
bundle.data_mut().bundle_contents = vec![ItemStack::new("minecraft:stone", 32)];
|
||||
@@ -412,7 +425,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// What a creative client sends (`stack_to_network`) comes back whole on
|
||||
/// the server: damage, both enchantment lists, repair cost, name, trim.
|
||||
/// the server: damage, both enchantment lists, repair cost, name, trim,
|
||||
/// potion and stew effects.
|
||||
#[test]
|
||||
fn a_creative_stack_keeps_its_components_across_the_wire() {
|
||||
let mut components = vec![String::new(); 60];
|
||||
@@ -428,6 +442,10 @@ mod tests {
|
||||
"minecraft:stored_enchantments",
|
||||
),
|
||||
(component::TRIM, "minecraft:trim"),
|
||||
(
|
||||
component::SUSPICIOUS_STEW_EFFECTS,
|
||||
"minecraft:suspicious_stew_effects",
|
||||
),
|
||||
] {
|
||||
components[id as usize] = name.to_owned();
|
||||
}
|
||||
@@ -440,6 +458,7 @@ mod tests {
|
||||
"minecraft:air",
|
||||
"minecraft:diamond_chestplate",
|
||||
"minecraft:splash_potion",
|
||||
"minecraft:suspicious_stew",
|
||||
])),
|
||||
),
|
||||
(
|
||||
@@ -450,6 +469,10 @@ mod tests {
|
||||
"minecraft:potion".to_owned(),
|
||||
Ids::from_names(names(&["minecraft:water", "minecraft:swiftness"])),
|
||||
),
|
||||
(
|
||||
"minecraft:mob_effect".to_owned(),
|
||||
Ids::from_names(names(&["minecraft:speed", "minecraft:fire_resistance"])),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
@@ -470,6 +493,7 @@ mod tests {
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut stack = ItemStack::new("minecraft:diamond_chestplate", 1);
|
||||
stack.damage = 5;
|
||||
@@ -530,5 +554,22 @@ mod tests {
|
||||
from_network(&wire, &builtin, &dynamic).potion(),
|
||||
Some("minecraft:swiftness")
|
||||
);
|
||||
// SuspiciousStewEffects, as a creative tab's stew has them: without
|
||||
// them the server's stew gives no effect when eaten (#3137).
|
||||
let mut stew = ItemStack::new("minecraft:suspicious_stew", 1);
|
||||
stew.data_mut()
|
||||
.stew_effects
|
||||
.push((Arc::from("minecraft:fire_resistance"), 60));
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| id("minecraft:item", name).unwrap_or(0),
|
||||
id,
|
||||
&Some(stew),
|
||||
)
|
||||
.unwrap();
|
||||
let back = from_network(&wire, &builtin, &dynamic);
|
||||
assert_eq!(
|
||||
back.data().map(|d| d.stew_effects.clone()),
|
||||
Some(vec![(Arc::from("minecraft:fire_resistance"), 60)])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -434,14 +434,26 @@ pub fn potion_modifier_lines(
|
||||
if !is_potion_item(&stack.item) {
|
||||
return Vec::new();
|
||||
}
|
||||
let applied: Vec<(String, u32)> = stack
|
||||
let effects = stack
|
||||
.potion()
|
||||
.and_then(crate::brewing::potion)
|
||||
.into_iter()
|
||||
.flat_map(|p| p.effects.iter())
|
||||
.filter_map(|(effect, _, amplifier)| {
|
||||
let (attribute, amount, operation) = effect_modifier(effect)?;
|
||||
let amount = amount * (*amplifier + 1) as f64;
|
||||
.map(|(effect, _, amplifier)| (*effect, *amplifier));
|
||||
effect_modifier_lines(effects, translate)
|
||||
}
|
||||
|
||||
/// `addPotionTooltip`'s attribute block for (effect id, amplifier) entries.
|
||||
fn effect_modifier_lines<'a>(
|
||||
effects: impl IntoIterator<Item = (&'a str, i32)>,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, u32)> {
|
||||
let applied: Vec<(String, u32)> = effects
|
||||
.into_iter()
|
||||
.filter_map(|(effect, amplifier)| {
|
||||
let (attribute, amount, operation) =
|
||||
effect_modifier(effect.trim_start_matches("minecraft:"))?;
|
||||
let amount = amount * (amplifier + 1) as f64;
|
||||
let line = modifier_line(translate, attribute, amount, operation, false)?;
|
||||
let color = if amount > 0.0 {
|
||||
rgba(85, 85, 255, 255)
|
||||
@@ -465,6 +477,48 @@ pub fn potion_modifier_lines(
|
||||
lines
|
||||
}
|
||||
|
||||
/// `SuspiciousStewEffects.addToTooltip`: only with the creative tooltip flag,
|
||||
/// the stew's effects as `PotionContents.addPotionTooltip` lists them (each in
|
||||
/// its `MobEffectCategory` colour, "No Effects" for none), then their
|
||||
/// attribute modifiers.
|
||||
pub fn stew_lines(
|
||||
stack: &ItemStack,
|
||||
creative: bool,
|
||||
display: &TooltipDisplay,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, u32)> {
|
||||
let effects = stack.data().map_or(&[][..], |d| &d.stew_effects[..]);
|
||||
// The component is the item's default (empty) on a suspicious stew.
|
||||
if !creative
|
||||
|| (effects.is_empty() && &*stack.item != "minecraft:suspicious_stew")
|
||||
|| !display.shows("minecraft:suspicious_stew_effects")
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
if effects.is_empty() {
|
||||
let none = translate("effect.none").unwrap_or_else(|| "No Effects".into());
|
||||
return vec![(none, rgba(170, 170, 170, 255))];
|
||||
}
|
||||
// `Entry.createEffectInstance`: amplifier 0.
|
||||
let entries = effects.iter().map(|(id, duration)| (&**id, *duration, 0));
|
||||
let mut lines: Vec<(String, u32)> = crate::brewing::effect_lines(entries, 1.0, translate)
|
||||
.into_iter()
|
||||
.map(|(line, category)| {
|
||||
// MobEffectCategory.getTooltipFormatting: harmful red, else blue.
|
||||
let color = match category {
|
||||
crate::brewing::Category::Harmful => rgba(255, 85, 85, 255),
|
||||
_ => rgba(85, 85, 255, 255),
|
||||
};
|
||||
(line, color)
|
||||
})
|
||||
.collect();
|
||||
lines.extend(effect_modifier_lines(
|
||||
effects.iter().map(|(id, _)| (&**id, 0)),
|
||||
translate,
|
||||
));
|
||||
lines
|
||||
}
|
||||
|
||||
/// `ItemStack.components.size()`: the item's default components with the
|
||||
/// stack's own changes applied.
|
||||
pub fn component_count(stack: &ItemStack, info: Option<&ItemInfo>) -> usize {
|
||||
@@ -533,6 +587,17 @@ pub fn detail_lines(
|
||||
let display = TooltipDisplay::for_stack(stack);
|
||||
lines.extend(bees_line(stack, &display, translate));
|
||||
lines.extend(honey_line(stack, &display, translate));
|
||||
// InstrumentComponent.addToTooltip: the instrument's description.
|
||||
if display.shows("minecraft:instrument") {
|
||||
if let Some(id) = crate::items::instrument_id(stack) {
|
||||
let (namespace, name) = id.split_once(':').unwrap_or(("minecraft", &id));
|
||||
lines.push(text(&format!("instrument.{namespace}.{name}")));
|
||||
} else if let Some(description) =
|
||||
crate::items::inline_instrument(stack).and_then(|tag| tag.get("description"))
|
||||
{
|
||||
lines.push(crate::entity_nbt::name_text(description).plain());
|
||||
}
|
||||
}
|
||||
let data = stack.data();
|
||||
// PotDecorations.addToTooltip.
|
||||
if let Some(sherds) = data
|
||||
@@ -754,6 +819,7 @@ pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn lore_keeps_component_colours_under_dark_purple_italic() {
|
||||
@@ -820,6 +886,59 @@ mod tests {
|
||||
assert!(!display.shows("minecraft:attribute_modifiers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stew_effects_show_only_with_the_creative_flag() {
|
||||
let translate = |key: &str| {
|
||||
Some(
|
||||
match key {
|
||||
"effect.minecraft.fire_resistance" => "Fire Resistance",
|
||||
"effect.minecraft.weakness" => "Weakness",
|
||||
"effect.minecraft.saturation" => "Saturation",
|
||||
"effect.none" => "No Effects",
|
||||
"potion.whenDrank" => "When Applied:",
|
||||
"attribute.modifier.take.0" => "-%s %s",
|
||||
"attribute.name.attack_damage" => "Attack Damage",
|
||||
_ => return None,
|
||||
}
|
||||
.to_owned(),
|
||||
)
|
||||
};
|
||||
let (blue, red) = (rgba(85, 85, 255, 255), rgba(255, 85, 85, 255));
|
||||
let lines = |stack: &ItemStack, creative: bool| {
|
||||
stew_lines(
|
||||
stack,
|
||||
creative,
|
||||
&TooltipDisplay::for_stack(stack),
|
||||
&translate,
|
||||
)
|
||||
};
|
||||
let mut stew = ItemStack::new("minecraft:suspicious_stew", 1);
|
||||
// The item's default, an empty list.
|
||||
assert_eq!(
|
||||
lines(&stew, true),
|
||||
[("No Effects".to_owned(), rgba(170, 170, 170, 255))]
|
||||
);
|
||||
stew.data_mut().stew_effects = vec![
|
||||
(Arc::from("minecraft:fire_resistance"), 60),
|
||||
(Arc::from("minecraft:weakness"), 140),
|
||||
(Arc::from("minecraft:saturation"), 7),
|
||||
];
|
||||
assert!(lines(&stew, false).is_empty());
|
||||
assert_eq!(
|
||||
lines(&stew, true),
|
||||
[
|
||||
("Fire Resistance (0:03)".to_owned(), blue),
|
||||
("Weakness (0:07)".to_owned(), red),
|
||||
// 20 ticks or less: no duration.
|
||||
("Saturation".to_owned(), blue),
|
||||
(String::new(), u32::MAX),
|
||||
("When Applied:".to_owned(), rgba(170, 0, 170, 255)),
|
||||
("-4 Attack Damage".to_owned(), red),
|
||||
]
|
||||
);
|
||||
assert!(lines(&ItemStack::new("minecraft:bowl", 1), true).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ominous_banner_keeps_its_default_hidden_pattern_tooltip() {
|
||||
let mut banner = crate::mobs::raider::ominous_banner();
|
||||
|
||||
@@ -940,6 +940,23 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
match entity {
|
||||
BlockEntity::Sign(sign) => Some(Tag::compound(sign.encode())),
|
||||
BlockEntity::Spawner(s) => Some(s.update_tag()),
|
||||
BlockEntity::Brushable(b) => {
|
||||
let mut entries = Vec::new();
|
||||
if let Some(face) = b.hit_direction {
|
||||
entries.push(("hit_direction", Tag::Byte(face as i8)));
|
||||
}
|
||||
if let Some(item) = &b.item {
|
||||
entries.push(("item", crate::save::item_to_nbt(item)));
|
||||
}
|
||||
Some(Tag::compound(entries))
|
||||
}
|
||||
BlockEntity::Shelf(s) => Some(Tag::compound([
|
||||
("Items", crate::save::encode_items(&s.items)),
|
||||
(
|
||||
"align_items_to_bottom",
|
||||
Tag::Byte(i8::from(s.align_to_bottom)),
|
||||
),
|
||||
])),
|
||||
// BannerBlockEntity.getUpdateTag: saveWithoutMetadata, so the patterns,
|
||||
// the custom name and the components.
|
||||
BlockEntity::Banner(b) => {
|
||||
|
||||
@@ -32,9 +32,12 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// ServerCommonPacketListenerImpl.KEEPALIVE_TIME and the disconnect timeout.
|
||||
/// ServerCommonPacketListenerImpl.keepConnectionAlive: a keep-alive every 15 s,
|
||||
/// and a client that has not answered the last one by the next is dropped.
|
||||
const KEEP_ALIVE_EVERY: Duration = Duration::from_secs(15);
|
||||
const KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// ReadTimeoutHandler(30) in ServerConnectionListener: a connection that sends
|
||||
/// nothing for 30 s.
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Packets queued for one client beyond this many bytes disconnect it.
|
||||
const MAX_PENDING: usize = 96 << 20;
|
||||
/// Vanilla's server-side disconnect during login is a JSON component.
|
||||
@@ -677,9 +680,16 @@ fn serve_login(
|
||||
_ => return Err("expected a login hello".into()),
|
||||
};
|
||||
if !valid_name(&hello.name) {
|
||||
// handleHello's validState throws; Connection.exceptionCaught sends the
|
||||
// exception as the login disconnect.
|
||||
login_disconnect(
|
||||
&mut c,
|
||||
&translatable("multiplayer.disconnect.invalid_name", &[]),
|
||||
&translatable(
|
||||
"disconnect.genericReason",
|
||||
&[
|
||||
"Internal Exception: java.lang.IllegalStateException: Invalid characters in username",
|
||||
],
|
||||
),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -950,7 +960,7 @@ fn offer_pack(
|
||||
fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::SocketAddr) {
|
||||
let conn_id = shared.next_id();
|
||||
let events = shared.events.clone();
|
||||
let _ = c.reader.set_read_timeout(Some(KEEP_ALIVE_TIMEOUT));
|
||||
let _ = c.reader.set_read_timeout(Some(READ_TIMEOUT));
|
||||
let (mut reader, writer) = c.split();
|
||||
let (tx, rx) = channel();
|
||||
let pending = Arc::new(AtomicUsize::new(0));
|
||||
@@ -1165,7 +1175,7 @@ fn write_loop(
|
||||
let old = latency.load(Ordering::Relaxed);
|
||||
latency.store((old * 3 + ms) / 4, Ordering::Relaxed);
|
||||
sent_at = None;
|
||||
} else if at.elapsed() > KEEP_ALIVE_TIMEOUT {
|
||||
} else if at.elapsed() >= KEEP_ALIVE_EVERY {
|
||||
let _ = writer.send(
|
||||
&play::Clientbound::Disconnect(cfg::Disconnect {
|
||||
reason: translatable("disconnect.timeout", &[]),
|
||||
|
||||
@@ -699,6 +699,21 @@ fn network_stack(
|
||||
},
|
||||
));
|
||||
}
|
||||
// SuspiciousStewEffects: each effect by its `minecraft:mob_effect` id.
|
||||
if !d.stew_effects.is_empty() {
|
||||
let effects = d
|
||||
.stew_effects
|
||||
.iter()
|
||||
.filter_map(|(effect, duration)| {
|
||||
let name = horizon_world::datafix::dynamic::namespaced(effect);
|
||||
Some((synced_id("minecraft:mob_effect", &name)?, *duration))
|
||||
})
|
||||
.collect();
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::SUSPICIOUS_STEW_EFFECTS,
|
||||
horizon_protocol::item::Component::StewEffects(effects),
|
||||
));
|
||||
}
|
||||
// A loaded crossbow's ammunition.
|
||||
if !d.charged.is_empty() {
|
||||
let charged = d
|
||||
|
||||
@@ -8,7 +8,7 @@ pub mod arrows;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, auth, bidi, biome, block_entities, block_entity_anim,
|
||||
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, clouds,
|
||||
collisions, command_block, containers, crash, dynamic_light, effects, environment, extras,
|
||||
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment, extras,
|
||||
fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hud, hud_layout,
|
||||
hud_overlays, inventory_effects, java_text, level_loading, light, lod, logging, menu_blur,
|
||||
mesher, minimap, mipmap, mod_list, model_library, models, options_file, options_fixes,
|
||||
@@ -37,7 +37,6 @@ pub mod crafting;
|
||||
pub mod creative;
|
||||
pub mod cushion;
|
||||
pub mod data_cmd;
|
||||
pub mod datapack;
|
||||
pub mod debug_chart;
|
||||
pub mod debug_keys;
|
||||
pub mod debug_overlay;
|
||||
@@ -45,6 +44,7 @@ pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
pub mod entity_culling;
|
||||
pub mod entity_nbt;
|
||||
|
||||
@@ -244,6 +244,8 @@ pub enum ArmPose {
|
||||
CrossbowCharge,
|
||||
/// Raising a trident to throw it (or to launch with Riptide).
|
||||
ThrowTrident,
|
||||
/// Playing a goat horn.
|
||||
TootHorn,
|
||||
/// A spear held out (`HumanoidModel.ArmPose.SPEAR`), at rest or charging.
|
||||
Spear,
|
||||
}
|
||||
@@ -394,6 +396,10 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
|
||||
p.xrot(arm, x * 0.5 - PI);
|
||||
p.yrot(arm, 0.0);
|
||||
}
|
||||
ArmPose::TootHorn => {
|
||||
p.xrot(arm, hx.clamp(-1.2, 1.2) - 1.483_529_8);
|
||||
p.yrot(arm, hy + if right { -FRAC_PI_6 } else { FRAC_PI_6 });
|
||||
}
|
||||
ArmPose::CrossbowHold => crossbow_hold(p, hx, hy, right),
|
||||
ArmPose::CrossbowCharge => crossbow_charge(p, h.charge.0, h.charge.1, right),
|
||||
// SpearAnimations.thirdPersonHandUse.
|
||||
@@ -748,6 +754,7 @@ pub fn setup(
|
||||
Some(_) if using_main && m.extra.blocking => ArmPose::Block,
|
||||
Some("minecraft:bow") if using_main => ArmPose::Bow,
|
||||
Some("minecraft:trident") if using_main => ArmPose::ThrowTrident,
|
||||
Some("minecraft:goat_horn") if using_main => ArmPose::TootHorn,
|
||||
Some("minecraft:crossbow") if using_main && !m.extra.charged => {
|
||||
ArmPose::CrossbowCharge
|
||||
}
|
||||
@@ -766,6 +773,11 @@ pub fn setup(
|
||||
ArmPose::Spear
|
||||
} else if m.extra.using_item > 0 && m.extra.using_off_hand && m.extra.blocking {
|
||||
ArmPose::Block
|
||||
} else if m.extra.using_item > 0
|
||||
&& m.extra.using_off_hand
|
||||
&& m.extra.off_hand.as_deref() == Some("minecraft:goat_horn")
|
||||
{
|
||||
ArmPose::TootHorn
|
||||
} else {
|
||||
ArmPose::Item
|
||||
}
|
||||
|
||||
@@ -705,6 +705,14 @@ impl PlayerMotion {
|
||||
/// Vehicle, item-use and effect bindings remain separate; hunger, flight,
|
||||
/// water and collision conditions use this actor's actual state.
|
||||
pub fn sprint_input_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
|
||||
self.sprint_input_tick_with_window(keys, 7)
|
||||
}
|
||||
/// The same phase with Java's `Options.sprintWindow` value.
|
||||
pub fn sprint_input_tick_with_window(
|
||||
&mut self,
|
||||
keys: crate::sprint::Keys,
|
||||
sprint_window: i32,
|
||||
) -> Result<()> {
|
||||
let conditions = crate::sprint::Conditions {
|
||||
sprinting: self.sprinting,
|
||||
enough_food: self.health.food.has_enough_food(),
|
||||
@@ -725,7 +733,9 @@ impl PlayerMotion {
|
||||
// The sprint setter edits attributes and flags, independent of local
|
||||
// keyboard history. Retain the input prefix even if that setter fails.
|
||||
let mut state = self.sprint_input;
|
||||
let result = state.tick(keys, conditions, 7, |value| self.set_sprinting(value));
|
||||
let result = state.tick(keys, conditions, sprint_window.clamp(0, 10), |value| {
|
||||
self.set_sprinting(value)
|
||||
});
|
||||
self.sprint_input = state;
|
||||
result
|
||||
}
|
||||
@@ -992,6 +1002,14 @@ impl MotionWorld<'_, '_, '_> {
|
||||
.food_tick(&self.resources.health, self.scene.health_rules)
|
||||
}
|
||||
pub fn controlled_simulation_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
|
||||
self.controlled_simulation_tick_with_sprint_window(keys, 7)
|
||||
}
|
||||
/// Local simulation with the configured double-tap sprint window.
|
||||
pub fn controlled_simulation_tick_with_sprint_window(
|
||||
&mut self,
|
||||
keys: crate::sprint::Keys,
|
||||
sprint_window: i32,
|
||||
) -> Result<()> {
|
||||
// The base tick (fluids, swimming) precedes LocalPlayer.aiStep.
|
||||
self.player.health.advance_clock();
|
||||
self.fluid_base_tick()?;
|
||||
@@ -1004,7 +1022,8 @@ impl MotionWorld<'_, '_, '_> {
|
||||
self.player.auto_jump_time -= 1;
|
||||
keys.jump = true;
|
||||
}
|
||||
self.player.sprint_input_tick(keys)?;
|
||||
self.player
|
||||
.sprint_input_tick_with_window(keys, sprint_window)?;
|
||||
self.player.shift_key_down = keys.shift;
|
||||
let direction = keys.direction();
|
||||
self.player.move_vector = direction;
|
||||
|
||||
@@ -625,6 +625,7 @@ fn configure(
|
||||
}))?
|
||||
}
|
||||
cfg::Clientbound::RegistryData(r) => registries.add(r),
|
||||
cfg::Clientbound::UpdateTags(t) => registries.add_tags(&t),
|
||||
cfg::Clientbound::CodeOfConduct(_) => {
|
||||
reply(cfg::Serverbound::AcceptCodeOfConduct(Empty))?
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
//! (`Entity.setAsInsidePortal` / `handlePortal`) for nether portals.
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
/// `Entity.getDimensionChangingDelay`: the portal cooldown of an entity that
|
||||
/// went through a portal.
|
||||
pub const ENTITY_DELAY: i32 = 300;
|
||||
|
||||
/// `PortalProcessor` for a nether portal.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Process {
|
||||
|
||||
@@ -42,10 +42,11 @@ use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, OnceLock,
|
||||
Arc, Condvar, Mutex, OnceLock, RwLock,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
pub const VERSION_NAME: &str = horizon_core::VANILLA_VERSION;
|
||||
@@ -54,10 +55,10 @@ pub const VERSION_NAME: &str = horizon_core::VANILLA_VERSION;
|
||||
/// mutating the world. Each map is visited once, retaining its iteration order
|
||||
/// within each chunk (including repeated chunk coordinates in the input).
|
||||
/// An empty batch skips both maps; one chunk uses the direct filter path.
|
||||
pub fn chunks_with_block_entities<'a>(
|
||||
pub fn chunks_with_block_entities<'a, N: std::hash::BuildHasher>(
|
||||
chunks: impl IntoIterator<Item = &'a Arc<Chunk>>,
|
||||
entities: &HashMap<(i32, i32, i32), crate::containers::BlockEntity>,
|
||||
names: &HashMap<(i32, i32, i32), String>,
|
||||
names: &HashMap<(i32, i32, i32), String, N>,
|
||||
) -> Vec<Chunk> {
|
||||
let chunks: Vec<_> = chunks.into_iter().collect();
|
||||
if chunks.is_empty() {
|
||||
@@ -470,6 +471,13 @@ pub fn encode_block_entity(
|
||||
}
|
||||
}
|
||||
BlockEntity::Sculk(s) => entries.extend(s.to_nbt()),
|
||||
BlockEntity::Shelf(shelf) => {
|
||||
entries.push(("Items", encode_items(&shelf.items)));
|
||||
entries.push((
|
||||
"align_items_to_bottom",
|
||||
Tag::Byte(i8::from(shelf.align_to_bottom)),
|
||||
));
|
||||
}
|
||||
// ChiseledBookShelfBlockEntity.saveAdditional.
|
||||
BlockEntity::ChiseledBookshelf(b) => {
|
||||
entries.push(("Items", encode_items(&b.items)));
|
||||
@@ -534,6 +542,16 @@ pub fn encode_block_entity(
|
||||
BlockEntity::Vault(v) => entries.extend(v.save()),
|
||||
BlockEntity::Beehive(h) => entries.extend(h.to_nbt()),
|
||||
BlockEntity::CommandBlock(c) => entries.extend(c.save()),
|
||||
BlockEntity::Brushable(b) => {
|
||||
if let Some(table) = &b.loot_table {
|
||||
entries.push(("LootTable", table.clone()));
|
||||
if let Some(seed) = &b.loot_seed {
|
||||
entries.push(("LootTableSeed", seed.clone()));
|
||||
}
|
||||
} else if let Some(item) = &b.item {
|
||||
entries.push(("item", item_to_nbt(item)));
|
||||
}
|
||||
}
|
||||
BlockEntity::DaylightDetector => {}
|
||||
}
|
||||
Tag::compound(entries)
|
||||
@@ -711,8 +729,24 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
_ => 1.0,
|
||||
};
|
||||
}
|
||||
BlockEntity::Brushable(b) => {
|
||||
b.loot_table = tag.get("LootTable").cloned();
|
||||
b.loot_seed = tag.get("LootTableSeed").cloned();
|
||||
b.item = if b.loot_table.is_none() {
|
||||
tag.get("item").and_then(item_from_nbt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
b.hit_direction = int("hit_direction").filter(|v| (0..=5).contains(v));
|
||||
}
|
||||
BlockEntity::Sign(sign) => sign.decode(tag),
|
||||
BlockEntity::Sculk(s) => s.load(tag),
|
||||
BlockEntity::Shelf(shelf) => {
|
||||
for (slot, item) in decode_items(tag.get("Items"), 3).into_iter().enumerate() {
|
||||
shelf.items[slot] = item;
|
||||
}
|
||||
shelf.align_to_bottom = int("align_items_to_bottom").unwrap_or(0) != 0;
|
||||
}
|
||||
// ChiseledBookShelfBlockEntity.loadAdditional.
|
||||
BlockEntity::ChiseledBookshelf(b) => {
|
||||
for (slot, item) in decode_items(tag.get("Items"), 6).into_iter().enumerate() {
|
||||
@@ -1613,7 +1647,43 @@ pub fn load_light(tag: &Tag, chunk: &mut Chunk, sky_light: bool) {
|
||||
chunk.light_loaded = true;
|
||||
}
|
||||
|
||||
type RegionCache = Mutex<HashMap<(i32, i32), Option<Arc<Mutex<RegionFile>>>>>;
|
||||
struct CachedRegion {
|
||||
/// `None` records a missing file, so a read does not retry the open.
|
||||
region: Option<Arc<RwLock<RegionFile>>>,
|
||||
/// When the cache last handed it out; the least recently used goes first.
|
||||
used: Instant,
|
||||
}
|
||||
type RegionCache = Mutex<HashMap<(i32, i32), CachedRegion>>;
|
||||
/// Enough descriptors for a long exploration without approaching the low
|
||||
/// soft limits of some systems; a region reopened later re-reads its header.
|
||||
const REGION_CACHE_CAP: usize = 64;
|
||||
/// A region nobody used for this long is closed even below the cap.
|
||||
const REGION_IDLE: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
/// Close the least recently used unheld regions past `cap`, and any idle one.
|
||||
/// An `Arc` a reader or the writer holds keeps its region open: dropping the
|
||||
/// last one closes the descriptor.
|
||||
fn trim_region_cache(regions: &mut HashMap<(i32, i32), CachedRegion>, cap: usize) {
|
||||
let now = Instant::now();
|
||||
let mut order: Vec<_> = regions
|
||||
.iter()
|
||||
.map(|(&key, cached)| (cached.used, key))
|
||||
.collect();
|
||||
order.sort_unstable();
|
||||
for (used, key) in order {
|
||||
if regions.len() <= cap && now.duration_since(used) < REGION_IDLE {
|
||||
break;
|
||||
}
|
||||
let unheld = regions.get(&key).is_some_and(|cached| {
|
||||
cached
|
||||
.region
|
||||
.as_ref()
|
||||
.is_none_or(|region| Arc::strong_count(region) == 1)
|
||||
});
|
||||
if unheld {
|
||||
regions.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The region file at `path` through the cache, which readers and the writer
|
||||
/// share, so a read never sees a header older than the sectors it points to.
|
||||
/// `create` makes a missing file (for a write); otherwise a missing file is
|
||||
@@ -1623,13 +1693,13 @@ fn cached_region(
|
||||
key: (i32, i32),
|
||||
path: &Path,
|
||||
create: bool,
|
||||
) -> Result<Option<Arc<Mutex<RegionFile>>>> {
|
||||
) -> Result<Option<Arc<RwLock<RegionFile>>>> {
|
||||
let mut regions = cache.lock().map_err(|_| "region cache poisoned")?;
|
||||
if let Some(Some(region)) = regions.get(&key) {
|
||||
return Ok(Some(region.clone()));
|
||||
}
|
||||
if !create && regions.contains_key(&key) {
|
||||
return Ok(None);
|
||||
if let Some(cached) = regions.get_mut(&key) {
|
||||
cached.used = Instant::now();
|
||||
if cached.region.is_some() || !create {
|
||||
return Ok(cached.region.clone());
|
||||
}
|
||||
}
|
||||
if create && let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
@@ -1642,11 +1712,18 @@ fn cached_region(
|
||||
other => other,
|
||||
};
|
||||
let region = match region {
|
||||
Ok(r) => Some(Arc::new(Mutex::new(r))),
|
||||
Ok(r) => Some(Arc::new(RwLock::new(r))),
|
||||
Err(_) if !create => None,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
regions.insert(key, region.clone());
|
||||
regions.insert(
|
||||
key,
|
||||
CachedRegion {
|
||||
region: region.clone(),
|
||||
used: Instant::now(),
|
||||
},
|
||||
);
|
||||
trim_region_cache(&mut regions, REGION_CACHE_CAP);
|
||||
Ok(region)
|
||||
}
|
||||
/// Write `records` into the region files of `folder` in place, as vanilla's
|
||||
@@ -1680,18 +1757,9 @@ fn write_region_records(
|
||||
let Some(region) = cached_region(cache, (rx, rz), &path, create)? else {
|
||||
continue;
|
||||
};
|
||||
let mut region = region.lock().map_err(|_| "region file poisoned")?;
|
||||
let mut changed = false;
|
||||
for (pos, record) in &list {
|
||||
match record {
|
||||
Some(record) => {
|
||||
region.write(*pos, record)?;
|
||||
changed = true;
|
||||
}
|
||||
None => changed |= region.clear(*pos, timestamp)?,
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
let mut region = region.write().map_err(|_| "region file poisoned")?;
|
||||
let batch: Vec<_> = list.iter().map(|(p, r)| (*p, r.as_ref())).collect();
|
||||
if region.write_batch(&batch, timestamp)? {
|
||||
region.sync()?;
|
||||
}
|
||||
}
|
||||
@@ -1826,8 +1894,9 @@ fn read_region_record(
|
||||
let Some(region) = cached_region(cache, key, &path, false)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// A read lock: several loaders can read one region at the same time.
|
||||
let record = region
|
||||
.lock()
|
||||
.read()
|
||||
.map_err(|_| "region file poisoned")?
|
||||
.read(ChunkPos::new(x, z))?;
|
||||
Ok(match record {
|
||||
@@ -1892,10 +1961,33 @@ pub struct SaveDir {
|
||||
pub tables: OnceLock<Arc<SaveTables>>,
|
||||
/// The dimension's skylight and height, from its folder name.
|
||||
pub shape: LevelShape,
|
||||
/// Which chunks each region file holds, for far terrain (`lod_saved`).
|
||||
lod_regions: Mutex<crate::fast_hash::FxHashMap<(i32, i32), LodRegion>>,
|
||||
/// Saved chunks decoded for far terrain, shared by its sampling threads
|
||||
/// (`lod_surfaces_shared`), oldest first.
|
||||
lod_decoded: Mutex<LodDecoded>,
|
||||
}
|
||||
fn is_forced_ticket(ticket: &Tag) -> bool {
|
||||
matches!(ticket.get("type"), Some(Tag::String(t)) if *t == JavaString::from("minecraft:forced"))
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct LodDecoded {
|
||||
chunks: crate::fast_hash::FxHashMap<(i32, i32), (std::time::Instant, LodSurfaces)>,
|
||||
order: std::collections::VecDeque<(i32, i32)>,
|
||||
}
|
||||
/// Saved chunks kept decoded for far terrain (each tens of kilobytes), and
|
||||
/// for how long: edits saved later show after it.
|
||||
const LOD_DECODED: usize = 1024;
|
||||
const LOD_DECODED_TTL: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
/// A region file's saved chunks (bit `x + z * 32`), `None` without the file,
|
||||
/// as of `checked`.
|
||||
struct LodRegion {
|
||||
checked: std::time::Instant,
|
||||
chunks: Option<[u64; 16]>,
|
||||
}
|
||||
/// How long far terrain trusts a region file's chunk list: chunks saved after
|
||||
/// it was read show in the far view this long after.
|
||||
const LOD_REGION_TTL: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
impl SaveDir {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
let shape = LevelShape::for_dir(&root);
|
||||
@@ -1910,6 +2002,8 @@ impl SaveDir {
|
||||
outstanding: Default::default(),
|
||||
tables: OnceLock::new(),
|
||||
shape,
|
||||
lod_regions: Default::default(),
|
||||
lod_decoded: Default::default(),
|
||||
}
|
||||
}
|
||||
/// 26.3's TicketStorage: persistent tickets live in each dimension's
|
||||
@@ -1956,6 +2050,40 @@ impl SaveDir {
|
||||
put(&mut data, "tickets", Tag::List(tickets));
|
||||
write_saved_data(&self.root, "chunk_tickets", data)
|
||||
}
|
||||
/// Whether chunk `x z` is in its region file, from a chunk list kept
|
||||
/// `LOD_REGION_TTL`. Far terrain asks for every column of the view: it
|
||||
/// opened the region file each time, also where there is none, which
|
||||
/// held the far view back for minutes.
|
||||
fn lod_saved(&self, x: i32, z: i32) -> bool {
|
||||
let key = (x >> 5, z >> 5);
|
||||
let mut regions = self.lod_regions.lock().unwrap();
|
||||
let fresh = regions
|
||||
.get(&key)
|
||||
.is_some_and(|region| region.checked.elapsed() < LOD_REGION_TTL);
|
||||
if !fresh {
|
||||
let chunks = RegionFile::open(self.region_path("region", key.0, key.1))
|
||||
.ok()
|
||||
.map(|region| {
|
||||
let mut bits = [0u64; 16];
|
||||
for (pos, _) in region.chunks() {
|
||||
let index = ((pos.x & 31) + (pos.z & 31) * 32) as usize;
|
||||
bits[index / 64] |= 1 << (index % 64);
|
||||
}
|
||||
bits
|
||||
});
|
||||
regions.insert(
|
||||
key,
|
||||
LodRegion {
|
||||
checked: std::time::Instant::now(),
|
||||
chunks,
|
||||
},
|
||||
);
|
||||
}
|
||||
let index = ((x & 31) + (z & 31) * 32) as usize;
|
||||
regions[&key]
|
||||
.chunks
|
||||
.is_some_and(|bits| bits[index / 64] & (1 << (index % 64)) != 0)
|
||||
}
|
||||
fn region_path(&self, folder: &str, rx: i32, rz: i32) -> PathBuf {
|
||||
region_path(&self.root, folder, rx, rz)
|
||||
}
|
||||
@@ -3362,11 +3490,18 @@ pub struct Persistent<G> {
|
||||
pub biomes: Arc<Biomes>,
|
||||
}
|
||||
type LodSurfaces = Arc<[crate::lod::Column; 256]>;
|
||||
/// The coarsest far tile spacing whose columns read saved chunks: 16 blocks
|
||||
/// a column, and 4 with Reduced far detail (`LodSampler::set_approximate`).
|
||||
const SAVED_LOD_STRIDE: i32 = 16;
|
||||
const SAVED_LOD_STRIDE_REDUCED: i32 = 4;
|
||||
struct SavedLodSampler<G: ChunkGenerator> {
|
||||
generator: Arc<Persistent<G>>,
|
||||
inner: Box<dyn crate::streaming::LodSampler>,
|
||||
cache: std::collections::VecDeque<((i32, i32), Option<LodSurfaces>)>,
|
||||
details: Vec<crate::streaming::LodDetail>,
|
||||
/// Reduced far detail: saved chunks only in tiles up to
|
||||
/// `SAVED_LOD_STRIDE_REDUCED` blocks a column.
|
||||
approximate: bool,
|
||||
}
|
||||
impl<G: ChunkGenerator> SavedLodSampler<G> {
|
||||
fn saved_columns(&mut self, key: (i32, i32)) -> Option<LodSurfaces> {
|
||||
@@ -3374,7 +3509,7 @@ impl<G: ChunkGenerator> SavedLodSampler<G> {
|
||||
Some(index) => self.cache.remove(index).unwrap(),
|
||||
None => (
|
||||
key,
|
||||
self.generator.save.lod_surfaces(
|
||||
self.generator.save.lod_surfaces_shared(
|
||||
key.0,
|
||||
key.1,
|
||||
&self.generator.registry,
|
||||
@@ -3395,8 +3530,13 @@ impl<G: ChunkGenerator> crate::streaming::LodSampler for SavedLodSampler<G> {
|
||||
inner: self.inner.fork()?,
|
||||
cache: Default::default(),
|
||||
details: Vec::new(),
|
||||
approximate: self.approximate,
|
||||
}))
|
||||
}
|
||||
fn set_approximate(&mut self, approximate: bool) {
|
||||
self.approximate = approximate;
|
||||
self.inner.set_approximate(approximate);
|
||||
}
|
||||
fn request_detail(&mut self, chunks: &[(i32, i32)], center: [i32; 2]) {
|
||||
let mut missing = Vec::new();
|
||||
for &key in chunks {
|
||||
@@ -3419,7 +3559,18 @@ impl<G: ChunkGenerator> crate::streaming::LodSampler for SavedLodSampler<G> {
|
||||
}
|
||||
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
|
||||
let mut columns = self.inner.grid(x, z, stride, side);
|
||||
if stride <= 16 {
|
||||
// Saved chunks show in tiles up to 16 blocks a column. With Reduced far
|
||||
// detail only up to 4 (within about a kilometre): coarser tiles keep
|
||||
// the generator's terrain, which saved chunks were made from, since
|
||||
// decoding up to 256 saved chunks for each of them held the whole far
|
||||
// view back for minutes in large saves. Edits that far away show once
|
||||
// their tiles refine on approach.
|
||||
let limit = if self.approximate {
|
||||
SAVED_LOD_STRIDE_REDUCED
|
||||
} else {
|
||||
SAVED_LOD_STRIDE
|
||||
};
|
||||
if stride <= limit {
|
||||
for (index, column) in columns.iter_mut().enumerate() {
|
||||
let (wx, wz) = (
|
||||
x + (index % side) as i32 * stride,
|
||||
@@ -3583,7 +3734,16 @@ fn decode_lod_surfaces(
|
||||
descriptor.name.as_str(),
|
||||
"minecraft:water" | "minecraft:lava"
|
||||
);
|
||||
(state, fluid, properties.blocks_motion_in_heightmap())
|
||||
// Chorus is thin and non-solid but still far-End detail.
|
||||
let chorus = matches!(
|
||||
descriptor.name.as_str(),
|
||||
"minecraft:chorus_plant" | "minecraft:chorus_flower"
|
||||
);
|
||||
(
|
||||
state,
|
||||
fluid,
|
||||
fluid || properties.blocks_motion_in_heightmap() || chorus,
|
||||
)
|
||||
}))
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
@@ -3607,10 +3767,10 @@ fn decode_lod_surfaces(
|
||||
for y in (0..16).rev() {
|
||||
let index = y * 256 + cell;
|
||||
let palette_index = lod_palette_index(blocks, index, 4096, 4)?;
|
||||
let Some((state, fluid, solid)) = palette[palette_index] else {
|
||||
let Some((state, fluid, kept)) = palette[palette_index] else {
|
||||
continue;
|
||||
};
|
||||
if !solid && !fluid {
|
||||
if !kept {
|
||||
continue;
|
||||
}
|
||||
let biome = section
|
||||
@@ -3669,6 +3829,33 @@ fn decode_lod_surfaces(
|
||||
}
|
||||
|
||||
impl SaveDir {
|
||||
/// `lod_surfaces`, decoded once for all far sampling threads: each saved
|
||||
/// chunk lies under far tiles of several levels, sampled on any thread.
|
||||
fn lod_surfaces_shared(
|
||||
&self,
|
||||
x: i32,
|
||||
z: i32,
|
||||
assets: &crate::assets::Assets,
|
||||
biomes: &Biomes,
|
||||
) -> Option<LodSurfaces> {
|
||||
if let Some((at, columns)) = self.lod_decoded.lock().unwrap().chunks.get(&(x, z))
|
||||
&& at.elapsed() < LOD_DECODED_TTL
|
||||
{
|
||||
return Some(columns.clone());
|
||||
}
|
||||
let columns = self.lod_surfaces(x, z, assets, biomes)?;
|
||||
let mut decoded = self.lod_decoded.lock().unwrap();
|
||||
let fresh = (std::time::Instant::now(), columns.clone());
|
||||
if decoded.chunks.insert((x, z), fresh).is_none() {
|
||||
decoded.order.push_back((x, z));
|
||||
while decoded.order.len() > LOD_DECODED {
|
||||
if let Some(old) = decoded.order.pop_front() {
|
||||
decoded.chunks.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(columns)
|
||||
}
|
||||
fn lod_surfaces(
|
||||
&self,
|
||||
x: i32,
|
||||
@@ -3685,8 +3872,11 @@ impl SaveDir {
|
||||
let root = if let Some(record) = pending {
|
||||
record.tag().map(|tag| (*tag).clone())?
|
||||
} else {
|
||||
if !self.lod_saved(x, z) {
|
||||
return None;
|
||||
}
|
||||
// Far-only reads must not populate the gameplay region cache across the whole view.
|
||||
let mut region = RegionFile::open(self.region_path("region", x >> 5, z >> 5)).ok()?;
|
||||
let region = RegionFile::open(self.region_path("region", x >> 5, z >> 5)).ok()?;
|
||||
region
|
||||
.read(ChunkPos::new(x, z))
|
||||
.ok()??
|
||||
@@ -3715,6 +3905,7 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
|
||||
inner,
|
||||
cache: Default::default(),
|
||||
details: Vec::new(),
|
||||
approximate: false,
|
||||
})
|
||||
}
|
||||
fn load(&self, x: i32, z: i32) -> Option<Chunk> {
|
||||
@@ -4082,6 +4273,18 @@ mod tests {
|
||||
.registry
|
||||
.default_state("minecraft:oak_leaves")
|
||||
.unwrap();
|
||||
let end_stone = assets
|
||||
.registry
|
||||
.default_state("minecraft:end_stone")
|
||||
.unwrap();
|
||||
let chorus = assets
|
||||
.registry
|
||||
.default_state("minecraft:chorus_plant")
|
||||
.unwrap();
|
||||
let flower = assets
|
||||
.registry
|
||||
.default_state("minecraft:chorus_flower")
|
||||
.unwrap();
|
||||
let mut chunk = Chunk::empty(-2, 3);
|
||||
chunk.set(0, 109, 0, stone.0);
|
||||
chunk.set(3, 121, 5, stone.0);
|
||||
@@ -4108,6 +4311,10 @@ mod tests {
|
||||
for y in 55..64 {
|
||||
chunk.set(4, y, 4, water.0);
|
||||
}
|
||||
// Chorus is thin and non-solid but still part of the far End.
|
||||
chunk.set(12, 100, 2, end_stone.0);
|
||||
chunk.set(12, 101, 2, chorus.0);
|
||||
chunk.set(12, 102, 2, flower.0);
|
||||
let mut saved = encode(&chunk, assets.registry, &biomes);
|
||||
put(
|
||||
&mut saved.tag,
|
||||
@@ -4165,6 +4372,25 @@ mod tests {
|
||||
let underwater = surfaces[4 * 16 + 4].surface.unwrap();
|
||||
assert_eq!((underwater.height, underwater.state), (55, sand));
|
||||
assert_eq!(underwater.water, Some((64, water)));
|
||||
let chorus_column = &surfaces[2 * 16 + 12];
|
||||
assert_eq!(
|
||||
chorus_column
|
||||
.surface
|
||||
.map(|surface| (surface.height, surface.state)),
|
||||
Some((103, flower))
|
||||
);
|
||||
assert_eq!(
|
||||
chorus_column
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| (span.min, span.max, span.state))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
(100, 101, end_stone),
|
||||
(101, 102, chorus),
|
||||
(102, 103, flower)
|
||||
]
|
||||
);
|
||||
assert!(surfaces[15].surface.is_none(), "saved void stays empty");
|
||||
assert!(
|
||||
reopened.regions.lock().unwrap().is_empty(),
|
||||
@@ -4219,6 +4445,7 @@ mod tests {
|
||||
inner: Box::new(SavedOnly(Vec::new())),
|
||||
cache: Default::default(),
|
||||
details: Vec::new(),
|
||||
approximate: false,
|
||||
};
|
||||
use crate::streaming::LodSampler;
|
||||
sampler.request_detail(&[(-2, 3)], [-32, 48]);
|
||||
@@ -4374,6 +4601,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_cache_closes_idle_regions_and_reopens_their_header() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-region-cache-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("region/r.0.0.mca");
|
||||
let cache: super::RegionCache = Default::default();
|
||||
let record = ChunkRecord {
|
||||
compression: Compression::None,
|
||||
compressed: vec![1, 2, 3],
|
||||
timestamp: 9,
|
||||
};
|
||||
let region = super::cached_region(&cache, (0, 0), &path, true)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
region
|
||||
.write()
|
||||
.unwrap()
|
||||
.write(ChunkPos::new(0, 0), &record)
|
||||
.unwrap();
|
||||
// A held Arc keeps its region open, even at a zero cap.
|
||||
super::trim_region_cache(&mut cache.lock().unwrap(), 0);
|
||||
assert_eq!(cache.lock().unwrap().len(), 1);
|
||||
drop(region);
|
||||
super::trim_region_cache(&mut cache.lock().unwrap(), 0);
|
||||
assert!(cache.lock().unwrap().is_empty());
|
||||
// The next access reopens the file and re-reads the header.
|
||||
let region = super::cached_region(&cache, (0, 0), &path, false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let got = region
|
||||
.read()
|
||||
.unwrap()
|
||||
.read(ChunkPos::new(0, 0))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(got.compressed, record.compressed);
|
||||
drop(region);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_worlds_are_upgraded_on_opening() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-dv-{}", std::process::id()));
|
||||
@@ -4408,7 +4675,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
// Hold the actual region lock, rather than relying on disk speed or sleeps.
|
||||
let blocked = region.lock().unwrap();
|
||||
let blocked = region.write().unwrap();
|
||||
let entity = |version| Tag::compound([("version", Tag::Int(version))]);
|
||||
save.save_entities_later(vec![((1, 2), vec![entity(1)])])
|
||||
.unwrap();
|
||||
|
||||
@@ -135,6 +135,11 @@ pub enum Action {
|
||||
Connect {
|
||||
address: String,
|
||||
},
|
||||
/// Create World's Data Packs screen on the new world's pack folder.
|
||||
DataPacks {
|
||||
dir: PathBuf,
|
||||
selected: Vec<String>,
|
||||
},
|
||||
}
|
||||
pub struct Title {
|
||||
pub mode: Mode,
|
||||
@@ -337,6 +342,12 @@ impl Title {
|
||||
row
|
||||
}
|
||||
fn cancel_create(&mut self) {
|
||||
// removeTempDataPackDir.
|
||||
if let Mode::Create { options, .. } = &self.mode
|
||||
&& let Some(dir) = &options.pack_dir
|
||||
{
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
if self.create_return_to_main {
|
||||
self.mode = Mode::Main;
|
||||
} else {
|
||||
@@ -401,6 +412,13 @@ impl Title {
|
||||
};
|
||||
}
|
||||
}
|
||||
/// The Data Packs screen's Done (`applyNewPackConfig`): its selection for
|
||||
/// the new world.
|
||||
pub fn set_data_packs(&mut self, ids: Vec<String>) {
|
||||
if let Mode::Create { options, .. } = &mut self.mode {
|
||||
options.data_packs = ids;
|
||||
}
|
||||
}
|
||||
fn matches_search(&self, world: &WorldEntry) -> bool {
|
||||
let filter = self.search.to_lowercase();
|
||||
world.name.to_lowercase().contains(&filter) || world.folder.to_lowercase().contains(&filter)
|
||||
@@ -1545,11 +1563,26 @@ impl Title {
|
||||
creative, options, ..
|
||||
},
|
||||
Some(id),
|
||||
) if id != 21 && id != 22 => {
|
||||
) if !matches!(id, 21 | 22 | 31) => {
|
||||
options.press(id, creative);
|
||||
Action::None
|
||||
}
|
||||
(Mode::Create { .. }, Some(21)) => self.create_action(),
|
||||
(Mode::Create { options, .. }, Some(31)) => {
|
||||
if options.pack_dir.is_none() {
|
||||
match crate::datapack::temp_folder() {
|
||||
Ok(dir) => options.pack_dir = Some(dir),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: no folder for data packs: {e}");
|
||||
return Action::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::DataPacks {
|
||||
dir: options.pack_dir.clone().unwrap_or_default(),
|
||||
selected: options.data_packs.clone(),
|
||||
}
|
||||
}
|
||||
(Mode::Create { .. }, Some(22)) => {
|
||||
self.cancel_create();
|
||||
Action::None
|
||||
|
||||
@@ -38,6 +38,11 @@ pub struct WorldOptions {
|
||||
pub bonus_chest: bool,
|
||||
/// `getGameRules`: the Game Rules screen's values, saved with the world.
|
||||
pub rules: crate::gamerules::GameRules,
|
||||
/// The Data Packs screen's selection, lowest priority first.
|
||||
pub data_packs: Vec<String>,
|
||||
/// The folder of packs for the new world (vanilla's `mcworld-`
|
||||
/// temporary folder), made when Data Packs first opens.
|
||||
pub pack_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
impl Default for WorldOptions {
|
||||
fn default() -> Self {
|
||||
@@ -51,6 +56,8 @@ impl Default for WorldOptions {
|
||||
structures: true,
|
||||
bonus_chest: false,
|
||||
rules: crate::gamerules::GameRules::default(),
|
||||
data_packs: vec!["vanilla".into()],
|
||||
pack_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,7 +293,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
|
||||
28,
|
||||
),
|
||||
],
|
||||
// Experiments and data packs have no screens yet.
|
||||
// Experiments have no screen yet.
|
||||
_ => [
|
||||
"selectWorld.gameRules",
|
||||
"selectWorld.experiments",
|
||||
@@ -300,7 +307,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
|
||||
gy + 28.0 * i as f32,
|
||||
210.0,
|
||||
tr(key),
|
||||
i == 0,
|
||||
i != 1,
|
||||
29 + i as u8,
|
||||
)
|
||||
})
|
||||
@@ -358,7 +365,7 @@ impl Title {
|
||||
let (x0, tw) = tabs(w);
|
||||
let right = x0 + tw * TABS.len() as f32;
|
||||
for (x, width) in [(0.0, x0), (right, w - right)] {
|
||||
ui::tiled(
|
||||
ui::tiled_sized(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/header_separator",
|
||||
@@ -366,7 +373,7 @@ impl Title {
|
||||
TAB_BAR - 2.0,
|
||||
width,
|
||||
2.0,
|
||||
32.0,
|
||||
[32.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
@@ -441,7 +448,7 @@ impl Title {
|
||||
_ => {}
|
||||
}
|
||||
// The footer separator, drawn over the screen.
|
||||
ui::tiled(
|
||||
ui::tiled_sized(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/footer_separator",
|
||||
@@ -449,7 +456,7 @@ impl Title {
|
||||
h - FOOTER - 2.0,
|
||||
w,
|
||||
2.0,
|
||||
32.0,
|
||||
[32.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -821,12 +821,29 @@ fn create_world_tabs_follow_vanilla_and_their_choices_reach_the_action() {
|
||||
bonus_chest: true
|
||||
}
|
||||
);
|
||||
// The More tab's Experiments and Data Packs screens don't exist yet.
|
||||
// The More tab: Experiments has no screen yet; Data
|
||||
// Packs opens on a new pack folder, and its selection reaches the action.
|
||||
f.title.click(54.0 + 248.0 + 10.0, 10.0, size);
|
||||
let more = labels(&f);
|
||||
assert_eq!(
|
||||
labels(&f)[..3].iter().map(|b| b.3).collect::<Vec<_>>(),
|
||||
[true, false, false]
|
||||
more[..3].iter().map(|b| b.3).collect::<Vec<_>>(),
|
||||
[true, false, true]
|
||||
);
|
||||
let (action, sound) = f.title.click(more[2].1 + 10.0, more[2].2 + 10.0, size);
|
||||
let Action::DataPacks { dir, selected } = action else {
|
||||
panic!("{action:?}")
|
||||
};
|
||||
assert!(sound && dir.is_dir() && selected == ["vanilla"]);
|
||||
f.title
|
||||
.set_data_packs(vec!["vanilla".into(), "file/ttm.zip".into()]);
|
||||
let Action::Create { options, .. } = f.title.create_action() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(options.data_packs, ["vanilla", "file/ttm.zip"]);
|
||||
assert_eq!(options.pack_dir.as_ref(), Some(&dir));
|
||||
// Cancel removes the folder (removeTempDataPackDir).
|
||||
f.title.escape();
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -71,6 +71,9 @@ pub(crate) mod color {
|
||||
/// The launcher's type scale and shapes (`horizon_theme`), in dp.
|
||||
pub(crate) const SMALL: f32 = horizon_theme::CAPTION.px;
|
||||
pub(crate) const BODY: f32 = horizon_theme::LABEL.px;
|
||||
/// The boot screen's wordmark (`DISPLAY`) and console lines (`MICRO`).
|
||||
pub(crate) const DISPLAY: f32 = horizon_theme::DISPLAY.px;
|
||||
pub(crate) const MICRO: f32 = horizon_theme::MICRO.px;
|
||||
/// Fields; plates (hover rows, selection); pills (buttons, segments).
|
||||
pub(crate) const RADIUS: f32 = horizon_theme::RADIUS;
|
||||
pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE;
|
||||
@@ -79,9 +82,13 @@ pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE;
|
||||
pub(crate) enum Face {
|
||||
Regular,
|
||||
Medium,
|
||||
/// The wordmark and page titles (the design system's 500).
|
||||
SemiBold,
|
||||
/// Counts, timestamps and the boot screen's console lines.
|
||||
Mono,
|
||||
}
|
||||
struct Fonts {
|
||||
faces: [FontRef<'static>; 2],
|
||||
faces: [FontRef<'static>; 4],
|
||||
}
|
||||
impl Fonts {
|
||||
fn load() -> Self {
|
||||
@@ -90,6 +97,8 @@ impl Fonts {
|
||||
faces: [
|
||||
f(horizon_theme::fonts::GEIST_REGULAR),
|
||||
f(horizon_theme::fonts::GEIST_MEDIUM),
|
||||
f(horizon_theme::fonts::GEIST_SEMIBOLD),
|
||||
f(horizon_theme::fonts::GEIST_MONO),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -524,6 +533,115 @@ impl<'a> Ui<'a> {
|
||||
// ---- the launcher's components ----
|
||||
}
|
||||
|
||||
// ---- the boot screen ----
|
||||
|
||||
/// The boot screen's palette on the pure black: a white wordmark and dimmed
|
||||
/// console lines (the theme's ink is for light pages).
|
||||
const BOOT_TEXT: u32 = color::hex(0xFFFFFF, 255);
|
||||
const BOOT_LOG: u32 = color::hex(0x9BA1A6, 255);
|
||||
|
||||
/// The boot screen while the menu is not ready yet: the wordmark centred on
|
||||
/// pure black and the console's last lines at the bottom right, as the
|
||||
/// terminal prints them.
|
||||
pub fn draw_boot(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
|
||||
// Pure black under everything: the frame's panorama (the title artwork
|
||||
// once the bundle is in) must not leak through.
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
batch.width,
|
||||
batch.height,
|
||||
color::hex(0x000000, 255),
|
||||
);
|
||||
wordmark(atlas, batch);
|
||||
draw_boot_log(atlas, batch, logs);
|
||||
}
|
||||
|
||||
/// The wordmark with its tagline, centred.
|
||||
fn wordmark(atlas: &HudAtlas, batch: &mut HudBatch) {
|
||||
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
|
||||
let size = (ui.w / 16.0).clamp(DISPLAY, 96.0);
|
||||
let small = (size * 0.25).clamp(MICRO, 18.0);
|
||||
let block = size * 1.45 + small * 0.6 + small * 1.5;
|
||||
let top = (ui.h - block) / 2.0;
|
||||
ui.text_centered(Face::SemiBold, size, ui.w / 2.0, top, BOOT_TEXT, "Horizon");
|
||||
ui.text_centered(
|
||||
Face::Mono,
|
||||
small,
|
||||
ui.w / 2.0,
|
||||
top + size * 1.45 + small * 0.6,
|
||||
BOOT_LOG,
|
||||
"Rust based minecraft client",
|
||||
);
|
||||
ui.finish();
|
||||
}
|
||||
|
||||
/// The console's last lines at the bottom left, over any loading screen: long
|
||||
/// lines wrap word-wise, the block grows upwards.
|
||||
pub fn draw_boot_log(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
|
||||
if logs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
|
||||
let (size, line, pad) = (MICRO, MICRO * 1.5, 16.0);
|
||||
let max_w = ui.w / 2.0;
|
||||
let max_rows = (((ui.h - pad) / line) as usize).clamp(1, 24);
|
||||
let mut y = ui.h - pad - line;
|
||||
let mut drawn = 0;
|
||||
'lines: for text in logs.iter().rev() {
|
||||
let chunks = wrapping_lines(&ui, Face::Mono, size, text, max_w);
|
||||
for chunk in chunks.iter().rev() {
|
||||
if drawn == max_rows {
|
||||
break 'lines;
|
||||
}
|
||||
ui.text(Face::Mono, size, pad, y, BOOT_LOG, chunk);
|
||||
y -= line;
|
||||
drawn += 1;
|
||||
}
|
||||
}
|
||||
ui.finish();
|
||||
}
|
||||
|
||||
/// The lines `text` wraps into at `max_w`, word-wise; a word wider than
|
||||
/// `max_w` on its own breaks at the edge.
|
||||
fn wrapping_lines(ui: &Ui, face: Face, size: f32, text: &str, max_w: f32) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut line = String::new();
|
||||
for word in text.split(' ') {
|
||||
let mut word = word;
|
||||
loop {
|
||||
let candidate = if line.is_empty() {
|
||||
word.to_owned()
|
||||
} else {
|
||||
format!("{line} {word}")
|
||||
};
|
||||
if ui.measure(face, size, &candidate) <= max_w {
|
||||
line = candidate;
|
||||
break;
|
||||
}
|
||||
if line.is_empty() {
|
||||
// The word alone does not fit: hard-break it at the edge.
|
||||
let mut end = 0;
|
||||
for (i, ch) in word.char_indices() {
|
||||
if i > 0 && ui.measure(face, size, &word[..i]) > max_w {
|
||||
break;
|
||||
}
|
||||
end = i + ch.len_utf8();
|
||||
}
|
||||
lines.push(word[..end].to_owned());
|
||||
word = &word[end..];
|
||||
} else {
|
||||
lines.push(std::mem::take(&mut line));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !line.is_empty() {
|
||||
lines.push(line);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -555,4 +673,93 @@ mod tests {
|
||||
}
|
||||
reset_atlas();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_boot_atlas_holds_the_run_time_glyph_region_without_a_pack() {
|
||||
let atlas = HudAtlas::boot();
|
||||
assert_eq!((atlas.width, atlas.height), (1024, 1024));
|
||||
assert_eq!(
|
||||
atlas.sprites["horizon/ui-text"].texels((atlas.width, atlas.height)),
|
||||
(1024, 512)
|
||||
);
|
||||
assert_eq!(
|
||||
atlas.sprites["horizon/white"].texels((atlas.width, atlas.height)),
|
||||
(4, 4)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_boot_screen_centres_the_wordmark_and_left_aligns_the_console() {
|
||||
reset_atlas();
|
||||
let atlas = HudAtlas::boot();
|
||||
let mut batch = HudBatch::new([1280, 720]);
|
||||
draw_boot(&atlas, &mut batch, &[]);
|
||||
// The first quad is the pure black backdrop over the frame's panorama.
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let bg = &batch.vertices[..6];
|
||||
assert!(bg.iter().all(|v| v.color == 0xFF00_0000));
|
||||
assert!(bg.iter().all(|v| v.pos[0] == 0.0 || v.pos[0] == w));
|
||||
assert!(bg.iter().all(|v| v.pos[1] == 0.0 || v.pos[1] == h));
|
||||
assert!(
|
||||
!batch.patches.is_empty(),
|
||||
"the glyphs are queued for the atlas"
|
||||
);
|
||||
|
||||
let mut batch = HudBatch::new([1280, 720]);
|
||||
wordmark(&atlas, &mut batch);
|
||||
let (min, max) = batch
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|v| v.pos[0])
|
||||
.fold((f32::MAX, f32::MIN), |(a, b), x| (a.min(x), b.max(x)));
|
||||
assert!(
|
||||
min < batch.width / 2.0 && max > batch.width / 2.0,
|
||||
"the wordmark straddles the centre"
|
||||
);
|
||||
assert!(((min + max) / 2.0 - batch.width / 2.0).abs() < 1.0);
|
||||
|
||||
let long = "[12:34:56] [Render thread/INFO]: Horizon: resources loaded in 1.0s; 1240 block states, 1500 items, 62 biomes; 8 worker threads".to_owned();
|
||||
let mut batch = HudBatch::new([1280, 720]);
|
||||
draw_boot_log(&atlas, &mut batch, &[long]);
|
||||
let (min, max) = batch
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|v| v.pos[0])
|
||||
.fold((f32::MAX, f32::MIN), |(a, b), x| (a.min(x), b.max(x)));
|
||||
assert!(min >= 4.0, "the line stays inside the left inset");
|
||||
assert!(
|
||||
max - min <= batch.width / 2.0 + 1.0,
|
||||
"a too wide line is cut to half the screen"
|
||||
);
|
||||
|
||||
let mut batch = HudBatch::new([1280, 720]);
|
||||
let ui = Ui::new(&atlas, &mut batch, (0.0, 0.0));
|
||||
let lines = wrapping_lines(
|
||||
&ui,
|
||||
Face::Mono,
|
||||
MICRO,
|
||||
"the quick brown fox jumps over the lazy dog",
|
||||
90.0,
|
||||
);
|
||||
assert!(lines.len() > 1, "a long line wraps");
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.all(|l| ui.measure(Face::Mono, MICRO, l) <= 90.0),
|
||||
"every wrapped line fits"
|
||||
);
|
||||
assert_eq!(
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| l.split_whitespace().count())
|
||||
.sum::<usize>(),
|
||||
9,
|
||||
"the wrap keeps every word"
|
||||
);
|
||||
|
||||
let mut batch = HudBatch::new([1280, 720]);
|
||||
draw_boot_log(&atlas, &mut batch, &[]);
|
||||
assert!(batch.vertices.is_empty() && batch.patches.is_empty());
|
||||
reset_atlas();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! aquifers, surface rules, carvers and placed features) to chunk streaming.
|
||||
use crate::{
|
||||
biome::Biomes,
|
||||
chunk::{BIOME_STRATEGY, Chunk, HEIGHT, MIN_Y, SECTIONS, Section},
|
||||
chunk::{BIOME_STRATEGY, Chunk, HEIGHT, MIN_Y, SECTIONS, Section, block_strategy},
|
||||
save::SavedProto,
|
||||
streaming::{ChunkGenerator, LodSampler, Proto},
|
||||
};
|
||||
@@ -226,28 +226,7 @@ impl VanillaTerrain {
|
||||
.map(|(id, start, _, _)| (Arc::from(id), (start.x, start.z)))
|
||||
.collect();
|
||||
out.structure_starts = chunk.own_starts();
|
||||
let mut values = Box::new([0u32; 4096]);
|
||||
for si in 0..SECTIONS as i32 {
|
||||
let base = MIN_Y + si * 16;
|
||||
if base + 16 <= lo || base >= hi {
|
||||
continue;
|
||||
}
|
||||
values.fill(0);
|
||||
let mut any = false;
|
||||
for y in base.max(lo)..(base + 16).min(hi) {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
let id = chunk.block(bx + x, y, bz + z).0;
|
||||
values[((y & 15) << 8 | z << 4 | x) as usize] = id;
|
||||
any |= id != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if any {
|
||||
Arc::make_mut(&mut out.sections[si as usize]).blocks =
|
||||
Section::from_blocks(&values);
|
||||
}
|
||||
}
|
||||
self.copy_blocks(chunk, &mut out);
|
||||
// Ticks the features scheduled (leaves reshaped at a tree's edge,
|
||||
// fluids of lakes and springs) run once the chunk is live, as
|
||||
// LevelChunk takes over ProtoChunk's ticks.
|
||||
@@ -297,6 +276,77 @@ impl VanillaTerrain {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `column`'s block copy alone.
|
||||
fn copy_blocks(&self, chunk: &ProtoChunk, out: &mut Chunk) {
|
||||
let pos = chunk.position();
|
||||
let (bx, bz) = (pos.x * 16, pos.z * 16);
|
||||
let (lo, hi) = (
|
||||
chunk.terrain.min_y().max(MIN_Y),
|
||||
(chunk.terrain.min_y() + chunk.terrain.height()).min(MIN_Y + HEIGHT),
|
||||
);
|
||||
let mut values = Box::new([0u32; 4096]);
|
||||
for si in 0..SECTIONS as i32 {
|
||||
let base = MIN_Y + si * 16;
|
||||
if base + 16 <= lo || base >= hi {
|
||||
continue;
|
||||
}
|
||||
// A uniform terrain section (air above the surface, mostly) needs
|
||||
// no per-cell reads: copy its one state, or leave resident air.
|
||||
// The terrain's sections start at its own MIN_Y, which differs per
|
||||
// dimension, so convert this client section to a terrain index.
|
||||
let section = usize::try_from((base - chunk.terrain.min_y()) >> 4)
|
||||
.ok()
|
||||
.and_then(|i| chunk.terrain.section(i));
|
||||
if let Some(section) = section {
|
||||
if section.bits() == 0 {
|
||||
let id = section.get(0).expect("index in range").0;
|
||||
if id != 0 {
|
||||
Arc::make_mut(&mut out.sections[si as usize]).blocks =
|
||||
PalettedContainer::new(block_strategy(), id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Hand the terrain palette and packed indices over as-is: the
|
||||
// resident container accepts any palette order and `pack`
|
||||
// rewrites vanilla's first-use order on save.
|
||||
if let Some(words) = section.words()
|
||||
&& let Some(container) = PalettedContainer::from_parts(
|
||||
block_strategy(),
|
||||
section.palette().iter().map(|s| s.0).collect(),
|
||||
section.bits(),
|
||||
words.to_vec(),
|
||||
)
|
||||
{
|
||||
Arc::make_mut(&mut out.sections[si as usize]).blocks = container;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
values.fill(0);
|
||||
let mut any = false;
|
||||
for y in base.max(lo)..(base + 16).min(hi) {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
let id = chunk.block(bx + x, y, bz + z).0;
|
||||
values[((y & 15) << 8 | z << 4 | x) as usize] = id;
|
||||
any |= id != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if any {
|
||||
Arc::make_mut(&mut out.sections[si as usize]).blocks =
|
||||
Section::from_blocks(&values);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A proto chunk's blocks as a column, for lighting a finished neighbour:
|
||||
/// biomes stay at the section default and ticks, structures, postprocessing
|
||||
/// and block entities are skipped. Never mesh or save this.
|
||||
fn light_column(&self, chunk: &ProtoChunk) -> Chunk {
|
||||
let pos = chunk.position();
|
||||
let mut out = Chunk::empty(pos.x, pos.z);
|
||||
self.copy_blocks(chunk, &mut out);
|
||||
out
|
||||
}
|
||||
/// The structure starts owned by a chunk (cached by the generator).
|
||||
pub fn starts_at(
|
||||
&self,
|
||||
@@ -507,7 +557,7 @@ impl ChunkGenerator for VanillaTerrain {
|
||||
}
|
||||
fn light_view(&self, proto: &Proto) -> Option<Chunk> {
|
||||
match proto.downcast_ref::<VanillaProto>() {
|
||||
Some(VanillaProto::Ready(chunk)) => Some(self.column(chunk)),
|
||||
Some(VanillaProto::Ready(chunk)) => Some(self.light_column(chunk)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -629,7 +679,7 @@ impl ChunkGenerator for VanillaTerrain {
|
||||
crate::structure_blocks::block_entities(&chunk, self.loot.as_deref());
|
||||
out.block_entities = block_entities;
|
||||
// The block entities Horizon does not model (decorated pots with their
|
||||
// sherds and loot table, brushable blocks) stay as placed, like a
|
||||
// sherds and loot table) stay as placed, like a
|
||||
// loaded chunk's.
|
||||
let mut unmodelled: Vec<Tag> = chunk
|
||||
.block_entities
|
||||
|
||||
@@ -242,12 +242,14 @@ fn columns(chunk: &ProtoChunk, biome_map: &[u16], dimension: &str) -> Arc<[Colum
|
||||
for y in min..max {
|
||||
let state = chunk.block(x, y, z);
|
||||
let properties = states.get(state);
|
||||
let name = registry.state(state).map_or("", |s| s.name.as_str());
|
||||
if properties.is_air()
|
||||
|| (!properties.blocks_motion_in_heightmap() && !properties.has_fluid())
|
||||
|| (!properties.blocks_motion_in_heightmap()
|
||||
&& !properties.has_fluid()
|
||||
&& !matches!(name, "minecraft:chorus_plant" | "minecraft:chorus_flower"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name = registry.state(state).map_or("", |s| s.name.as_str());
|
||||
let fluid = matches!(name, "minecraft:water" | "minecraft:lava");
|
||||
let biome = biome_map
|
||||
.get(chunk.biome_id_at(x, y, z).0 as usize)
|
||||
@@ -300,6 +302,8 @@ pub(super) struct VanillaLodSampler {
|
||||
cache: FxHashMap<(i32, i32), Arc<[Column; 256]>>,
|
||||
order: VecDeque<(i32, i32)>,
|
||||
details: Vec<LodDetail>,
|
||||
/// Reduced far detail: `lod_grid` approximates wide cells.
|
||||
approximate: bool,
|
||||
}
|
||||
impl VanillaLodSampler {
|
||||
pub(super) fn new(generator: Arc<VanillaTerrain>) -> Self {
|
||||
@@ -318,6 +322,7 @@ impl VanillaLodSampler {
|
||||
cache: FxHashMap::default(),
|
||||
order: VecDeque::new(),
|
||||
details: Vec::new(),
|
||||
approximate: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,8 +336,12 @@ impl LodSampler for VanillaLodSampler {
|
||||
cache: FxHashMap::default(),
|
||||
order: VecDeque::new(),
|
||||
details: Vec::new(),
|
||||
approximate: self.approximate,
|
||||
}))
|
||||
}
|
||||
fn set_approximate(&mut self, approximate: bool) {
|
||||
self.approximate = approximate;
|
||||
}
|
||||
fn request_detail(&mut self, chunks: &[(i32, i32)], center: [i32; 2]) {
|
||||
let mut missing = Vec::new();
|
||||
for &key in chunks {
|
||||
@@ -391,16 +400,17 @@ impl LodSampler for VanillaLodSampler {
|
||||
self.grid(x, z, width, 1).pop().unwrap_or_default()
|
||||
}
|
||||
fn grid(&mut self, x: i32, z: i32, width: i32, side: usize) -> Vec<Column> {
|
||||
let samples = match self
|
||||
.worker
|
||||
.lod_grid(x + width / 2, z + width / 2, width, side)
|
||||
{
|
||||
Ok(samples) => samples,
|
||||
Err(error) => {
|
||||
log::warn!("Horizon: direct far sampling failed: {error}");
|
||||
return vec![Column::default(); side * side];
|
||||
}
|
||||
};
|
||||
let samples =
|
||||
match self
|
||||
.worker
|
||||
.lod_grid(x + width / 2, z + width / 2, width, side, self.approximate)
|
||||
{
|
||||
Ok(samples) => samples,
|
||||
Err(error) => {
|
||||
log::warn!("Horizon: direct far sampling failed: {error}");
|
||||
return vec![Column::default(); side * side];
|
||||
}
|
||||
};
|
||||
samples
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -499,10 +509,10 @@ mod tests {
|
||||
.registry
|
||||
.default_state("minecraft:grass_block")
|
||||
.unwrap();
|
||||
let planted = |biome, width, x, z| {
|
||||
let planted = |ground, biome, width, x, z| {
|
||||
let mut column = Column::from_surface(Some(Surface {
|
||||
height: 80,
|
||||
state: grass,
|
||||
state: ground,
|
||||
water: None,
|
||||
biome,
|
||||
}));
|
||||
@@ -525,8 +535,8 @@ mod tests {
|
||||
let mut plains_count = 0;
|
||||
for z in 0..64 {
|
||||
for x in 0..64 {
|
||||
forest_count += usize::from(leaves(&planted(forest, 1, x, z)));
|
||||
plains_count += usize::from(leaves(&planted(plains, 1, x, z)));
|
||||
forest_count += usize::from(leaves(&planted(grass, forest, 1, x, z)));
|
||||
plains_count += usize::from(leaves(&planted(grass, plains, 1, x, z)));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
@@ -535,10 +545,82 @@ mod tests {
|
||||
);
|
||||
for width in [16, 32, 128, 512, 1024] {
|
||||
assert!(
|
||||
leaves(&planted(forest, width, 0, 0)),
|
||||
leaves(&planted(grass, forest, width, 0, 0)),
|
||||
"forest canopy missing at scale {width}"
|
||||
);
|
||||
}
|
||||
// The End's chorus plants come from the biome's own feature data: only
|
||||
// end highlands carry it, and only end stone is a valid ground.
|
||||
let end_stone = assets
|
||||
.registry
|
||||
.default_state("minecraft:end_stone")
|
||||
.unwrap();
|
||||
let chorus_name = |state| {
|
||||
matches!(
|
||||
assets.registry.state(state).unwrap().name.as_str(),
|
||||
"minecraft:chorus_plant" | "minecraft:chorus_flower"
|
||||
)
|
||||
};
|
||||
let chorus = |column: &Column| column.spans.iter().any(|span| chorus_name(span.state));
|
||||
let highlands = biomes.id("minecraft:end_highlands");
|
||||
assert!(
|
||||
sampler.vegetation.chorus_count(highlands).unwrap_or(0.) > 0.,
|
||||
"end highlands keep the chorus feature"
|
||||
);
|
||||
assert_eq!(
|
||||
sampler.vegetation.chorus_count(forest),
|
||||
None,
|
||||
"a biome without the feature estimates none"
|
||||
);
|
||||
let mut highlands_count = 0;
|
||||
let mut control_count = 0;
|
||||
for z in 0..64 {
|
||||
for x in 0..64 {
|
||||
highlands_count += usize::from(chorus(&planted(end_stone, highlands, 1, x, z)));
|
||||
control_count += usize::from(chorus(&planted(end_stone, forest, 1, x, z)));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
highlands_count > 0 && control_count == 0,
|
||||
"feature-derived chorus density: highlands={highlands_count}, forest={control_count}"
|
||||
);
|
||||
let stem = (0..64)
|
||||
.flat_map(|z| (0..64).map(move |x| (x, z)))
|
||||
.map(|(x, z)| planted(end_stone, highlands, 1, x, z))
|
||||
.find(|column| chorus(column))
|
||||
.expect("a chorus column");
|
||||
let plant = stem
|
||||
.spans
|
||||
.iter()
|
||||
.find(|span| chorus_name(span.state))
|
||||
.unwrap();
|
||||
assert!(
|
||||
(2..=5).contains(&(i32::from(plant.max - plant.min))),
|
||||
"stem height follows the feature's 2..=5"
|
||||
);
|
||||
let top = stem.spans.last().unwrap();
|
||||
assert_eq!(
|
||||
assets.registry.state(top.state).unwrap().name,
|
||||
"minecraft:chorus_flower"
|
||||
);
|
||||
assert_eq!(stem.surface.unwrap().height, i32::from(top.max));
|
||||
let mut wide = 0;
|
||||
for width in [2, 4] {
|
||||
for z in (0..64).step_by(2) {
|
||||
for x in (0..64).step_by(2) {
|
||||
wide += usize::from(chorus(&planted(end_stone, highlands, width, x, z)));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wide > 0, "nearby LOD cells still draw chorus");
|
||||
for z in (0..64).step_by(16) {
|
||||
for x in (0..64).step_by(16) {
|
||||
assert!(
|
||||
!chorus(&planted(end_stone, highlands, 16, x, z)),
|
||||
"a 16-block cell is too wide to read as one plant"
|
||||
);
|
||||
}
|
||||
}
|
||||
let key = (-64, 0);
|
||||
sampler.request_detail(&[key], [-1024, 0]);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
@@ -697,4 +779,76 @@ mod tests {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Vanilla decoration places chorus plants in the End; the far columns must
|
||||
/// keep them even though they are not motion-blocking blocks.
|
||||
#[test]
|
||||
#[ignore = "requires prepared worldgen data in HORIZON_TEST_RESOURCES"]
|
||||
fn end_far_columns_keep_real_chorus_plants() -> crate::Result<()> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("resources"),
|
||||
);
|
||||
let assets = crate::assets::Assets::load(&root)?;
|
||||
let biomes = crate::biome::Biomes::load(&root)?;
|
||||
let terrain = Arc::new(VanillaTerrain::for_dimension(
|
||||
&root.join("worldgen"),
|
||||
assets.registry,
|
||||
&biomes,
|
||||
1234,
|
||||
"minecraft:the_end",
|
||||
)?);
|
||||
let mut worker = terrain.generator.worker();
|
||||
let chorus = |state| {
|
||||
matches!(
|
||||
assets.registry.state(state).unwrap().name.as_str(),
|
||||
"minecraft:chorus_plant" | "minecraft:chorus_flower"
|
||||
)
|
||||
};
|
||||
let mut checked = 0;
|
||||
'search: for cx in (-160..=160).step_by(16) {
|
||||
for cz in (-160..=160).step_by(16) {
|
||||
let (x, z) = (cx * 16 + 8, cz * 16 + 8);
|
||||
let biome = worker.noise_biomes(&[[x >> 2, 16, z >> 2]]);
|
||||
if biome.first().and_then(|name| name.as_deref()) != Some("minecraft:end_highlands")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if worker.surface_sample(x, z).is_none() {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
if checked > 24 {
|
||||
break 'search;
|
||||
}
|
||||
let Some(chunk) = decorated_region(&mut worker, (cx, cz), &AtomicBool::new(false))?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let min = chunk.terrain.min_y();
|
||||
let max = min + chunk.terrain.height();
|
||||
let mut raw = 0;
|
||||
for y in min..max {
|
||||
for zz in 0..16 {
|
||||
for xx in 0..16 {
|
||||
raw += usize::from(chorus(chunk.block(xx, y, zz)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw == 0 {
|
||||
continue;
|
||||
}
|
||||
let kept: usize = columns(&chunk, &terrain.biome_map, "minecraft:the_end")
|
||||
.iter()
|
||||
.flat_map(|column| &column.spans)
|
||||
.filter(|span| chorus(span.state))
|
||||
.map(|span| (span.max - span.min) as usize)
|
||||
.sum();
|
||||
assert_eq!(
|
||||
raw, kept,
|
||||
"far columns keep every real chorus block in {cx} {cz}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
panic!("no generated end_highlands chunk with chorus among {checked} candidates");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Seed-stable visual vegetation from the world's own feature data.
|
||||
//! Seed-stable visual vegetation from the world's own feature data: trees, and
|
||||
//! the End's chorus plants.
|
||||
//! shortcut: these positions approximate native placement; saved/visited and
|
||||
//! completed native chunks replace them rather than changing gameplay worldgen.
|
||||
use crate::{
|
||||
@@ -11,6 +12,10 @@ use horizon_world::{
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
/// The widest LOD cell that draws chorus: a cell is drawn as a full-footprint
|
||||
/// column, so a one-block plant in a wider cell would read as a coloured slab.
|
||||
const CHORUS_CELL: i32 = 4;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Tree {
|
||||
leaves: BlockStateId,
|
||||
@@ -20,8 +25,16 @@ struct Tree {
|
||||
radius: i32,
|
||||
count: f64,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Chorus {
|
||||
plant: BlockStateId,
|
||||
flower: BlockStateId,
|
||||
/// Expected plants per chunk (`minecraft:chorus_plant`'s placement).
|
||||
count: f64,
|
||||
}
|
||||
pub(super) struct Vegetation {
|
||||
trees: FxHashMap<u16, Vec<Tree>>,
|
||||
chorus: FxHashMap<u16, Chorus>,
|
||||
seed: u64,
|
||||
registry: &'static BlockRegistry,
|
||||
}
|
||||
@@ -59,6 +72,7 @@ fn gather(
|
||||
count: f64,
|
||||
depth: usize,
|
||||
out: &mut Vec<Tree>,
|
||||
chorus: &mut Option<Chorus>,
|
||||
) {
|
||||
if depth > 12 || count <= 0. {
|
||||
return;
|
||||
@@ -70,7 +84,7 @@ fn gather(
|
||||
data.configured_feature_json(name)
|
||||
};
|
||||
if let Some(json) = json.and_then(|s| serde_json::from_str::<Value>(s).ok()) {
|
||||
gather(data, &json, placed, count, depth + 1, out);
|
||||
gather(data, &json, placed, count, depth + 1, out, chorus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -95,7 +109,15 @@ fn gather(
|
||||
}
|
||||
}
|
||||
}
|
||||
gather(data, &value["feature"], false, count, depth + 1, out);
|
||||
gather(
|
||||
data,
|
||||
&value["feature"],
|
||||
false,
|
||||
count,
|
||||
depth + 1,
|
||||
out,
|
||||
chorus,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let config = if value["config"].is_object() {
|
||||
@@ -104,6 +126,34 @@ fn gather(
|
||||
value
|
||||
};
|
||||
match value["type"].as_str().unwrap_or("") {
|
||||
"minecraft:chorus_plant" => {
|
||||
// A stateless feature: the tip is always a fully grown flower, and
|
||||
// the trunk height (2..=5) is a property of its block behaviour.
|
||||
let registry = data.registry();
|
||||
let (Some(plant), Some(base)) = (
|
||||
registry.default_state("minecraft:chorus_plant"),
|
||||
registry.default_state("minecraft:chorus_flower"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let Some(mut state) = registry.state(base).cloned() else {
|
||||
return;
|
||||
};
|
||||
state.properties.insert("age".into(), "5".into());
|
||||
let Some(flower) = registry.id(&state) else {
|
||||
return;
|
||||
};
|
||||
match chorus {
|
||||
Some(existing) => existing.count += count,
|
||||
None => {
|
||||
*chorus = Some(Chorus {
|
||||
plant,
|
||||
flower,
|
||||
count,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
"minecraft:tree" => {
|
||||
let (Some(leaves), Some(log)) = (
|
||||
block(&config["foliage_provider"], data.registry()),
|
||||
@@ -137,15 +187,32 @@ fn gather(
|
||||
remaining * chance,
|
||||
depth + 1,
|
||||
out,
|
||||
chorus,
|
||||
);
|
||||
remaining *= 1. - chance;
|
||||
}
|
||||
}
|
||||
gather(data, &config["default"], true, remaining, depth + 1, out);
|
||||
gather(
|
||||
data,
|
||||
&config["default"],
|
||||
true,
|
||||
remaining,
|
||||
depth + 1,
|
||||
out,
|
||||
chorus,
|
||||
);
|
||||
}
|
||||
"minecraft:random_boolean_selector" => {
|
||||
for key in ["feature_true", "feature_false"] {
|
||||
gather(data, &config[key], true, count * 0.5, depth + 1, out);
|
||||
gather(
|
||||
data,
|
||||
&config[key],
|
||||
true,
|
||||
count * 0.5,
|
||||
depth + 1,
|
||||
out,
|
||||
chorus,
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:simple_random_selector" => {
|
||||
@@ -158,6 +225,7 @@ fn gather(
|
||||
count / features.len().max(1) as f64,
|
||||
depth + 1,
|
||||
out,
|
||||
chorus,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -173,9 +241,34 @@ fn hash(seed: u64, x: i32, z: i32) -> u64 {
|
||||
n = (n ^ n >> 27).wrapping_mul(0x94d049bb133111eb);
|
||||
n ^ n >> 31
|
||||
}
|
||||
/// The chorus plant whose root falls in this output cell: its height, or none.
|
||||
/// `count` is per chunk, so a 4×4 cell holds one with probability `count/16`;
|
||||
/// a wider cell scans the cells it covers and keeps the expected density.
|
||||
fn chorus_at(seed: u64, count: f64, x: i32, z: i32, width: i32) -> Option<i32> {
|
||||
if width > CHORUS_CELL || count <= 0. {
|
||||
return None;
|
||||
}
|
||||
let probability = (count / 16.).min(1.);
|
||||
for cz in z.div_euclid(4)..=(z + width - 1).div_euclid(4) {
|
||||
for cx in x.div_euclid(4)..=(x + width - 1).div_euclid(4) {
|
||||
let n = hash(seed, cx, cz);
|
||||
if (n as u32 as f64 / u32::MAX as f64) >= probability {
|
||||
continue;
|
||||
}
|
||||
let tx = cx * 4 + ((n >> 32) & 3) as i32;
|
||||
let tz = cz * 4 + ((n >> 34) & 3) as i32;
|
||||
if tx < x || tx >= x + width || tz < z || tz >= z + width {
|
||||
continue;
|
||||
}
|
||||
return Some(2 + ((n >> 40) % 4) as i32);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
impl Vegetation {
|
||||
pub(super) fn new(data: &GeneratorData, map: &[u16], seed: i64) -> Self {
|
||||
let mut trees = FxHashMap::default();
|
||||
let mut chorus = FxHashMap::default();
|
||||
for (index, &biome) in map.iter().enumerate() {
|
||||
let Ok(json) = serde_json::from_str::<Value>(
|
||||
data.biome_json(horizon_world::worldgen::biome::BiomeId(index as u32)),
|
||||
@@ -183,11 +276,12 @@ impl Vegetation {
|
||||
continue;
|
||||
};
|
||||
let mut list = Vec::new();
|
||||
let mut plant = None;
|
||||
if let Some(steps) = json["features"].as_array() {
|
||||
for step in steps {
|
||||
if let Some(features) = step.as_array() {
|
||||
for feature in features {
|
||||
gather(data, feature, true, 1., 0, &mut list);
|
||||
gather(data, feature, true, 1., 0, &mut list, &mut plant);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,9 +289,13 @@ impl Vegetation {
|
||||
if !list.is_empty() {
|
||||
trees.insert(biome, list);
|
||||
}
|
||||
if let Some(plant) = plant {
|
||||
chorus.insert(biome, plant);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
trees,
|
||||
chorus,
|
||||
seed: seed as u64,
|
||||
registry: data.registry(),
|
||||
}
|
||||
@@ -213,6 +311,10 @@ impl Vegetation {
|
||||
.registry
|
||||
.state(surface.state)
|
||||
.map_or("", |s| s.name.as_str());
|
||||
if ground == "minecraft:end_stone" {
|
||||
self.apply_chorus(column, surface, x, z, width, light);
|
||||
return;
|
||||
}
|
||||
if !matches!(
|
||||
ground,
|
||||
"minecraft:grass_block"
|
||||
@@ -306,4 +408,79 @@ impl Vegetation {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// A chorus plant as its place-and-grow feature leaves it: a 2..=5 block
|
||||
/// stem under one fully grown flower, or a full-cell stand-in where the
|
||||
/// output cell covers several blocks.
|
||||
fn apply_chorus(
|
||||
&self,
|
||||
column: &mut Column,
|
||||
surface: Surface,
|
||||
x: i32,
|
||||
z: i32,
|
||||
width: i32,
|
||||
light: u16,
|
||||
) {
|
||||
let Some(chorus) = self.chorus.get(&surface.biome) else {
|
||||
return;
|
||||
};
|
||||
let Some(height) = chorus_at(self.seed, chorus.count, x, z, width) else {
|
||||
return;
|
||||
};
|
||||
let base = surface.height;
|
||||
let top = base + height;
|
||||
column.push(Span {
|
||||
min: base as i16,
|
||||
max: top as i16,
|
||||
state: chorus.plant,
|
||||
biome: surface.biome,
|
||||
light,
|
||||
});
|
||||
column.push(Span {
|
||||
min: top as i16,
|
||||
max: (top + 1) as i16,
|
||||
state: chorus.flower,
|
||||
biome: surface.biome,
|
||||
light,
|
||||
});
|
||||
column.surface = Some(Surface {
|
||||
height: top + 1,
|
||||
state: chorus.flower,
|
||||
..surface
|
||||
});
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(super) fn chorus_count(&self, biome: u16) -> Option<f64> {
|
||||
self.chorus.get(&biome).map(|chorus| chorus.count)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chorus_density_and_height_follow_the_placements() {
|
||||
let mut plants = 0;
|
||||
for z in 0..256 {
|
||||
for x in 0..256 {
|
||||
if let Some(height) = chorus_at(7, 1., x, z, 1) {
|
||||
plants += 1;
|
||||
assert!((2..=5).contains(&height), "height {height}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// One plant per chunk: 256² blocks hold 256 plants, within hash noise.
|
||||
assert!((180..=330).contains(&plants), "plants: {plants}");
|
||||
assert_eq!(chorus_at(7, 1., 0, 0, 1), chorus_at(7, 1., 0, 0, 1));
|
||||
assert_eq!(chorus_at(7, 1., 0, 0, 16), None);
|
||||
assert_eq!(chorus_at(7, 0., 0, 0, 1), None);
|
||||
|
||||
let mut wide = 0;
|
||||
for z in (0..64).step_by(4) {
|
||||
for x in (0..64).step_by(4) {
|
||||
wide += usize::from(chorus_at(7, 1., x, z, 4).is_some());
|
||||
}
|
||||
}
|
||||
assert!((4..=40).contains(&wide), "4×4-cell plants: {wide}");
|
||||
}
|
||||
}
|
||||
@@ -357,7 +357,7 @@ fn saved_proto_block_entities_keep_their_id() {
|
||||
generator.decorate(&mut worker, &mut region);
|
||||
generator.save_protos(®ion.iter().collect::<Vec<_>>());
|
||||
save.flush().unwrap();
|
||||
let mut file = RegionFile::open(dir.join("region/r.1.0.mca")).unwrap();
|
||||
let file = RegionFile::open(dir.join("region/r.1.0.mca")).unwrap();
|
||||
let mut suspicious = 0;
|
||||
for &(x, z) in &keys {
|
||||
let Some(record) = file
|
||||
|
||||
@@ -36,6 +36,14 @@ impl SamplerContext {
|
||||
..Self::cached()
|
||||
}
|
||||
}
|
||||
/// Keeps each y-fixed slice's plane (a function of x and z only), so that
|
||||
/// sampling the same x/z lattice again, at other heights or another
|
||||
/// vertical step, reuses it. For one evaluation over one graph: far
|
||||
/// previews sample a tile coarsely, then again around its surface.
|
||||
pub fn with_planes(mut self) -> Self {
|
||||
self.planes = Some(Default::default());
|
||||
self
|
||||
}
|
||||
pub fn clear_caches(&mut self) {
|
||||
if let Some(caches) = &mut self.caches {
|
||||
caches.clear();
|
||||
|
||||
@@ -94,6 +94,8 @@ pub struct SamplerContext {
|
||||
caches: Option<Vec<cache::CacheCell>>,
|
||||
fields: WorldFields,
|
||||
sparse: bool,
|
||||
/// Planes of y-fixed slices already sampled (`with_planes`).
|
||||
planes: Option<std::collections::HashMap<slice::PlaneKey, Vec<f32>>>,
|
||||
}
|
||||
impl SamplerContext {
|
||||
fn with_buffer<T>(
|
||||
@@ -113,6 +115,9 @@ impl SamplerContext {
|
||||
pub fn reset(&mut self) {
|
||||
self.caches = self.caches.as_ref().map(|_| Vec::new());
|
||||
self.fields = WorldFields::default();
|
||||
if let Some(planes) = &mut self.planes {
|
||||
planes.clear();
|
||||
}
|
||||
}
|
||||
/// Release retained storage between worlds or after unusually large work.
|
||||
pub fn clear_scratch(&mut self) {
|
||||
|
||||
@@ -19,6 +19,10 @@ impl SliceAxes {
|
||||
}
|
||||
}
|
||||
|
||||
/// A y-fixed slice's plane: the slice node, the plane's corner (y fixed),
|
||||
/// its x/z sizes and steps (`SamplerContext::with_planes`).
|
||||
pub(super) type PlaneKey = (usize, [i32; 3], [usize; 2], [i32; 2]);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SliceSampler {
|
||||
pub input: Arc<dyn DensitySampler>,
|
||||
@@ -61,25 +65,54 @@ impl DensitySampler for SliceSampler {
|
||||
if reduced == volume {
|
||||
return self.input.sample_volume(context, buffer, volume);
|
||||
}
|
||||
let plane = (fixed[1].is_some() && context.planes.is_some()).then(|| {
|
||||
let [rx, _, rz] = reduced.sizes();
|
||||
let [step_x, _, step_z] = volume.steps();
|
||||
(
|
||||
self as *const Self as usize,
|
||||
reduced.minimum(),
|
||||
[rx, rz],
|
||||
[step_x, step_z],
|
||||
)
|
||||
});
|
||||
if let Some(key) = &plane
|
||||
&& let Some(samples) = context.planes.as_ref().and_then(|p| p.get(key))
|
||||
{
|
||||
spread(fixed, volume, reduced, samples, buffer);
|
||||
return Ok(());
|
||||
}
|
||||
context.with_buffer(reduced.size(), |context, samples| {
|
||||
self.input.sample_volume(context, samples, reduced)?;
|
||||
let [sx, sy, sz] = volume.sizes();
|
||||
let [rx, ry, _] = reduced.sizes();
|
||||
for z in 0..sz {
|
||||
let source_z = if fixed[2].is_some() { 0 } else { z };
|
||||
for x in 0..sx {
|
||||
let source_x = if fixed[0].is_some() { 0 } else { x };
|
||||
let source = (source_x + source_z * rx) * ry;
|
||||
let target = (x + z * sx) * sy;
|
||||
let column = &mut buffer[target..target + sy];
|
||||
if fixed[1].is_some() {
|
||||
column.fill(samples[source]);
|
||||
} else {
|
||||
column.copy_from_slice(&samples[source..source + sy]);
|
||||
}
|
||||
}
|
||||
spread(fixed, volume, reduced, samples, buffer);
|
||||
if let (Some(key), Some(planes)) = (plane, &mut context.planes) {
|
||||
planes.insert(key, samples[..reduced.size()].to_vec());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
/// Copies the `reduced` samples along the fixed axes of `volume`.
|
||||
fn spread(
|
||||
fixed: [Option<i32>; 3],
|
||||
volume: DensityVolume,
|
||||
reduced: DensityVolume,
|
||||
samples: &[f32],
|
||||
buffer: &mut [f32],
|
||||
) {
|
||||
let [sx, sy, sz] = volume.sizes();
|
||||
let [rx, ry, _] = reduced.sizes();
|
||||
for z in 0..sz {
|
||||
let source_z = if fixed[2].is_some() { 0 } else { z };
|
||||
for x in 0..sx {
|
||||
let source_x = if fixed[0].is_some() { 0 } else { x };
|
||||
let source = (source_x + source_z * rx) * ry;
|
||||
let target = (x + z * sx) * sy;
|
||||
let column = &mut buffer[target..target + sy];
|
||||
if fixed[1].is_some() {
|
||||
column.fill(samples[source]);
|
||||
} else {
|
||||
column.copy_from_slice(&samples[source..source + sy]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -959,3 +959,54 @@ fn binary_bulk_preserves_child_failure_order_and_output() {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A y-fixed slice's plane, kept by `SamplerContext::with_planes`, gives the
|
||||
/// values fresh sampling gives at other heights and vertical steps, and is
|
||||
/// sampled once for one x/z lattice.
|
||||
#[test]
|
||||
fn kept_planes_match_fresh_slices_across_vertical_lattices() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
#[derive(Debug)]
|
||||
struct Counted(NoiseSampler, AtomicUsize);
|
||||
impl DensitySampler for Counted {
|
||||
fn sample_value(&self, c: &mut SamplerContext, p: [i32; 3]) -> Result<f32, SampleError> {
|
||||
self.0.sample_value(c, p)
|
||||
}
|
||||
fn sample_volume(
|
||||
&self,
|
||||
c: &mut SamplerContext,
|
||||
out: &mut [f32],
|
||||
v: DensityVolume,
|
||||
) -> Result<(), SampleError> {
|
||||
self.1.fetch_add(1, Ordering::Relaxed);
|
||||
self.0.sample_volume(c, out, v)
|
||||
}
|
||||
}
|
||||
let input = Arc::new(Counted(
|
||||
NoiseSampler {
|
||||
noise: Arc::new(PerlinNoise::new(&mut LegacyRandom::new(5))),
|
||||
xz_scale: 0.3,
|
||||
y_scale: 0.7,
|
||||
},
|
||||
AtomicUsize::new(0),
|
||||
));
|
||||
let slice = slice::SliceSampler {
|
||||
input: input.clone(),
|
||||
axes: slice::SliceAxes::Y(0),
|
||||
};
|
||||
let lattices = [
|
||||
DensityVolume::new([5, 9, 4], [-8, -64, 12], [4, 64, 4]).unwrap(),
|
||||
DensityVolume::new([5, 17, 4], [-8, 0, 12], [4, 8, 4]).unwrap(),
|
||||
];
|
||||
let mut kept = SamplerContext::sparse().with_planes();
|
||||
for (i, volume) in lattices.into_iter().enumerate() {
|
||||
let mut fresh = vec![0.0; volume.size()];
|
||||
slice
|
||||
.sample_volume(&mut SamplerContext::sparse(), &mut fresh, volume)
|
||||
.unwrap();
|
||||
let mut reused = vec![1.0; volume.size()];
|
||||
slice.sample_volume(&mut kept, &mut reused, volume).unwrap();
|
||||
assert_eq!(fresh, reused, "lattice {i}");
|
||||
}
|
||||
// Two fresh evaluations and one kept plane.
|
||||
assert_eq!(input.1.load(Ordering::Relaxed), 3);
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
//! in index order, and the value that did not fit is added last. Palettes
|
||||
//! never shrink while resident; `pack` writes the canonical saved form.
|
||||
|
||||
use crate::storage::{locate, mask};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// `Strategy.createForBlockStates`: 4096 entries, linear palettes up to 4
|
||||
@@ -106,20 +108,6 @@ enum Palette {
|
||||
List(Vec<u32>),
|
||||
Global,
|
||||
}
|
||||
/// `n / per_word` for `n < 4096` by multiplication: `MAGIC[bits].0 = ceil(2^20 /
|
||||
/// (64 / bits))` is exact while `n * (64 / bits) < 2^20`. `.1` is the bits a
|
||||
/// word's entries fill (`64 / bits * bits`): looked up, not divided for, as a
|
||||
/// division by a run-time value costs tens of cycles on x86.
|
||||
const MAGIC: [(u32, u32); 33] = {
|
||||
let mut table = [(0, 0); 33];
|
||||
let mut bits = 1;
|
||||
while bits <= 32 {
|
||||
let per = 64 / bits as u32;
|
||||
table[bits] = ((1u32 << 20).div_ceil(per), per * bits as u32);
|
||||
bits += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
/// Words of `bits`-wide values: `entries.div_ceil(64 / bits)`.
|
||||
pub fn storage_len(bits: u8, entries: usize) -> usize {
|
||||
if bits == 0 {
|
||||
@@ -128,23 +116,6 @@ pub fn storage_len(bits: u8, entries: usize) -> usize {
|
||||
entries.div_ceil(64 / usize::from(bits))
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn locate(bits: u8, i: usize) -> (usize, u32) {
|
||||
if bits.is_power_of_two() {
|
||||
// 64 / bits is a power of two: shifts instead of the multiplication.
|
||||
let log = bits.trailing_zeros();
|
||||
let word = i >> (6 - log);
|
||||
(word, ((i & ((64 >> log) - 1)) as u32) << log)
|
||||
} else {
|
||||
let (magic, filled) = MAGIC[usize::from(bits)];
|
||||
let word = (i as u32 * magic) >> 20;
|
||||
(word as usize, i as u32 * u32::from(bits) - word * filled)
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn mask(bits: u8) -> u64 {
|
||||
(1u64 << bits) - 1
|
||||
}
|
||||
fn read(words: &[u64], bits: u8, i: usize) -> u32 {
|
||||
let (word, shift) = locate(bits, i);
|
||||
((words[word] >> shift) & mask(bits)) as u32
|
||||
@@ -476,6 +447,46 @@ impl PalettedContainer {
|
||||
let (palette, ids) = palette_ids(values);
|
||||
Self::build(strategy, palette, &ids)
|
||||
}
|
||||
/// A container over a paletted storage handed over as-is (worldgen's
|
||||
/// `BlockSection`): `palette` in any order, `bits` wide indices in `words`.
|
||||
/// None for a configuration this container does not store that way
|
||||
/// (a global palette, or storage of the wrong size).
|
||||
pub fn from_parts(
|
||||
strategy: Strategy,
|
||||
palette: Vec<u32>,
|
||||
bits: u8,
|
||||
words: Vec<u64>,
|
||||
) -> Option<Self> {
|
||||
if palette.is_empty() || palette.len() > 1 << bits {
|
||||
return None;
|
||||
}
|
||||
if palette.len() == 1 {
|
||||
return Some(Self::new(strategy, palette[0]));
|
||||
}
|
||||
let config = strategy.config(ceil_log2(palette.len()));
|
||||
let Config::List(_) = config else {
|
||||
return None;
|
||||
};
|
||||
if strategy.storage_bits(config) != bits {
|
||||
return None;
|
||||
}
|
||||
if words.len() != storage_len(bits, strategy.entries()) {
|
||||
return None;
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
for i in 0..strategy.entries() {
|
||||
debug_assert!(
|
||||
(read(&words, bits, i) as usize) < palette.len(),
|
||||
"palette id out of range"
|
||||
);
|
||||
}
|
||||
Some(Self {
|
||||
strategy,
|
||||
bits,
|
||||
palette: Palette::List(palette),
|
||||
words,
|
||||
})
|
||||
}
|
||||
/// A container over a distinct-value `palette` and an id per entry.
|
||||
fn build(strategy: Strategy, palette: Vec<u32>, ids: &[u32]) -> Self {
|
||||
let config = strategy.config(ceil_log2(palette.len()));
|
||||
@@ -772,6 +783,28 @@ mod tests {
|
||||
assert_eq!(b.bits(), want, "after {n} biomes");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn from_parts_hands_over_storage_unchanged() {
|
||||
let values: Vec<u32> = (0..4096).map(|i| [7, 9, 11][i % 3]).collect();
|
||||
let original = PalettedContainer::from_values(BLOCKS, &values);
|
||||
let (bits, palette, words) = original.to_wire();
|
||||
let handed =
|
||||
PalettedContainer::from_parts(BLOCKS, palette, bits, words.to_vec()).expect("list");
|
||||
assert_eq!(handed, original);
|
||||
assert_eq!(handed.pack(), original.pack());
|
||||
// Dead palette slots are accepted as long as every index is in range.
|
||||
let (bits, mut palette, words) = original.to_wire();
|
||||
palette.push(99);
|
||||
let handed = PalettedContainer::from_parts(BLOCKS, palette, bits, words.to_vec());
|
||||
assert_eq!(handed.expect("dead slot"), original);
|
||||
// A global-palette or wrongly sized storage is refused.
|
||||
assert!(
|
||||
PalettedContainer::from_parts(BLOCKS, vec![0; 300], 9, vec![0; storage_len(9, 4096)])
|
||||
.is_none()
|
||||
);
|
||||
assert!(PalettedContainer::from_parts(BLOCKS, vec![5, 6, 7], 4, vec![0; 8]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_a_plain_array_under_random_edits() {
|
||||
for (strategy, distinct) in [(BLOCKS, 700u64), (BLOCKS, 12), (BIOMES, 9), (BIOMES, 3)] {
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
//! Palette indices packed into padded 64-bit words, not a continuous bitstream.
|
||||
|
||||
/// `n / per_word` for `n < MAGIC_LEN` by multiplication: `MAGIC[bits].0 =
|
||||
/// ceil(2^20 / (64 / bits))` is exact while `n * (64 / bits) < 2^20`. `.1` is
|
||||
/// the bits a word's entries fill (`64 / bits * bits`): looked up, not divided
|
||||
/// for, as a division by a run-time value costs tens of cycles on x86.
|
||||
pub(crate) const MAGIC: [(u32, u32); 33] = {
|
||||
let mut table = [(0, 0); 33];
|
||||
let mut bits = 1;
|
||||
while bits <= 32 {
|
||||
let per = 64 / bits as u32;
|
||||
table[bits] = ((1u32 << 20).div_ceil(per), per * bits as u32);
|
||||
bits += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
/// Entries `locate` handles exactly for every width: the smallest width (1 bit,
|
||||
/// 64 per word) gives the bound `2^20 / 64`.
|
||||
pub(crate) const MAGIC_LEN: usize = 1 << 14;
|
||||
/// The multiply-shift replaces a ~20-26 cycle x86 divide, but arm64's divider
|
||||
/// is as cheap as the table lookup: measured on an Apple M-series CPU, 65k
|
||||
/// reads took 1.5 ms with the division and 2.5 ms with the table (bits 4-16),
|
||||
/// so only the x86 targets take the table.
|
||||
const MAGIC_BEATS_DIVISION: bool = cfg!(any(target_arch = "x86", target_arch = "x86_64"));
|
||||
/// The word and bit shift of entry `i`; exact for `i < MAGIC_LEN`.
|
||||
#[inline]
|
||||
pub(crate) fn locate(bits: u8, i: usize) -> (usize, u32) {
|
||||
if bits.is_power_of_two() {
|
||||
// 64 / bits is a power of two: shifts instead of the multiplication.
|
||||
let log = bits.trailing_zeros();
|
||||
let word = i >> (6 - log);
|
||||
(word, ((i & ((64 >> log) - 1)) as u32) << log)
|
||||
} else {
|
||||
let (magic, filled) = MAGIC[usize::from(bits)];
|
||||
let word = (i as u32 * magic) >> 20;
|
||||
(word as usize, i as u32 * u32::from(bits) - word * filled)
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub(crate) fn mask(bits: u8) -> u64 {
|
||||
(1u64 << bits) - 1
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum StorageError {
|
||||
InvalidBits(u8),
|
||||
@@ -22,6 +63,9 @@ pub struct PackedStorage {
|
||||
len: usize,
|
||||
per_word: usize,
|
||||
mask: u64,
|
||||
/// Whether `len` is past what [`locate`] handles exactly; such a storage
|
||||
/// falls back to division.
|
||||
wide: bool,
|
||||
words: Vec<u64>,
|
||||
}
|
||||
|
||||
@@ -46,6 +90,7 @@ impl PackedStorage {
|
||||
len,
|
||||
per_word,
|
||||
mask: (1_u64 << bits) - 1,
|
||||
wide: len > MAGIC_LEN,
|
||||
words,
|
||||
})
|
||||
}
|
||||
@@ -70,17 +115,27 @@ impl PackedStorage {
|
||||
&self.words
|
||||
}
|
||||
|
||||
fn location(&self, index: usize) -> Result<(usize, usize), StorageError> {
|
||||
/// The word and bit shift of an in-range index.
|
||||
#[inline]
|
||||
fn slot(&self, index: usize) -> (usize, u32) {
|
||||
if self.wide || !MAGIC_BEATS_DIVISION {
|
||||
(
|
||||
index / self.per_word,
|
||||
((index % self.per_word) * usize::from(self.bits)) as u32,
|
||||
)
|
||||
} else {
|
||||
locate(self.bits, index)
|
||||
}
|
||||
}
|
||||
|
||||
fn location(&self, index: usize) -> Result<(usize, u32), StorageError> {
|
||||
if index >= self.len {
|
||||
return Err(StorageError::OutOfBounds {
|
||||
index,
|
||||
len: self.len,
|
||||
});
|
||||
}
|
||||
Ok((
|
||||
index / self.per_word,
|
||||
(index % self.per_word) * usize::from(self.bits),
|
||||
))
|
||||
Ok(self.slot(index))
|
||||
}
|
||||
|
||||
pub fn get(&self, index: usize) -> Result<u32, StorageError> {
|
||||
@@ -93,8 +148,7 @@ impl PackedStorage {
|
||||
/// `index` must be less than `len`.
|
||||
pub fn read(&self, index: usize) -> u32 {
|
||||
debug_assert!(index < self.len);
|
||||
let word = index / self.per_word;
|
||||
let shift = (index % self.per_word) * usize::from(self.bits);
|
||||
let (word, shift) = self.slot(index);
|
||||
((self.words[word] >> shift) & self.mask) as u32
|
||||
}
|
||||
|
||||
@@ -103,8 +157,7 @@ impl PackedStorage {
|
||||
/// palette index from the same container. Returns the previous value.
|
||||
pub fn write(&mut self, index: usize, value: u32) -> u32 {
|
||||
debug_assert!(index < self.len && u64::from(value) <= self.mask);
|
||||
let word = index / self.per_word;
|
||||
let shift = (index % self.per_word) * usize::from(self.bits);
|
||||
let (word, shift) = self.slot(index);
|
||||
let old = ((self.words[word] >> shift) & self.mask) as u32;
|
||||
self.words[word] = (self.words[word] & !(self.mask << shift)) | (u64::from(value) << shift);
|
||||
old
|
||||
@@ -177,6 +230,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared multiply-shift index equals division for every width at the
|
||||
/// sizes that use `PackedStorage`: 4,096-cell sections and 256-cell
|
||||
/// heightmaps. A storage past the exact bound keeps the division path.
|
||||
#[test]
|
||||
fn multiply_shift_matches_division_at_section_and_heightmap_sizes() {
|
||||
for bits in 1..=32u8 {
|
||||
for len in [256usize, 4096] {
|
||||
let per = 64 / usize::from(bits);
|
||||
let mut storage = PackedStorage::new(bits, len).unwrap();
|
||||
assert!(!storage.wide);
|
||||
let mask = storage.mask;
|
||||
for i in 0..len {
|
||||
assert_eq!(
|
||||
storage.slot(i),
|
||||
(i / per, ((i % per) * usize::from(bits)) as u32),
|
||||
"bits {bits} len {len} index {i}"
|
||||
);
|
||||
let value = (i as u64 & mask) as u32;
|
||||
assert_eq!(storage.write(i, value), 0);
|
||||
assert_eq!(storage.read(i), value);
|
||||
assert_eq!(storage.get(i).unwrap(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut wide = PackedStorage::new(1, MAGIC_LEN + 1).unwrap();
|
||||
assert!(wide.wide);
|
||||
wide.write(MAGIC_LEN, 1);
|
||||
assert_eq!(wide.read(MAGIC_LEN), 1);
|
||||
assert_eq!(wide.get(MAGIC_LEN).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_padding_is_preserved_and_invalid_writes_are_atomic() {
|
||||
let mut storage = PackedStorage::from_words(5, 1, vec![u64::MAX]).unwrap();
|
||||
|
||||
@@ -107,6 +107,8 @@ pub struct ItemInfo {
|
||||
pub weapon_damage: Option<u32>,
|
||||
/// `use_effects.speed_multiplier` (UseEffects.DEFAULT: 0.2).
|
||||
pub use_speed: f32,
|
||||
/// `use_effects.interact_vibrations` (default true).
|
||||
pub interact_vibrations: bool,
|
||||
/// `glider` (elytra).
|
||||
pub glider: bool,
|
||||
/// `equippable.swappable` and `equip_sound`.
|
||||
@@ -260,6 +262,8 @@ pub struct Items {
|
||||
loot: HashMap<String, Value>,
|
||||
/// The `jukebox_song` registry.
|
||||
pub songs: HashMap<String, Song>,
|
||||
/// The `instrument` registry (goat horns).
|
||||
pub instruments: HashMap<String, Instrument>,
|
||||
/// The enchantment registry: loot functions enchant the items they roll
|
||||
/// (`EnchantWithLevelsFunction`, `EnchantRandomlyFunction`), and the
|
||||
/// station menus share this copy.
|
||||
@@ -279,6 +283,76 @@ impl Song {
|
||||
ticks >= self.length_ticks + 20
|
||||
}
|
||||
}
|
||||
/// An `Instrument`: what a goat horn plays.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Instrument {
|
||||
pub sound: String,
|
||||
/// Seconds the horn stays up, and its cooldown.
|
||||
pub use_duration: f32,
|
||||
/// How far it carries, in blocks (`range / 16` is its volume).
|
||||
pub range: f32,
|
||||
}
|
||||
impl Instrument {
|
||||
/// `InstrumentItem.getUseDuration`: `Mth.floor(useDuration * 20)`.
|
||||
pub fn use_ticks(&self) -> i32 {
|
||||
(self.use_duration * 20.0).floor() as i32
|
||||
}
|
||||
}
|
||||
/// The id of a goat horn's instrument (`InstrumentItem.getInstrument`): the
|
||||
/// stack's own `instrument` component, else the item's `ponder_goat_horn`.
|
||||
/// None for an inline instrument (see `inline_instrument`).
|
||||
pub fn instrument_id(stack: &ItemStack) -> Option<String> {
|
||||
if &*stack.item != "minecraft:goat_horn" {
|
||||
return None;
|
||||
}
|
||||
match stack.data().and_then(|d| d.other("minecraft:instrument")) {
|
||||
Some(horizon_world::nbt::Tag::String(id)) => Some(id.to_string_lossy()),
|
||||
Some(horizon_world::nbt::Tag::Compound(_)) => None,
|
||||
_ => Some("minecraft:ponder_goat_horn".to_owned()),
|
||||
}
|
||||
}
|
||||
/// A goat horn's inline `instrument` component (`Instrument.CODEC` also
|
||||
/// takes the instrument itself instead of a registry id).
|
||||
pub fn inline_instrument(stack: &ItemStack) -> Option<&horizon_world::nbt::Tag> {
|
||||
if &*stack.item != "minecraft:goat_horn" {
|
||||
return None;
|
||||
}
|
||||
match stack.data().and_then(|d| d.other("minecraft:instrument")) {
|
||||
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => Some(tag),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `Instrument.DIRECT_CODEC`: a `sound_event` that is an id or `{sound_id}`,
|
||||
/// `use_duration` and `range`.
|
||||
fn parse_instrument(v: &Value) -> Instrument {
|
||||
let sound = match &v["sound_event"] {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other["sound_id"].as_str().unwrap_or("").to_owned(),
|
||||
};
|
||||
Instrument {
|
||||
sound,
|
||||
use_duration: v["use_duration"].as_f64().unwrap_or(0.0) as f32,
|
||||
range: v["range"].as_f64().unwrap_or(0.0) as f32,
|
||||
}
|
||||
}
|
||||
fn load_instruments(root: &Path) -> HashMap<String, Instrument> {
|
||||
let mut instruments = HashMap::new();
|
||||
let Ok(dir) = fs::read_dir(root.join("data/minecraft/instrument")) else {
|
||||
return instruments;
|
||||
};
|
||||
for entry in dir.flatten() {
|
||||
let path = entry.path();
|
||||
let (Some(stem), Ok(bytes)) = (path.file_stem().and_then(|s| s.to_str()), fs::read(&path))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
instruments.insert(format!("minecraft:{stem}"), parse_instrument(&v));
|
||||
}
|
||||
instruments
|
||||
}
|
||||
fn load_songs(root: &Path) -> HashMap<String, Song> {
|
||||
let mut songs = HashMap::new();
|
||||
let Ok(dir) = fs::read_dir(root.join("data/minecraft/jukebox_song")) else {
|
||||
@@ -663,6 +737,9 @@ impl Items {
|
||||
use_speed: c["minecraft:use_effects"]["speed_multiplier"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.2) as f32,
|
||||
interact_vibrations: c["minecraft:use_effects"]["interact_vibrations"]
|
||||
.as_bool()
|
||||
.unwrap_or(true),
|
||||
blocks_attacks: BlocksAttacks::parse(&c["minecraft:blocks_attacks"]),
|
||||
disable_blocking_seconds: c["minecraft:weapon"]["disable_blocking_for_seconds"]
|
||||
.as_f64()
|
||||
@@ -756,12 +833,23 @@ impl Items {
|
||||
item_tag_order: load_tag_lists(root, "item")?,
|
||||
loot,
|
||||
songs: load_songs(root),
|
||||
instruments: load_instruments(root),
|
||||
enchantments: Arc::new(crate::enchant::Enchantments::load(root)),
|
||||
})
|
||||
}
|
||||
pub fn get(&self, id: &str) -> Option<&ItemInfo> {
|
||||
self.items.get(id)
|
||||
}
|
||||
/// The instrument a goat horn plays: its `instrument` component's
|
||||
/// registry entry or inline instrument.
|
||||
pub fn instrument(&self, stack: &ItemStack) -> Option<Instrument> {
|
||||
if let Some(tag) = inline_instrument(stack) {
|
||||
return Some(parse_instrument(
|
||||
&horizon_client_core::scoreboard::tag_to_json(tag),
|
||||
));
|
||||
}
|
||||
self.instruments.get(&instrument_id(stack)?).cloned()
|
||||
}
|
||||
/// Only the block and item tags (and the enchantment registry, which the
|
||||
/// station rules and loot functions are built from), for tools that need
|
||||
/// tag membership.
|
||||
@@ -775,6 +863,7 @@ impl Items {
|
||||
item_tag_order: load_tag_lists(root, "item")?,
|
||||
loot: HashMap::new(),
|
||||
songs: HashMap::new(),
|
||||
instruments: HashMap::new(),
|
||||
enchantments: Arc::new(crate::enchant::Enchantments::load(root)),
|
||||
})
|
||||
}
|
||||
@@ -793,6 +882,7 @@ impl Items {
|
||||
item_tags,
|
||||
loot: HashMap::new(),
|
||||
songs: HashMap::new(),
|
||||
instruments: HashMap::new(),
|
||||
enchantments: Arc::new(crate::enchant::Enchantments::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,10 @@ interface render {
|
||||
record sprite-shape {
|
||||
/// One of the game's GUI sprites, as `gui/sprites/` names them
|
||||
/// (`hud/heart/full`, `widget/button`), the resource packs' versions
|
||||
/// included. An unknown one draws nothing.
|
||||
/// included, drawn over `rect` as vanilla draws it: nine-sliced when
|
||||
/// its `gui.scaling` says so (`tooltip/frame`, `widget/button`: the
|
||||
/// borders keep their size), stretched otherwise. An unknown one
|
||||
/// draws nothing.
|
||||
sprite: string,
|
||||
rect: rect,
|
||||
/// A tint, 0xAARRGGBB: 0xFFFFFFFF draws it as it is.
|
||||
|
||||
@@ -909,6 +909,30 @@ fn steps(
|
||||
.collect::<Result<_, String>>()?,
|
||||
))
|
||||
};
|
||||
// The fluid tags are the one seed of the behaviour snapshot that is a
|
||||
// folder; the game keys them by their relative path, as recorded here.
|
||||
let fluid_hashes = {
|
||||
let dir = root.join("data/minecraft/tags/fluid");
|
||||
let mut paths: Vec<PathBuf> = if dir.is_dir() {
|
||||
fs::read_dir(&dir)
|
||||
.map_err(|e| format!("{}: {e}", dir.display()))?
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_file())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
paths.sort();
|
||||
let mut hashes = Value::Object(Vec::new());
|
||||
for path in paths {
|
||||
hashes.insert(
|
||||
&relative_display(&path, root)?,
|
||||
Value::Str(digest(&path, "sha256")?),
|
||||
);
|
||||
}
|
||||
hashes
|
||||
};
|
||||
let mut manifest = vec![("schema".to_string(), Value::Int(SCHEMA.to_string()))];
|
||||
manifest.extend(run.origin.as_object().unwrap().iter().cloned());
|
||||
manifest.extend([
|
||||
@@ -946,10 +970,19 @@ fn steps(
|
||||
.ok_or("version.json has no world_version")?,
|
||||
),
|
||||
("facts_sha256".into(), run.file_hash("facts.json")?),
|
||||
(
|
||||
"gameplay_facts_sha256".into(),
|
||||
run.file_hash("gameplay-facts.json")?,
|
||||
),
|
||||
(
|
||||
"behaviour_sha256".into(),
|
||||
run.file_hash("block-behaviours.json")?,
|
||||
),
|
||||
(
|
||||
"block_tags_sha256".into(),
|
||||
run.file_hash("block-tags.json")?,
|
||||
),
|
||||
("fluid_tags_sha256".into(), fluid_hashes),
|
||||
("resource_files".into(), Value::Int(count.to_string())),
|
||||
]);
|
||||
write_json(
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! `item::Slot` and prints, per stack, whether every byte was consumed, the
|
||||
//! patch's component ids (added, then removed), which of them Horizon
|
||||
//! decoded into its own lossy types (the rest it reads past keeping their
|
||||
//! bytes, or decodes into ids it writes back unchanged, like trims) and
|
||||
//! whether encoding the decoded stack gives the same bytes back.
|
||||
//! bytes, or decodes into ids it writes back unchanged, like trims and stew
|
||||
//! effects) and whether encoding the decoded stack gives the same bytes back.
|
||||
//!
|
||||
//! cargo run -p horizon-protocol --example item_wire_probe -- vanilla.jsonl rust.jsonl
|
||||
use horizon_protocol::{
|
||||
@@ -46,7 +46,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let typed: Vec<i32> = stack
|
||||
.iter()
|
||||
.flat_map(|s| &s.added)
|
||||
.filter(|(_, c)| !matches!(c, Component::Raw(_) | Component::Trim { .. }))
|
||||
.filter(|(_, c)| {
|
||||
!matches!(
|
||||
c,
|
||||
Component::Raw(_) | Component::Trim { .. } | Component::StewEffects(_)
|
||||
)
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
json!({
|
||||
|
||||
@@ -39,6 +39,7 @@ pub mod component {
|
||||
pub const CHARGED_PROJECTILES: i32 = 51;
|
||||
pub const BUNDLE_CONTENTS: i32 = 52;
|
||||
pub const POTION_CONTENTS: i32 = 53;
|
||||
pub const SUSPICIOUS_STEW_EFFECTS: i32 = 55;
|
||||
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
|
||||
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
|
||||
pub const TRIM: i32 = 58;
|
||||
@@ -114,6 +115,9 @@ pub enum Component {
|
||||
material: i32,
|
||||
pattern: i32,
|
||||
},
|
||||
/// `SuspiciousStewEffects`: each entry's `minecraft:mob_effect` id and
|
||||
/// duration in ticks.
|
||||
StewEffects(Vec<(i32, i32)>),
|
||||
/// `BannerPatternLayers`: each layer's `banner_pattern` registry id and
|
||||
/// its dye id (`DyeColor.getId`). A layer with a direct pattern has no
|
||||
/// registry name: that value stays [`Component::Raw`].
|
||||
@@ -220,6 +224,16 @@ impl Component {
|
||||
let n = r.count(256)?;
|
||||
Self::Lore((0..n).map(|_| r.nbt()).collect::<Result<_>>()?)
|
||||
}
|
||||
// SuspiciousStewEffects.Entry.STREAM_CODEC: MobEffect.STREAM_CODEC
|
||||
// (the registry id), then the duration, both varints.
|
||||
SUSPICIOUS_STEW_EFFECTS => {
|
||||
let n = r.count(r.remaining())?;
|
||||
Self::StewEffects(
|
||||
(0..n)
|
||||
.map(|_| Ok((r.varint()?, r.varint()?)))
|
||||
.collect::<Result<_>>()?,
|
||||
)
|
||||
}
|
||||
ENCHANTMENTS | STORED_ENCHANTMENTS => {
|
||||
let n = r.count(r.remaining())?;
|
||||
Self::Enchantments(
|
||||
@@ -415,7 +429,7 @@ impl Component {
|
||||
w.varint(lines.len() as i32);
|
||||
lines.iter().for_each(|l| w.nbt(l));
|
||||
}
|
||||
Self::Enchantments(list) => {
|
||||
Self::Enchantments(list) | Self::StewEffects(list) => {
|
||||
w.varint(list.len() as i32);
|
||||
for (e, level) in list {
|
||||
w.varint(*e);
|
||||
@@ -968,6 +982,8 @@ mod tests {
|
||||
pattern: 1,
|
||||
}
|
||||
}
|
||||
// night_vision for 100 ticks, poison for the default 160.
|
||||
55 => *c == Component::StewEffects(vec![(15, 100), (18, 160)]),
|
||||
_ => matches!(c, Component::Raw(_)),
|
||||
};
|
||||
assert!(
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! registries report. Data-driven registries (dimension types, biomes, chat
|
||||
//! types, damage types ...) are numbered in the order the server sends them
|
||||
//! during configuration.
|
||||
use crate::{Error, Result, packets::configuration::RegistryData};
|
||||
use crate::{
|
||||
Error, Result,
|
||||
packets::configuration::{RegistryData, UpdateTags},
|
||||
};
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
@@ -89,8 +92,24 @@ impl Builtin {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Dynamic {
|
||||
pub registries: HashMap<String, Vec<(String, Option<Tag>)>>,
|
||||
/// The `minecraft:dialog` tags the server sent: each tag's entries by id.
|
||||
pub dialog_tags: HashMap<String, Vec<i32>>,
|
||||
}
|
||||
impl Dynamic {
|
||||
/// The dialog tags of a `ClientboundUpdateTagsPacket`.
|
||||
pub fn add_tags(&mut self, packet: &UpdateTags) {
|
||||
for r in packet
|
||||
.registries
|
||||
.iter()
|
||||
.filter(|r| r.registry == "minecraft:dialog")
|
||||
{
|
||||
self.dialog_tags = r
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| (t.name.clone(), t.ids.clone()))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
pub fn add(&mut self, packet: RegistryData) {
|
||||
let entries = packet.entries.into_iter().map(|e| (e.id, e.data)).collect();
|
||||
self.registries.insert(packet.registry, entries);
|
||||
|
||||
@@ -10,7 +10,7 @@ description = "Scheduling hints for the game's threads: the OS calls the crates
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_System_Threading", "Win32_System_WindowsProgramming"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,195 @@
|
||||
//! The game window's boot screen before wgpu's first frame: the renderer's
|
||||
//! device and pipelines take seconds to build, and winit's window sits white
|
||||
//! until a frame is presented. The game's boot design (`ui_theme::draw_boot`:
|
||||
//! pure black, the white wordmark, the console's last lines at the bottom
|
||||
//! left, wrapping word-wise), painted with GDI and replaced by the wgpu frames
|
||||
//! when the renderer starts drawing.
|
||||
|
||||
use std::sync::Once;
|
||||
|
||||
use windows_sys::Win32::Foundation::{COLORREF, HWND, RECT};
|
||||
use windows_sys::Win32::Graphics::Gdi::{
|
||||
AddFontMemResourceEx, CLEARTYPE_QUALITY, CLIP_DEFAULT_PRECIS, CreateFontW, CreateSolidBrush,
|
||||
DEFAULT_CHARSET, DT_CALCRECT, DT_CENTER, DT_LEFT, DT_NOCLIP, DT_SINGLELINE, DT_VCENTER,
|
||||
DT_WORDBREAK, DeleteObject, DrawTextW, FF_DONTCARE, FillRect, GetDC, OUT_DEFAULT_PRECIS,
|
||||
ReleaseDC, SelectObject, SetBkMode, SetTextColor, TRANSPARENT,
|
||||
};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::GetClientRect;
|
||||
|
||||
/// The wordmark on the pure black.
|
||||
const WORDMARK: COLORREF = 0x00ff_ffff;
|
||||
/// The console's lines, `0x9BA1A6` in GDI's `0x00bbggrr`.
|
||||
const CONSOLE: COLORREF = 0x00a6_a19b;
|
||||
|
||||
/// GDI's font table is global to the process: the bundled faces register once
|
||||
/// across every paint.
|
||||
static FACES: Once = Once::new();
|
||||
|
||||
/// Paints the client area of the window at `hwnd` black, the wordmark centred
|
||||
/// and the console's last lines at the bottom left — the same look as the
|
||||
/// game's `ui_theme::draw_boot`, which takes over when the renderer presents
|
||||
/// its first frame.
|
||||
///
|
||||
/// `semibold` and `mono` are the faces' TTF bytes (`Geist SemiBold`,
|
||||
/// `Geist Mono`), which must outlive the process: `AddFontMemResourceEx`
|
||||
/// keeps referencing them. Repainting is safe and cheap (a background thread
|
||||
/// may call this as the console's lines arrive).
|
||||
pub fn paint(hwnd: isize, semibold: &[u8], mono: &[u8], logs: &[String]) {
|
||||
let hwnd = hwnd as HWND;
|
||||
FACES.call_once(|| {
|
||||
for font in [semibold, mono] {
|
||||
let count = 0;
|
||||
unsafe {
|
||||
AddFontMemResourceEx(
|
||||
font.as_ptr().cast(),
|
||||
font.len() as u32,
|
||||
std::ptr::null(),
|
||||
&count,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
unsafe {
|
||||
let hdc = GetDC(hwnd);
|
||||
if hdc.is_null() {
|
||||
return;
|
||||
}
|
||||
let mut rect = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
};
|
||||
GetClientRect(hwnd, &mut rect);
|
||||
let (w, h) = (
|
||||
(rect.right - rect.left) as f32,
|
||||
(rect.bottom - rect.top) as f32,
|
||||
);
|
||||
// The pure black clear.
|
||||
let brush = CreateSolidBrush(0);
|
||||
FillRect(hdc, &rect, brush);
|
||||
let _ = DeleteObject(brush);
|
||||
// The type scale of the game's `Ui::new`: physical pixels per dp,
|
||||
// then the sizes in dp (the wordmark at `DISPLAY`, the console at
|
||||
// `MICRO` with 1.5 line heights).
|
||||
let density = (w / 1040.0).min(h / 700.0).clamp(1.0, 4.0);
|
||||
let size = ((w / density / 16.0).clamp(40.0, 96.0) * density).round();
|
||||
let line = 11.0 * density * 1.5;
|
||||
let pad = 16.0 * density;
|
||||
let face = |px: i32, weight: i32, name: &str| {
|
||||
let name: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
CreateFontW(
|
||||
-px,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
weight,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
DEFAULT_CHARSET as u32,
|
||||
OUT_DEFAULT_PRECIS as u32,
|
||||
CLIP_DEFAULT_PRECIS as u32,
|
||||
CLEARTYPE_QUALITY as u32,
|
||||
FF_DONTCARE as u32,
|
||||
name.as_ptr(),
|
||||
)
|
||||
};
|
||||
// The wordmark with its tagline, centred.
|
||||
let small = (size * 0.25).clamp(11.0 * density, 18.0 * density);
|
||||
let block = size * 1.45 + small * 0.6 + small * 1.5;
|
||||
let top = (h - block) / 2.0;
|
||||
let mark = face(-(size as i32), 600, "Geist SemiBold");
|
||||
let old_mark = SelectObject(hdc, mark);
|
||||
SetBkMode(hdc, TRANSPARENT as i32);
|
||||
SetTextColor(hdc, WORDMARK);
|
||||
let text: Vec<u16> = "Horizon".encode_utf16().collect();
|
||||
let mut mark_rect = RECT {
|
||||
left: 0,
|
||||
top: top as i32,
|
||||
right: w as i32,
|
||||
bottom: (top + size * 1.45) as i32,
|
||||
};
|
||||
DrawTextW(
|
||||
hdc,
|
||||
text.as_ptr(),
|
||||
text.len() as i32,
|
||||
&mut mark_rect,
|
||||
DT_CENTER | DT_SINGLELINE | DT_NOCLIP | DT_VCENTER,
|
||||
);
|
||||
let tagline = face(-(small as i32), 400, "Geist Mono");
|
||||
let old_tagline = SelectObject(hdc, tagline);
|
||||
SetTextColor(hdc, CONSOLE);
|
||||
let tagline_text: Vec<u16> = "Rust based minecraft client".encode_utf16().collect();
|
||||
let tagline_top = top + size * 1.45 + small * 0.6;
|
||||
let mut tagline_rect = RECT {
|
||||
left: 0,
|
||||
top: tagline_top as i32,
|
||||
right: w as i32,
|
||||
bottom: (tagline_top + small * 1.5) as i32,
|
||||
};
|
||||
DrawTextW(
|
||||
hdc,
|
||||
tagline_text.as_ptr(),
|
||||
tagline_text.len() as i32,
|
||||
&mut tagline_rect,
|
||||
DT_CENTER | DT_SINGLELINE | DT_NOCLIP | DT_VCENTER,
|
||||
);
|
||||
// The console's last lines at the bottom left, the newest at the
|
||||
// bottom: long lines wrap word-wise and the block grows upwards, as
|
||||
// the game's `draw_boot_log` draws it (capped at 24 sub-lines).
|
||||
let console = face(-(line as i32), 400, "Geist Mono");
|
||||
let old_console = SelectObject(hdc, console);
|
||||
SetTextColor(hdc, CONSOLE);
|
||||
let max_rows = (((h - pad) / line) as usize).clamp(1, 24);
|
||||
let mut y = h - pad;
|
||||
let mut drawn = 0;
|
||||
'lines: for text in logs.iter().rev() {
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let wide: Vec<u16> = text.encode_utf16().collect();
|
||||
// The wrapped height, measured without drawing (DT_CALCRECT);
|
||||
// every row is the face's cell height, `line`.
|
||||
let mut rect = RECT {
|
||||
left: pad as i32,
|
||||
top: 0,
|
||||
right: (w - pad) as i32,
|
||||
bottom: 0,
|
||||
};
|
||||
DrawTextW(
|
||||
hdc,
|
||||
wide.as_ptr(),
|
||||
wide.len() as i32,
|
||||
&mut rect,
|
||||
DT_LEFT | DT_WORDBREAK | DT_CALCRECT,
|
||||
);
|
||||
let height = rect.bottom.max(line as i32);
|
||||
let rows = ((height as f32 / line).ceil() as usize).max(1);
|
||||
// The block may only grow past the top before anything is drawn
|
||||
// (a newest line wider than the whole screen on a tiny window);
|
||||
// once something is there, older lines stop.
|
||||
if drawn + rows > max_rows && drawn > 0 {
|
||||
break 'lines;
|
||||
}
|
||||
rect.top = ((y - height as f32).max(0.0)) as i32;
|
||||
rect.bottom = y as i32;
|
||||
DrawTextW(
|
||||
hdc,
|
||||
wide.as_ptr(),
|
||||
wide.len() as i32,
|
||||
&mut rect,
|
||||
DT_LEFT | DT_WORDBREAK,
|
||||
);
|
||||
y -= height as f32;
|
||||
drawn += rows;
|
||||
}
|
||||
SelectObject(hdc, old_mark);
|
||||
SelectObject(hdc, old_tagline);
|
||||
SelectObject(hdc, old_console);
|
||||
let _ = DeleteObject(mark);
|
||||
let _ = DeleteObject(tagline);
|
||||
let _ = DeleteObject(console);
|
||||
let _ = ReleaseDC(hwnd, hdc);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Scheduling hints for the game's threads: the few OS calls that the other
|
||||
//! crates, which forbid unsafe code, can't make themselves.
|
||||
|
||||
/// The game window's boot screen, before wgpu's first frame paints over it.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod boot_paint;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Ask the OS to run the calling thread ahead of the process's other
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user