mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
commit
938f0323ac
444 files changed
+36279
-5414
No files matched your search
Generated
+11
@@ -1334,6 +1334,9 @@ dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
@@ -1506,6 +1509,13 @@ dependencies = [
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-port-pack"
|
||||
version = "0.1.0"
|
||||
@@ -1609,6 +1619,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"naga",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@@ -30,6 +30,7 @@ members = [
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"xtask",
|
||||
|
||||
@@ -6,5 +6,5 @@ authors = []
|
||||
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
# registries: "May add blocks, items and mob effects to your worlds".
|
||||
# registries: "May add blocks, items, mob effects and entity types to your worlds".
|
||||
capabilities = ["registries"]
|
||||
@@ -39,6 +39,11 @@ pub enum Change {
|
||||
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
|
||||
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
|
||||
Attributes { sprint: Option<bool> },
|
||||
/// ClientboundSetEntityDataPacket for the player itself with its shared flags: their sprinting
|
||||
/// bit, which the client takes as sent (SynchedEntityData.assignValues sets the flag alone, its
|
||||
/// speed modifier and wasSprinting stay). A client whose sprint the server cleared this way
|
||||
/// starts it again in its next aiStep, which adds the modifier back, and says nothing.
|
||||
Sprinting(bool),
|
||||
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
|
||||
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
|
||||
/// are the server's: they reach the client as attributes.)
|
||||
|
||||
@@ -81,6 +81,19 @@ impl InteractionInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sentence of `Minecraft.handleKeybinds` that follows a key which opened
|
||||
/// a screen: `Gui.setScreen` has already run `KeyMapping.releaseAll()`, so
|
||||
/// `key.use` reads up and the same call releases an active use
|
||||
/// (`player.isUsingItem() && !keyUse.isDown()` → `MultiPlayerGameMode.releaseUsingItem`).
|
||||
/// Only the screens opened from the keybinds do this (the inventory, chat and
|
||||
/// advancements); the pause menu, server containers, mount inventories and
|
||||
/// dialogs release on the first tick after closing instead, as vanilla does.
|
||||
pub fn release_on_screen_open(world: &mut impl InteractionWorld) {
|
||||
if world.using_item() {
|
||||
world.release_using();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -96,6 +96,11 @@ pub struct RenderAssets {
|
||||
pub translucent_layers: Vec<bool>,
|
||||
/// Each layer's `MipmapGenerator` settings.
|
||||
pub sprite_mips: Vec<crate::mipmap::Settings>,
|
||||
/// The resource packs' LabPBR maps (normals, specular), layer by layer at `pbr_size`:
|
||||
/// empty when they ship none.
|
||||
pub pbr_normals: Vec<u8>,
|
||||
pub pbr_specular: Vec<u8>,
|
||||
pub pbr_size: u32,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -259,6 +264,9 @@ impl RenderAssets {
|
||||
sprite_layers: sprites.layers,
|
||||
translucent_layers: sprites.translucent,
|
||||
sprite_mips: sprites.mips,
|
||||
pbr_normals: sprites.normals,
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1226,6 +1226,17 @@ impl Audio {
|
||||
self.play(event, Some(at), volume, pitch);
|
||||
return;
|
||||
}
|
||||
self.play_after(
|
||||
event,
|
||||
at,
|
||||
volume,
|
||||
pitch,
|
||||
(distance_sqr.sqrt() / 40.0 * 20.0) as i32,
|
||||
);
|
||||
}
|
||||
/// `play` at a world position, starting `ticks` sound-engine ticks (20 a
|
||||
/// second) from now: `SoundEngine.playDelayed`.
|
||||
pub fn play_after(&mut self, event: &str, at: [f64; 3], volume: f32, pitch: f32, ticks: i32) {
|
||||
let seed = self.random.next_long();
|
||||
if let Some(capture) = self.capture.as_mut() {
|
||||
capture.push((event.to_owned(), at, volume, pitch));
|
||||
@@ -1239,8 +1250,7 @@ impl Audio {
|
||||
let instance =
|
||||
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
|
||||
let id = self.engine.add(instance, false);
|
||||
self.engine
|
||||
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
|
||||
self.engine.play_delayed(id, ticks);
|
||||
}
|
||||
/// `play` for this client only: a sound a packet's handler plays
|
||||
/// (`playLocalSound`), which the guests play from that packet themselves.
|
||||
|
||||
@@ -885,25 +885,26 @@ impl BlockEntityRenderer {
|
||||
let mut renderer = Self::new(models, registry);
|
||||
renderer.visibility = Some(Mutex::new(visibility));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
|
||||
let items: Value = serde_json::from_slice(&bytes)?;
|
||||
for (item, components) in items.as_object().into_iter().flatten() {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
|
||||
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
|
||||
let Ok(def) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let def: Value = serde_json::from_slice(&def)?;
|
||||
if let Some(asset) = def["asset_id"].as_str() {
|
||||
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
|
||||
renderer
|
||||
.pot_patterns
|
||||
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
|
||||
}
|
||||
let items = match horizon_world::item_defaults::read(root) {
|
||||
Ok(items) => items,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
for (item, components) in &items {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
|
||||
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
|
||||
let Ok(def) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let def: Value = serde_json::from_slice(&def)?;
|
||||
if let Some(asset) = def["asset_id"].as_str() {
|
||||
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
|
||||
renderer
|
||||
.pot_patterns
|
||||
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
|
||||
}
|
||||
}
|
||||
Ok(renderer)
|
||||
|
||||
@@ -160,6 +160,18 @@ pub struct Brewing {
|
||||
}
|
||||
impl Brewing {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
|
||||
// NeoForge's load conditions: a file whose don't hold is silently
|
||||
// absent. Vanilla's brewing recipes carry none; a pack's can.
|
||||
let mods: Vec<String> = {
|
||||
let mut mods = horizon_items::content_namespaces::mod_ids(root);
|
||||
if !mods.iter().any(|m| m == "minecraft") {
|
||||
mods.push("minecraft".to_owned());
|
||||
}
|
||||
mods
|
||||
};
|
||||
let conditions_hold = |v: &Value| {
|
||||
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
|
||||
};
|
||||
let mut sources: Vec<(String, Value)> =
|
||||
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
@@ -191,6 +203,9 @@ impl Brewing {
|
||||
None => sources.push((id.to_owned(), v)),
|
||||
}
|
||||
}
|
||||
// The gate answers the merged file, as the crafting loader's does:
|
||||
// a false-conditioned pack file leaves the lower one gone too.
|
||||
sources.retain(|(_, v)| conditions_hold(v));
|
||||
sources.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut recipes = Vec::new();
|
||||
for (id, v) in sources {
|
||||
|
||||
@@ -233,6 +233,9 @@ pub struct SkullSkin {
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<NameOrId>,
|
||||
/// `PlayerSkin.Patch.body`: the profile's `texture`, a resource pack
|
||||
/// texture drawn instead of any skin.
|
||||
pub texture: Option<String>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
|
||||
|
||||
@@ -9,6 +9,59 @@ pub use crate::chunk::block_entity::{
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// a content mod's menu type (`data/<id>/horizon/menu/`, vanilla's
|
||||
/// `MenuType`): its container slots, its data slots (`ContainerData`) and the
|
||||
/// slots players only take from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ModMenuType {
|
||||
pub id: &'static str,
|
||||
pub slots: usize,
|
||||
pub data_slots: usize,
|
||||
pub results: Vec<usize>,
|
||||
}
|
||||
|
||||
static MOD_MENU_TYPES: std::sync::OnceLock<Vec<ModMenuType>> = std::sync::OnceLock::new();
|
||||
|
||||
/// registers the content mods' menu types, `(id, slots, data slots,
|
||||
/// results)`, a list set once per process (false when it already was).
|
||||
pub fn register_mod_menu_types(
|
||||
menus: impl IntoIterator<Item = (String, usize, usize, Vec<usize>)>,
|
||||
) -> bool {
|
||||
if MOD_MENU_TYPES.get().is_some() {
|
||||
return false;
|
||||
}
|
||||
let menus = menus
|
||||
.into_iter()
|
||||
.map(|(id, slots, data_slots, results)| ModMenuType {
|
||||
id: Box::leak(id.into_boxed_str()),
|
||||
slots,
|
||||
data_slots,
|
||||
results,
|
||||
});
|
||||
MOD_MENU_TYPES.set(menus.collect()).is_ok()
|
||||
}
|
||||
|
||||
/// the content mods' menu types, in registry order.
|
||||
pub fn mod_menu_types() -> &'static [ModMenuType] {
|
||||
MOD_MENU_TYPES.get().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// the mod menu type named `id`, kept out of line: a vanilla menu never
|
||||
/// looks one up.
|
||||
#[cold]
|
||||
#[inline(never)]
|
||||
pub fn mod_menu_type(id: &str) -> Option<&'static ModMenuType> {
|
||||
mod_menu_types().iter().find(|m| m.id == id)
|
||||
}
|
||||
|
||||
/// whether a client of `brand` can be sent menu type `menu`: a vanilla
|
||||
/// client reads the type through `ByteBufCodecs.registry(Registries.MENU)`,
|
||||
/// and an id it lacks (`IdMap.byIdOrThrow`) disconnects it, so it is sent
|
||||
/// vanilla's only. A Horizon client has the server's (the content identity).
|
||||
pub fn menu_reaches(brand: &str, menu: &str) -> bool {
|
||||
brand == "horizon" || menu.starts_with("minecraft:")
|
||||
}
|
||||
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
match self {
|
||||
@@ -196,6 +249,7 @@ impl SkullSkin {
|
||||
Self {
|
||||
download,
|
||||
dynamic,
|
||||
texture: crate::skins::profile_texture_nbt(profile),
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
@@ -610,6 +664,15 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// a client that isn't Horizon's is sent vanilla's menu types only.
|
||||
#[test]
|
||||
fn a_mod_menu_type_reaches_horizon_clients_only() {
|
||||
assert!(menu_reaches("horizon", "ruby:press"));
|
||||
assert!(!menu_reaches("vanilla", "ruby:press"));
|
||||
assert!(!menu_reaches("fabric", "ruby:press"));
|
||||
assert!(menu_reaches("vanilla", "minecraft:generic_9x3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
|
||||
@@ -195,6 +195,14 @@ fn read(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
fn windows_processor_name(query: &str) -> Option<String> {
|
||||
query.lines().find_map(|line| {
|
||||
let (_, value) = line.split_once("REG_SZ")?;
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
|
||||
fn operating_system() -> String {
|
||||
let arch = std::env::consts::ARCH;
|
||||
@@ -235,6 +243,18 @@ pub fn cpu() -> String {
|
||||
})
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else if cfg!(windows) {
|
||||
command_output(
|
||||
"reg",
|
||||
&[
|
||||
"query",
|
||||
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
|
||||
"/v",
|
||||
"ProcessorNameString",
|
||||
],
|
||||
)
|
||||
.and_then(|query| windows_processor_name(&query))
|
||||
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
|
||||
} else {
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
};
|
||||
@@ -636,6 +656,22 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
|
||||
let query = r#"
|
||||
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
|
||||
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
|
||||
"#;
|
||||
assert_eq!(
|
||||
windows_processor_name(query).as_deref(),
|
||||
Some("13th Gen Intel(R) Core(TM) i7-13700F")
|
||||
);
|
||||
assert_eq!(
|
||||
windows_processor_name("ProcessorNameString REG_SZ "),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_reports_its_built_in_version() {
|
||||
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
|
||||
|
||||
@@ -320,6 +320,13 @@ fn shadow_color(in: Out) -> vec4<f32> {
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
|
||||
// vertex colour, with fog fading both RGB and alpha before additive blending.
|
||||
if in.overlay == 37u {
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return in.color * (1.0 - fog);
|
||||
}
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
|
||||
@@ -113,6 +113,8 @@ pub struct Extras {
|
||||
pub agent_control: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
/// Another controller can join P1's world (`splitscreen`, off by default).
|
||||
pub splitscreen: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -146,6 +148,7 @@ impl Default for Extras {
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +224,7 @@ impl Extras {
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
"splitscreen" => e.splitscreen = flag(e.splitscreen),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -228,7 +232,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -263,6 +267,7 @@ impl Extras {
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.discord_presence),
|
||||
bool_text(self.splitscreen),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
@@ -393,6 +398,8 @@ pub enum Item {
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -426,6 +433,7 @@ impl Item {
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -435,7 +443,7 @@ impl Item {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
| AgentControl | DiscordPresence | Splitscreen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -471,6 +479,9 @@ impl Item {
|
||||
Item::DiscordPresence => {
|
||||
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
|
||||
}
|
||||
Item::Splitscreen => {
|
||||
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
@@ -482,9 +493,11 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
|
||||
Some(Cost::None)
|
||||
}
|
||||
Item::OwnNameTag
|
||||
| Item::AutoReconnect
|
||||
| Item::MouseTweaks
|
||||
| Item::Sort
|
||||
| Item::Splitscreen => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -501,6 +514,7 @@ impl Item {
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -528,6 +542,7 @@ impl Item {
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
@@ -580,6 +595,9 @@ mod tests {
|
||||
e.sort = true;
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +1,3 @@
|
||||
//! A fast, non-cryptographic hasher (the Fx hash used by rustc) for hot maps
|
||||
//! keyed by small integers, such as chunk coordinates. Iteration order of
|
||||
//! such maps carries no meaning: the default hasher's is random anyway.
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
|
||||
const SEED: u64 = 0xf135_7aea_2e62_a9c5;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct FxHasher {
|
||||
hash: u64,
|
||||
}
|
||||
impl FxHasher {
|
||||
#[inline]
|
||||
fn add(&mut self, word: u64) {
|
||||
self.hash = self.hash.wrapping_add(word).wrapping_mul(SEED);
|
||||
}
|
||||
}
|
||||
impl Hasher for FxHasher {
|
||||
#[inline]
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
let (words, rest) = bytes.as_chunks::<8>();
|
||||
for word in words {
|
||||
self.add(u64::from_le_bytes(*word));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
let mut word = [0u8; 8];
|
||||
word[..rest.len()].copy_from_slice(rest);
|
||||
self.add(u64::from_le_bytes(word));
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn write_u8(&mut self, i: u8) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u16(&mut self, i: u16) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u32(&mut self, i: u32) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u64(&mut self, i: u64) {
|
||||
self.add(i);
|
||||
}
|
||||
#[inline]
|
||||
fn write_usize(&mut self, i: usize) {
|
||||
self.add(i as u64);
|
||||
}
|
||||
#[inline]
|
||||
fn write_i32(&mut self, i: i32) {
|
||||
self.add(u64::from(i as u32));
|
||||
}
|
||||
#[inline]
|
||||
fn finish(&self) -> u64 {
|
||||
// The multiply leaves the low bits weakest; hash tables use them.
|
||||
self.hash.rotate_left(26)
|
||||
}
|
||||
}
|
||||
pub type FxBuild = BuildHasherDefault<FxHasher>;
|
||||
pub type FxHashMap<K, V> = std::collections::HashMap<K, V, FxBuild>;
|
||||
pub type FxHashSet<K> = std::collections::HashSet<K, FxBuild>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nearby_chunks_spread_over_buckets() {
|
||||
let mut map: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
for x in -40..40 {
|
||||
for z in -40..40 {
|
||||
map.insert((x, z), x * z);
|
||||
}
|
||||
}
|
||||
assert_eq!(map.len(), 6400);
|
||||
assert_eq!(map[&(-7, 13)], -91);
|
||||
let mut low = std::collections::HashSet::new();
|
||||
for x in 0..64 {
|
||||
low.insert(std::hash::BuildHasher::hash_one(&FxBuild::default(), (x, 0)) & 63);
|
||||
}
|
||||
assert!(low.len() > 32, "low bits vary: {}", low.len());
|
||||
}
|
||||
}
|
||||
//! The Fx hasher for hot maps keyed by small integers: kept in horizon-core
|
||||
//! (the scheduled ticks use it too), and reachable here under its old path.
|
||||
pub use horizon_core::fast_hash::*;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -76,8 +76,22 @@ struct Page {
|
||||
/// Capacity in vertices.
|
||||
capacity: u64,
|
||||
free: FreeList,
|
||||
/// The buffer went to system memory (`Arena::create`'s fallback): meshes
|
||||
/// are never moved into it, and it is the first to empty.
|
||||
host: bool,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
/// Vertices in the page's free ranges.
|
||||
fn free_vertices(&self) -> u64 {
|
||||
self.free.0.values().sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// An arena with this many bytes in free ranges, at least half of its pages,
|
||||
/// has a page worth emptying (`Arena::sparsest`).
|
||||
const COMPACT_MIN_FREE: u64 = 128 << 20;
|
||||
|
||||
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
|
||||
/// buffer sizes): a section's vertices sit in one page.
|
||||
pub struct Arena {
|
||||
@@ -86,6 +100,8 @@ pub struct Arena {
|
||||
page_limit: u64,
|
||||
initial_capacity: u64,
|
||||
usage: wgpu::BufferUsages,
|
||||
/// A page being emptied (`Arena::close`): nothing is placed in it.
|
||||
closing: Option<u32>,
|
||||
/// A page had to go to system memory (logged once).
|
||||
spilled: bool,
|
||||
/// Tests: GPU-local pages fail as if video memory were full.
|
||||
@@ -129,6 +145,7 @@ impl Arena {
|
||||
page_limit,
|
||||
initial_capacity: capacity,
|
||||
usage,
|
||||
closing: None,
|
||||
spilled: false,
|
||||
#[cfg(test)]
|
||||
video_memory_full: false,
|
||||
@@ -138,7 +155,7 @@ impl Arena {
|
||||
/// and the device allows it, in system memory: Vulkan drivers fail an
|
||||
/// allocation rather than page like OpenGL ones, and wgpu's default error
|
||||
/// handler panics. `None` when neither has room.
|
||||
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
|
||||
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<(wgpu::Buffer, bool)> {
|
||||
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
|
||||
#[cfg(test)]
|
||||
let local = (!self.video_memory_full)
|
||||
@@ -146,6 +163,7 @@ impl Arena {
|
||||
.flatten();
|
||||
#[cfg(not(test))]
|
||||
let local = try_create(device, capacity * self.stride, usage);
|
||||
let local = local.map(|buffer| (buffer, false));
|
||||
local.or_else(|| {
|
||||
// Mappable buffers are placed in host-visible memory, which the
|
||||
// allocator takes from system memory once video memory is full.
|
||||
@@ -166,7 +184,7 @@ impl Arena {
|
||||
(lower the render distance if frames slow down)"
|
||||
);
|
||||
}
|
||||
Some(buffer)
|
||||
Some((buffer, true))
|
||||
})
|
||||
}
|
||||
/// The rounded length a request of `vertices` occupies.
|
||||
@@ -207,16 +225,13 @@ impl Arena {
|
||||
vertices: u64,
|
||||
) -> Option<(u32, u64)> {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
for (i, page) in self.pages.iter_mut().enumerate() {
|
||||
if let Some(page) = page
|
||||
&& let Some(start) = page.free.take(len)
|
||||
{
|
||||
return Some((i as u32, start));
|
||||
}
|
||||
if let Some(found) = self.alloc_existing(len) {
|
||||
return Some(found);
|
||||
}
|
||||
let last = self.pages.len().saturating_sub(1);
|
||||
if let Some(page) = self.pages.last().and_then(Option::as_ref)
|
||||
&& page.capacity < self.page_limit
|
||||
&& self.closing != Some(last as u32)
|
||||
{
|
||||
let current = page.capacity;
|
||||
let grown = (current * 2).max(current + len).min(self.page_limit);
|
||||
@@ -235,7 +250,7 @@ impl Arena {
|
||||
.max(len)
|
||||
.min(self.page_limit);
|
||||
// A page just large enough when memory is short.
|
||||
let (buffer, capacity) = match self.create(device, capacity) {
|
||||
let ((buffer, host), capacity) = match self.create(device, capacity) {
|
||||
Some(buffer) => (buffer, capacity),
|
||||
None if capacity > len => (self.create(device, len)?, len),
|
||||
None => return None,
|
||||
@@ -246,6 +261,7 @@ impl Arena {
|
||||
buffer,
|
||||
capacity,
|
||||
free,
|
||||
host,
|
||||
};
|
||||
let index = self
|
||||
.pages
|
||||
@@ -258,6 +274,96 @@ impl Arena {
|
||||
self.pages[index] = Some(page);
|
||||
Some((index as u32, start))
|
||||
}
|
||||
/// A range of `vertices` in a page that exists already and is not being
|
||||
/// emptied: never grows the arena.
|
||||
pub fn alloc_existing(&mut self, vertices: u64) -> Option<(u32, u64)> {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
let closing = self.closing;
|
||||
self.pages
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| closing != Some(*i as u32))
|
||||
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
|
||||
}
|
||||
/// `alloc_existing` for a mesh moved off the closing page: only pages in
|
||||
/// video memory, so compaction never pushes terrain into system memory.
|
||||
pub fn alloc_moved(&mut self, vertices: u64) -> Option<(u32, u64)> {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
let closing = self.closing;
|
||||
self.pages
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.filter(|(i, p)| closing != Some(*i as u32) && p.as_ref().is_some_and(|p| !p.host))
|
||||
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
|
||||
}
|
||||
/// The page to empty when much of the arena is free ranges (video memory
|
||||
/// that fragmentation keeps: a page goes back only once all of it is free):
|
||||
/// the one with the fewest vertices in use that the other pages' free
|
||||
/// ranges can take. `None` when the arena is dense enough or already
|
||||
/// emptying a page.
|
||||
pub fn sparsest(&self) -> Option<u32> {
|
||||
self.sparsest_from(COMPACT_MIN_FREE)
|
||||
}
|
||||
/// `sparsest` for an arena with at least `min_free` bytes in free ranges.
|
||||
fn sparsest_from(&self, min_free: u64) -> Option<u32> {
|
||||
if self.closing.is_some() {
|
||||
return None;
|
||||
}
|
||||
let pages = || {
|
||||
self.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, p)| Some((i, p.as_ref()?)))
|
||||
};
|
||||
let free: u64 = pages().map(|(_, p)| p.free_vertices()).sum();
|
||||
let capacity: u64 = pages().map(|(_, p)| p.capacity).sum();
|
||||
if free * self.stride < min_free || free * 2 < capacity {
|
||||
return None;
|
||||
}
|
||||
// Moves only go to video-memory pages (`alloc_moved`).
|
||||
let local_free: u64 = pages()
|
||||
.filter(|(_, p)| !p.host)
|
||||
.map(|(_, p)| p.free_vertices())
|
||||
.sum();
|
||||
pages()
|
||||
.filter_map(|(i, p)| {
|
||||
let used = p.capacity - p.free_vertices();
|
||||
let own = if p.host { 0 } else { p.free_vertices() };
|
||||
// The room elsewhere, less a quarter for first fit's gaps.
|
||||
let elsewhere = (local_free - own) / 4 * 3;
|
||||
// A page in system memory is emptied first.
|
||||
(used > 0 && used <= elsewhere).then_some((!p.host, used, i as u32))
|
||||
})
|
||||
.min()
|
||||
.map(|(_, _, i)| i)
|
||||
}
|
||||
/// Stops placing vertices in `page` until `reopen`.
|
||||
pub fn close(&mut self, page: u32) {
|
||||
self.closing = Some(page);
|
||||
}
|
||||
pub fn reopen(&mut self) {
|
||||
self.closing = None;
|
||||
}
|
||||
pub fn closing(&self) -> Option<u32> {
|
||||
self.closing
|
||||
}
|
||||
/// Records the copy of `vertices` from one range to another of other pages.
|
||||
pub fn copy(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
from: (u32, u64),
|
||||
to: (u32, u64),
|
||||
vertices: u64,
|
||||
) {
|
||||
debug_assert_ne!(from.0, to.0);
|
||||
encoder.copy_buffer_to_buffer(
|
||||
self.buffer(from.0),
|
||||
from.1 * self.stride,
|
||||
self.buffer(to.0),
|
||||
to.1 * self.stride,
|
||||
vertices * self.stride,
|
||||
);
|
||||
}
|
||||
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
self.pages[page as usize]
|
||||
@@ -296,7 +402,7 @@ impl Arena {
|
||||
capacity: u64,
|
||||
) -> bool {
|
||||
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
|
||||
let Some(buffer) = self.create(device, capacity) else {
|
||||
let Some((buffer, host)) = self.create(device, capacity) else {
|
||||
return false;
|
||||
};
|
||||
let old = self.pages[page].as_mut().expect("live arena page");
|
||||
@@ -304,6 +410,7 @@ impl Arena {
|
||||
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
|
||||
let previous = old.capacity;
|
||||
old.buffer = buffer;
|
||||
old.host = host;
|
||||
old.capacity = capacity;
|
||||
old.free.give(previous, capacity - previous);
|
||||
true
|
||||
@@ -318,6 +425,9 @@ impl Arena {
|
||||
*page = None;
|
||||
}
|
||||
}
|
||||
if self.closing.is_some_and(|page| !self.has_page(page)) {
|
||||
self.closing = None;
|
||||
}
|
||||
// Keep live page indices stable; later allocations reuse interior holes.
|
||||
while self.pages.last().is_some_and(Option::is_none) {
|
||||
self.pages.pop();
|
||||
@@ -325,6 +435,7 @@ impl Arena {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
self.closing = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +518,76 @@ mod tests {
|
||||
assert_eq!(read(&device, &queue, arena.buffer(page), next * 4), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sparse_page_empties_into_the_others_and_is_reclaimed() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut arena = Arena::new(&device, 4, GRAIN, false);
|
||||
arena.page_limit = 4 * GRAIN;
|
||||
// Eight ranges fill two pages, each holding its own number.
|
||||
let ranges: Vec<(u32, u64)> = (0..8)
|
||||
.map(|i| {
|
||||
let range = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
queue.write_buffer(
|
||||
arena.buffer(range.0),
|
||||
range.1 * 4,
|
||||
&(100 + i as u32).to_le_bytes(),
|
||||
);
|
||||
range
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(arena.pages.iter().flatten().count(), 2);
|
||||
let (first, second) = (ranges[0].0, ranges[7].0);
|
||||
assert_ne!(first, second);
|
||||
// Fragmented: one range live in the first page, two in the second.
|
||||
for (i, &(page, start)) in ranges.iter().enumerate() {
|
||||
if ![0, 6, 7].contains(&i) {
|
||||
arena.release(page, start, GRAIN);
|
||||
}
|
||||
}
|
||||
// Dense enough arenas, and ones without room elsewhere, are left alone.
|
||||
assert_eq!(arena.sparsest_from(u64::MAX), None);
|
||||
assert_eq!(arena.sparsest_from(0), Some(first));
|
||||
arena.close(first);
|
||||
assert_eq!(
|
||||
arena.sparsest_from(0),
|
||||
None,
|
||||
"one page is emptied at a time"
|
||||
);
|
||||
// New ranges avoid the closing page though it has room.
|
||||
let (page, extra) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
assert_eq!(page, second);
|
||||
arena.release(page, extra, GRAIN);
|
||||
// Its live range moves, data included, and the page goes back.
|
||||
let (page, start) = ranges[0];
|
||||
let to = arena.alloc_existing(GRAIN).unwrap();
|
||||
assert_eq!(to.0, second);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
arena.copy(&mut encoder, (page, start), to, GRAIN);
|
||||
queue.submit([encoder.finish()]);
|
||||
arena.release(page, start, GRAIN);
|
||||
assert_eq!(read(&device, &queue, arena.buffer(to.0), to.1 * 4), 100);
|
||||
// Emptied but not trimmed yet: it stays closed, so uploads can't refill it.
|
||||
assert_eq!(arena.closing(), Some(page));
|
||||
let refill = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
assert_ne!(refill.0, page);
|
||||
arena.release(refill.0, refill.1, GRAIN);
|
||||
let before = arena.bytes();
|
||||
arena.trim();
|
||||
assert_eq!(arena.bytes(), before - 4 * GRAIN * 4);
|
||||
assert_eq!(arena.closing(), None);
|
||||
// The others kept their ranges.
|
||||
for i in [6, 7] {
|
||||
let (page, start) = ranges[i];
|
||||
assert_eq!(
|
||||
read(&device, &queue, arena.buffer(page), start * 4),
|
||||
100 + i as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
|
||||
let Some((device, queue)) = device() else {
|
||||
@@ -572,6 +753,9 @@ mod tests {
|
||||
queue.write_buffer(arena.buffer(grown), next * 4, &9u32.to_le_bytes());
|
||||
assert_eq!(read(&device, &queue, arena.buffer(page), start * 4), 7);
|
||||
assert_eq!(read(&device, &queue, arena.buffer(grown), next * 4), 9);
|
||||
// Compaction never moves terrain into a page in system memory.
|
||||
assert!(arena.pages[page as usize].as_ref().unwrap().host);
|
||||
assert_eq!(arena.alloc_moved(GRAIN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! The colour of block light for Horizon's shader packs (`[[lights]]`, docs/api/shaders.md):
|
||||
//! a grid of cells around the camera, each holding the colour of the light sources that
|
||||
//! reach it, averaged by how near each is, which the material stage reads through the
|
||||
//! prelude's `block_light_color`. Block light keeps its own brightness (the light engine's,
|
||||
//! which walls stop): the grid only tints it, so it needs no occlusion of its own.
|
||||
//!
|
||||
//! The grid is rebuilt on the CPU when the camera moves on to another 16-block step or the
|
||||
//! light sources change, from the sources the section meshes list (`SectionMesh::lights`).
|
||||
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use glam::{DVec3, Vec3};
|
||||
|
||||
use super::SectionKey;
|
||||
|
||||
/// Cells along each side of the grid.
|
||||
pub const SIZE: u32 = 64;
|
||||
/// The side of a cell, in blocks: the grid spans 128 blocks.
|
||||
pub const CELL: f32 = 2.0;
|
||||
/// The grid moves in steps of this many blocks.
|
||||
const STEP: i32 = 16;
|
||||
/// The most sources one rebuild splats, the nearest first (a lava lake lists thousands).
|
||||
const MAX_SOURCES: usize = 2048;
|
||||
/// `[[lights]]` colours go up to 4; the cells keep them over 4 in 8 bits.
|
||||
const COLOR_RANGE: f32 = 4.0;
|
||||
|
||||
/// The light a block state gives off: its `[[lights]]` colour and its level, 1 to 15.
|
||||
pub type LightTable = FxHashMap<u32, ([f32; 3], u8)>;
|
||||
|
||||
pub struct BlockLight {
|
||||
texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
/// `block_light_grid`: the grid's corner, camera-relative, and the side of a cell.
|
||||
pub grid: wgpu::Buffer,
|
||||
table: LightTable,
|
||||
/// The world corner and the sources' revision the texture was last built for.
|
||||
built: Option<([i32; 3], u64)>,
|
||||
}
|
||||
|
||||
impl BlockLight {
|
||||
pub fn new(device: &wgpu::Device, table: LightTable) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Native shader pack block light colours"),
|
||||
size: wgpu::Extent3d {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
depth_or_array_layers: SIZE,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D3,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let grid = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Native shader pack block light grid"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Self {
|
||||
view: texture.create_view(&Default::default()),
|
||||
texture,
|
||||
grid,
|
||||
table,
|
||||
built: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Places the grid around `camera`, rebuilding it when it moved or `revision` (of
|
||||
/// `sections`' sources) changed.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
queue: &wgpu::Queue,
|
||||
camera: DVec3,
|
||||
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
revision: u64,
|
||||
) {
|
||||
let half = (SIZE as f32 * CELL / 2.0) as f64;
|
||||
let origin = ((camera - half) / f64::from(STEP))
|
||||
.floor()
|
||||
.as_ivec3()
|
||||
.to_array()
|
||||
.map(|v| v * STEP);
|
||||
let corner = (glam::IVec3::from(origin).as_dvec3() - camera).as_vec3();
|
||||
queue.write_buffer(
|
||||
&self.grid,
|
||||
0,
|
||||
bytemuck::cast_slice(&[corner.x, corner.y, corner.z, CELL]),
|
||||
);
|
||||
if self.built == Some((origin, revision)) {
|
||||
return;
|
||||
}
|
||||
self.built = Some((origin, revision));
|
||||
let cells = build(&self.table, origin, camera, sections);
|
||||
queue.write_texture(
|
||||
self.texture.as_image_copy(),
|
||||
&cells,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(SIZE * 4),
|
||||
rows_per_image: Some(SIZE),
|
||||
},
|
||||
self.texture.size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells of the grid with its corner at `origin`: RGBA8, x fastest, then y, then z.
|
||||
fn build(
|
||||
table: &LightTable,
|
||||
origin: [i32; 3],
|
||||
camera: DVec3,
|
||||
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
) -> Vec<u8> {
|
||||
let n = SIZE as usize;
|
||||
let mut cells = vec![0u8; n * n * n * 4];
|
||||
if table.is_empty() {
|
||||
return cells;
|
||||
}
|
||||
let span = SIZE as i32 * CELL as i32;
|
||||
// Sections the grid or the light reaching into it (15 blocks) touches.
|
||||
let lo = origin.map(|v| (v - 15).div_euclid(16));
|
||||
let hi = origin.map(|v| (v + span + 15).div_euclid(16));
|
||||
let mut sources: Vec<(Vec3, [f32; 3], f32, f64)> = Vec::new();
|
||||
for (key, lights) in sections {
|
||||
let key = [key.0, key.1 + crate::chunk::MIN_Y.div_euclid(16), key.2];
|
||||
if (0..3).any(|i| key[i] < lo[i] || key[i] > hi[i]) {
|
||||
continue;
|
||||
}
|
||||
for &(pos, state) in lights {
|
||||
let Some(&(color, level)) = table.get(&state) else {
|
||||
continue;
|
||||
};
|
||||
let center = glam::IVec3::from(pos).as_dvec3() + 0.5;
|
||||
let local = (center - glam::IVec3::from(origin).as_dvec3()).as_vec3();
|
||||
sources.push((
|
||||
local,
|
||||
color,
|
||||
f32::from(level),
|
||||
center.distance_squared(camera),
|
||||
));
|
||||
}
|
||||
}
|
||||
if sources.len() > MAX_SOURCES {
|
||||
sources.select_nth_unstable_by(MAX_SOURCES, |a, b| a.3.total_cmp(&b.3));
|
||||
sources.truncate(MAX_SOURCES);
|
||||
}
|
||||
let mut sum = vec![[0.0f32; 4]; n * n * n];
|
||||
for (at, color, reach, _) in sources {
|
||||
let first = ((at - reach) / CELL).floor().max(Vec3::ZERO).as_ivec3();
|
||||
let last = ((at + reach) / CELL)
|
||||
.ceil()
|
||||
.min(Vec3::splat(SIZE as f32 - 1.0))
|
||||
.as_ivec3();
|
||||
for z in first.z..=last.z {
|
||||
for y in first.y..=last.y {
|
||||
for x in first.x..=last.x {
|
||||
let cell = (Vec3::new(x as f32, y as f32, z as f32) + 0.5) * CELL;
|
||||
let d = cell.distance(at);
|
||||
if d >= reach {
|
||||
continue;
|
||||
}
|
||||
// As block light falls off: strongest at the source, gone at its level.
|
||||
let w = (1.0 - d / reach).powi(2);
|
||||
let s = &mut sum[x as usize + n * (y as usize + n * z as usize)];
|
||||
s[0] += color[0] * w;
|
||||
s[1] += color[1] * w;
|
||||
s[2] += color[2] * w;
|
||||
s[3] += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (cell, s) in cells.as_chunks_mut::<4>().0.iter_mut().zip(&sum) {
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
for i in 0..3 {
|
||||
cell[i] = byte(s[i] / s[3] / COLOR_RANGE);
|
||||
}
|
||||
cell[3] = byte(s[3]);
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_source_tints_the_cells_it_reaches_with_its_colour() {
|
||||
let mut table = LightTable::default();
|
||||
table.insert(7, ([2.0, 1.0, 0.0], 14));
|
||||
let mut sections = FxHashMap::default();
|
||||
// A torch at world (8, 64, 8): section y = (64 - MIN_Y) / 16.
|
||||
let sy = (64 - crate::chunk::MIN_Y) / 16;
|
||||
sections.insert((0, sy, 0), vec![([8, 64, 8], 7)]);
|
||||
let camera = DVec3::new(8.0, 64.0, 8.0);
|
||||
let origin = [-64, 0, -64];
|
||||
let cells = build(&table, origin, camera, §ions);
|
||||
let n = SIZE as usize;
|
||||
let at = |p: [i32; 3]| {
|
||||
let c = p
|
||||
.iter()
|
||||
.zip(origin)
|
||||
.map(|(v, o)| ((v - o) as f32 / CELL) as usize)
|
||||
.collect::<Vec<_>>();
|
||||
let i = (c[0] + n * (c[1] + n * c[2])) * 4;
|
||||
[cells[i], cells[i + 1], cells[i + 2], cells[i + 3]]
|
||||
};
|
||||
let near = at([8, 64, 8]);
|
||||
// The colour over 4, whatever the weight: (2, 1, 0) / 4.
|
||||
assert_eq!(&near[..3], &[128, 64, 0]);
|
||||
assert!(near[3] > 150);
|
||||
assert_eq!(at([8, 64, 40]), [0; 4], "past its level, nothing");
|
||||
assert!(
|
||||
build(&LightTable::default(), origin, camera, §ions)
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ const MATERIALS: usize = 10;
|
||||
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
|
||||
/// `breezeWind`). Only the improved transparency path reads it.
|
||||
pub const OIT_ENTITY: u32 = 1 << 24;
|
||||
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
|
||||
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
|
||||
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
|
||||
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
|
||||
pub fn mark_oit(quads: &mut [Vertex]) {
|
||||
@@ -79,6 +81,11 @@ pub(super) struct EntityBatch {
|
||||
}
|
||||
|
||||
impl EntityBatch {
|
||||
/// After `update`: quads only a native pack's shadow pass draws, at the buffer's end.
|
||||
pub(super) fn append_shadow_only(&mut self, shadow_only: &[Vertex]) {
|
||||
self.combined.extend_from_slice(shadow_only);
|
||||
}
|
||||
|
||||
pub(super) fn update(
|
||||
&mut self,
|
||||
entities: &[Vertex],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
|
||||
//! two real ones. So far this holds its optical flow and interpolation; the setting that
|
||||
//! runs them comes next.
|
||||
//! two real ones. So far this holds its optical flow, its interpolation and the motion
|
||||
//! vectors of what moves on its own; the setting that runs them comes next.
|
||||
|
||||
// Unused until the renderer runs them.
|
||||
#[allow(dead_code)]
|
||||
mod interpolation;
|
||||
#[allow(dead_code)]
|
||||
mod motion;
|
||||
#[allow(dead_code)]
|
||||
mod optical_flow;
|
||||
@@ -10,8 +10,8 @@
|
||||
//! priorities and vectors) and fill the game field's holes from a pyramid; mask what each
|
||||
//! frame can't see; blend the two frames along both fields; fill what is left from a
|
||||
//! pyramid of the result. What differs from AMD's, all forced by WGSL or by Horizon:
|
||||
//! the game's motion vectors are computed from the depth and `Inputs::reproject` (the
|
||||
//! camera's, so what moves on its own relies on the optical flow); textures AMD updates
|
||||
//! the game's motion vectors are the camera's, from the depth and `Inputs::reproject`,
|
||||
//! except where `Inputs::object_motion` has an entity's own; textures AMD updates
|
||||
//! with image atomics are storage buffers; SPD's single-pass pyramids are a pass per
|
||||
//! level; and there is no HUD-less pass, as Horizon draws its HUD over the made frame.
|
||||
|
||||
@@ -33,6 +33,9 @@ pub struct Inputs<'a> {
|
||||
/// `world_post::reprojection`): the motion vectors are the camera's, from this and
|
||||
/// the depth.
|
||||
pub reproject: Mat4,
|
||||
/// What moved on its own: `motion::FORMAT` at `render_size`, xy its motion vector
|
||||
/// (FSR's convention, in UV) where z is 1, used over the camera's.
|
||||
pub object_motion: &'a wgpu::TextureView,
|
||||
pub near: f32,
|
||||
pub far: f32,
|
||||
/// Vertical field of view, radians.
|
||||
@@ -442,6 +445,7 @@ impl Interpolation {
|
||||
&[
|
||||
(0, params()),
|
||||
(2, view(inputs.depth)),
|
||||
(34, view(inputs.object_motion)),
|
||||
(5, view(&r.dilated_depth)),
|
||||
(6, view(&r.dilated_mv)),
|
||||
(9, whole(&r.rec_prev_depth)),
|
||||
@@ -644,10 +648,12 @@ mod tests {
|
||||
/// Runs `frames` (pattern shifts) through the optical flow and the interpolation with
|
||||
/// a camera that moves by `reproject` each frame, and returns the mean error of the
|
||||
/// last made frame's interior against `expected`, and the last two real frames'.
|
||||
/// `object`: every pixel's own motion vector (UV), as if all of it moved on its own.
|
||||
fn interpolate(
|
||||
seed: u32,
|
||||
shifts: &[[i32; 2]],
|
||||
reproject: Mat4,
|
||||
object: Option<[f32; 2]>,
|
||||
expected: [i32; 2],
|
||||
) -> Option<[f32; 3]> {
|
||||
let (device, queue) = of::device()?;
|
||||
@@ -682,6 +688,35 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
queue.submit([encoder.finish()]);
|
||||
let object_motion = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: super::super::motion::FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
if let Some(mv) = object {
|
||||
let texel = [mv[0], mv[1], 1.0, 0.0].map(|v| half::f16::from_f32(v).to_bits());
|
||||
let texels: Vec<u16> = (0..size[0] * size[1]).flat_map(|_| texel).collect();
|
||||
queue.write_texture(
|
||||
object_motion.as_image_copy(),
|
||||
bytemuck::cast_slice(&texels),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(size[0] * 8),
|
||||
rows_per_image: None,
|
||||
},
|
||||
object_motion.size(),
|
||||
);
|
||||
}
|
||||
let object_motion = object_motion.create_view(&Default::default());
|
||||
let mut flow = OpticalFlow::new(&device, size);
|
||||
let mut interpolation = Interpolation::new(&device, size);
|
||||
let frames: Vec<_> = shifts
|
||||
@@ -731,6 +766,7 @@ mod tests {
|
||||
render_size: size,
|
||||
jitter: [0.0; 2],
|
||||
reproject: if i == 0 { Mat4::IDENTITY } else { reproject },
|
||||
object_motion: &object_motion,
|
||||
near: 0.05,
|
||||
far: 512.0,
|
||||
fov_y: 1.2,
|
||||
@@ -776,7 +812,8 @@ mod tests {
|
||||
fn moves_what_the_optical_flow_follows_halfway() {
|
||||
let mut shifts = vec![[0, 0]; 20];
|
||||
shifts.push([16, 8]);
|
||||
let Some([made, previous, current]) = interpolate(100, &shifts, Mat4::IDENTITY, [8, 4])
|
||||
let Some([made, previous, current]) =
|
||||
interpolate(100, &shifts, Mat4::IDENTITY, None, [8, 4])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -795,7 +832,26 @@ mod tests {
|
||||
// What is at a pixel now was 16 pixels to its right last frame.
|
||||
let reproject = Mat4::from_translation(glam::Vec3::new(32.0 / size[0], 0.0, 0.0));
|
||||
let shifts = [[0, 0], [-16, 0], [-32, 0], [-48, 0]];
|
||||
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, [-40, 0]) else {
|
||||
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, None, [-40, 0])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
made * 4.0 < previous.min(current),
|
||||
"{made} against {previous} and {current}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Something moving on its own with its own motion vectors (the camera still): the
|
||||
/// made frame is halfway, as with the camera's.
|
||||
#[test]
|
||||
fn moves_what_has_its_own_motion_vectors_halfway() {
|
||||
let size = [512.0, 288.0];
|
||||
let shifts = [[0, 0], [16, 8], [32, 16], [48, 24]];
|
||||
let mv = [-16.0 / size[0], -8.0 / size[1]];
|
||||
let Some([made, previous, current]) =
|
||||
interpolate(1, &shifts, Mat4::IDENTITY, Some(mv), [40, 20])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
// for Horizon, for an SDR back buffer, inverted finite depth, the whole screen as the
|
||||
// interpolation rect and no distortion field. What differs from AMD's (interpolation.rs
|
||||
// lists why):
|
||||
// - the motion vectors come from the depth and the camera (`prepare`), already unjittered;
|
||||
// - the motion vectors are the camera's, from the depth (`prepare`), already unjittered,
|
||||
// or an entity's own where `object_motion` has one;
|
||||
// - the vector fields and reconstructed depths are storage buffers of atomics;
|
||||
// - both inpainting pyramids are a pass per level, and textures of their own;
|
||||
// - the interpolation writes one texture and the inpainting another (rgba16float can't
|
||||
@@ -85,6 +86,8 @@ struct Params {
|
||||
@group(0) @binding(31) var interpolated_out: texture_storage_2d<rgba16float, write>;
|
||||
@group(0) @binding(32) var interpolated: texture_2d<f32>;
|
||||
@group(0) @binding(33) var output: texture_storage_2d<rgba16float, write>;
|
||||
// What moved on its own (motion.wgsl): xy its motion vector where z is 1.
|
||||
@group(0) @binding(34) var object_motion: texture_2d<f32>;
|
||||
|
||||
const EPSILON: f32 = 1e-03;
|
||||
const RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD: f32 = EPSILON;
|
||||
@@ -355,7 +358,11 @@ fn prepare_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
|
||||
let object = textureLoad(object_motion, nearest_coord, 0);
|
||||
var mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
|
||||
if object.z > 0.5 {
|
||||
mv = object.xy;
|
||||
}
|
||||
textureStore(dilated_depth_out, p, vec4(nearest, 0.0, 0.0, 0.0));
|
||||
textureStore(dilated_mv_out, p, vec4(mv, 0.0, 0.0));
|
||||
// ReconstructPrevDepth
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Motion vectors for what moves on its own, for frame generation: the camera's motion
|
||||
//! vectors (from the depth) see mobs and dropped items as still. The game tags each
|
||||
//! entity's vertices with its id (`Frame::entity_motion`); an entity whose vertices have
|
||||
//! the same shape as last frame's (the same count, UVs and layers) gets each vertex's
|
||||
//! own motion, limbs and spin included, drawn into a texture at the render size
|
||||
//! (`motion.wgsl`) that the interpolation reads before the camera's.
|
||||
|
||||
use crate::mesher::Vertex;
|
||||
use glam::{DVec3, Mat4, Vec3};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `motion.wgsl`'s `Params`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Params {
|
||||
jittered: [[f32; 4]; 4],
|
||||
current: [[f32; 4]; 4],
|
||||
previous: [[f32; 4]; 4],
|
||||
}
|
||||
|
||||
/// A vertex this frame and where it was last frame, both relative to this frame's camera.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct MotionVertex {
|
||||
position: [f32; 3],
|
||||
previous: [f32; 3],
|
||||
uv: [f32; 2],
|
||||
/// 1 for a translucent item, whose texture says where it shows.
|
||||
translucent: u32,
|
||||
}
|
||||
|
||||
pub struct ObjectMotion {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
atlas: wgpu::TextureView,
|
||||
sampler: wgpu::Sampler,
|
||||
params: wgpu::Buffer,
|
||||
vertices: wgpu::Buffer,
|
||||
/// Triangles of the entities matched this frame.
|
||||
staged: Vec<MotionVertex>,
|
||||
/// Last frame's vertices by entity, and its camera.
|
||||
tracked: HashMap<u64, Vec<Vertex>>,
|
||||
camera: DVec3,
|
||||
target: Option<([u32; 2], wgpu::TextureView)>,
|
||||
}
|
||||
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
impl ObjectMotion {
|
||||
/// `atlas`: the entities' texture atlas.
|
||||
pub fn new(device: &wgpu::Device, atlas: wgpu::TextureView) -> Self {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("motion.wgsl").into()),
|
||||
});
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2, 3 => Uint32];
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_motion"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<MotionVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_motion"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(FORMAT.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self {
|
||||
pipeline,
|
||||
atlas,
|
||||
sampler: device.create_sampler(&Default::default()),
|
||||
params: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
size: std::mem::size_of::<Params>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
vertices: vertex_buffer(device, 1 << 12),
|
||||
staged: Vec::new(),
|
||||
tracked: HashMap::new(),
|
||||
camera: DVec3::ZERO,
|
||||
target: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget last frame's entities: the next frame has no motion of its own.
|
||||
pub fn reset(&mut self) {
|
||||
self.tracked.clear();
|
||||
}
|
||||
|
||||
/// Match this frame's `entities` (relative to `camera`) to last frame's by the ids
|
||||
/// `spans` gives their vertices (start, id; 0 for none, each up to the next start), and
|
||||
/// keep them for the next frame.
|
||||
pub fn track(&mut self, entities: &[Vertex], spans: &[(u32, u64)], camera: DVec3) {
|
||||
self.staged.clear();
|
||||
let shift = (self.camera - camera).as_vec3();
|
||||
let mut tracked = HashMap::with_capacity(self.tracked.len());
|
||||
for (i, &(start, id)) in spans.iter().enumerate() {
|
||||
let end = spans.get(i + 1).map_or(entities.len(), |s| s.0 as usize);
|
||||
let Some(now) = entities
|
||||
.get(start as usize..end)
|
||||
.filter(|v| id != 0 && !v.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(then) = self.tracked.remove(&id)
|
||||
&& then.len() == now.len()
|
||||
&& then
|
||||
.iter()
|
||||
.zip(now)
|
||||
.all(|(a, b)| a.uv == b.uv && a.layer == b.layer)
|
||||
{
|
||||
let pair = |i: usize| MotionVertex {
|
||||
position: now[i].pos,
|
||||
previous: (Vec3::from(then[i].pos) + shift).into(),
|
||||
uv: now[i].uv,
|
||||
translucent: u32::from(matches!(
|
||||
now[i].layer,
|
||||
super::super::entity_batch::TRANSLUCENT_ITEM_OVERLAY
|
||||
| super::super::entity_batch::TRANSLUCENT_GLINT_ITEM_OVERLAY
|
||||
)),
|
||||
};
|
||||
for q in (0..now.len() / 4).map(|q| q * 4) {
|
||||
self.staged
|
||||
.extend([q, q + 1, q + 2, q, q + 2, q + 3].map(pair));
|
||||
}
|
||||
}
|
||||
tracked.insert(id, now.to_vec());
|
||||
}
|
||||
self.tracked = tracked;
|
||||
self.camera = camera;
|
||||
}
|
||||
|
||||
/// Draw the matched entities' motion vectors at `size` (the world's render size),
|
||||
/// where `depth` (the world's) shows them; the rest of the texture is 0.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
jittered: Mat4,
|
||||
current: Mat4,
|
||||
previous: Mat4,
|
||||
) -> wgpu::TextureView {
|
||||
if self.target.as_ref().is_none_or(|t| t.0 != size) {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
self.target = Some((size, texture.create_view(&Default::default())));
|
||||
}
|
||||
let target = self.target.as_ref().expect("made above").1.clone();
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&self.staged);
|
||||
if bytes.len() as u64 > self.vertices.size() {
|
||||
self.vertices = vertex_buffer(device, (bytes.len() as u64).next_power_of_two());
|
||||
}
|
||||
if !bytes.is_empty() {
|
||||
queue.write_buffer(&self.vertices, 0, bytes);
|
||||
}
|
||||
let params = Params {
|
||||
jittered: jittered.to_cols_array_2d(),
|
||||
current: current.to_cols_array_2d(),
|
||||
previous: previous.to_cols_array_2d(),
|
||||
};
|
||||
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(¶ms));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
layout: &self.pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&self.atlas),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
if !self.staged.is_empty() {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..bytes.len() as u64));
|
||||
pass.draw(0..self.staged.len() as u32, 0..1);
|
||||
}
|
||||
drop(pass);
|
||||
target
|
||||
}
|
||||
}
|
||||
|
||||
fn vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame generation motion vertices"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("motion.wgsl")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::empty(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn quad(x: f32, uv: f32) -> Vec<Vertex> {
|
||||
(0..4)
|
||||
.map(|i| Vertex {
|
||||
pos: [x + i as f32, 0.0, -5.0],
|
||||
uv: [uv, 0.0],
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An entity seen last frame gets its vertices' own motion, in this frame's camera
|
||||
/// space; a new one, one whose shape changed and untagged geometry get none.
|
||||
#[test]
|
||||
fn tracks_each_entity_by_id() {
|
||||
let Some((device, queue)) = super::super::optical_flow::tests::device() else {
|
||||
return;
|
||||
};
|
||||
let atlas = super::super::optical_flow::tests::upload(&device, &queue, [1, 1], &[0; 4]);
|
||||
let mut motion = ObjectMotion::new(&device, atlas);
|
||||
let mut entities = quad(0.0, 0.0);
|
||||
entities.extend(quad(10.0, 0.5));
|
||||
motion.track(&entities, &[(0, 7), (4, 8)], DVec3::ZERO);
|
||||
assert!(motion.staged.is_empty());
|
||||
// Entity 7 moved 1 block right, the camera 2 blocks right; entity 8 became
|
||||
// another item; 9 is new; then untagged geometry.
|
||||
let mut entities = quad(-1.0, 0.0);
|
||||
entities.extend(quad(10.0, 0.25));
|
||||
entities.extend(quad(20.0, 0.0));
|
||||
entities.extend(quad(30.0, 0.0));
|
||||
motion.track(
|
||||
&entities,
|
||||
&[(0, 7), (4, 8), (8, 9), (12, 0)],
|
||||
DVec3::new(2.0, 0.0, 0.0),
|
||||
);
|
||||
assert_eq!(motion.staged.len(), 6);
|
||||
let v = motion.staged[0];
|
||||
assert_eq!(v.position, [-1.0, 0.0, -5.0]);
|
||||
assert_eq!(v.previous, [-2.0, 0.0, -5.0]);
|
||||
assert_eq!(motion.tracked.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Frame generation's motion vectors for what moves on its own (mobs, dropped items): each
|
||||
// vertex drawn where the world pass drew it, with where it was last frame; only the pixels
|
||||
// the world shows it at keep its vector: where the depth is its own, or, for translucent
|
||||
// items (which don't write depth), where its texture shows and nothing is in front.
|
||||
|
||||
struct Params {
|
||||
// The world's projection this frame (TAA's jitter in it), the same without the jitter,
|
||||
// and last frame's projection from this frame's camera.
|
||||
jittered: mat4x4<f32>,
|
||||
current: mat4x4<f32>,
|
||||
previous: mat4x4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(3) var atlas_sampler: sampler;
|
||||
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) current: vec4<f32>,
|
||||
@location(1) previous: vec4<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
// 1: a translucent item.
|
||||
@location(3) @interpolate(flat) translucent: u32,
|
||||
};
|
||||
|
||||
@vertex fn vs_motion(@location(0) position: vec3<f32>, @location(1) previous: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>, @location(3) translucent: u32) -> Out {
|
||||
var out: Out;
|
||||
out.uv = uv;
|
||||
out.translucent = translucent;
|
||||
out.clip = params.jittered * vec4(position, 1.0);
|
||||
out.current = params.current * vec4(position, 1.0);
|
||||
out.previous = params.previous * vec4(previous, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
|
||||
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
|
||||
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
|
||||
let tolerance = stored * 1e-3 + 1e-7;
|
||||
// Not what the world shows here: a transparent texel, or behind something.
|
||||
if in.translucent != 0u {
|
||||
let alpha = textureSampleLevel(atlas, atlas_sampler, in.uv, 0.0).a;
|
||||
if alpha < 0.1 || in.clip.z < stored - tolerance {
|
||||
discard;
|
||||
}
|
||||
} else if abs(in.clip.z - stored) > tolerance {
|
||||
discard;
|
||||
}
|
||||
if in.previous.w <= 0.0 {
|
||||
discard;
|
||||
}
|
||||
let now = in.current.xy / in.current.w;
|
||||
let then = in.previous.xy / in.previous.w;
|
||||
return vec4((then - now) * vec2(0.5, -0.5), 1.0, 0.0);
|
||||
}
|
||||
@@ -152,6 +152,10 @@ impl Tile {
|
||||
None => &mut self.mesh,
|
||||
}
|
||||
}
|
||||
/// Both meshes it holds in the arena: its own and the stand-in, if any.
|
||||
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
|
||||
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
|
||||
}
|
||||
pub fn visible(
|
||||
&self,
|
||||
key: TileKey,
|
||||
|
||||
@@ -18,6 +18,14 @@ const PREFIX: &str = "horizon_terrain_";
|
||||
/// The entries around the pack's `material`: vanilla's vertex work without the lightmap
|
||||
/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do.
|
||||
const ENTRIES: &str = "
|
||||
// The manifest's `[material] fog`: false when the pack fogs its surfaces itself.
|
||||
override horizon_material_fog: bool = true;
|
||||
fn horizon_material_fogged(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
if horizon_material_fog {
|
||||
return horizon_terrain_apply_fog(color, spherical, cylindrical);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
struct HorizonMaterialOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -79,26 +87,52 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
|
||||
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
|
||||
color, light, origin.xyz);
|
||||
}
|
||||
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
|
||||
// in the pack's frame group (3), one array each, a blank 1x1 without them.
|
||||
@group(3) @binding(12) var horizon_pbr_normals: texture_2d_array<f32>;
|
||||
@group(3) @binding(13) var horizon_pbr_specular: texture_2d_array<f32>;
|
||||
fn horizon_block_pbr_present() -> bool {
|
||||
return textureDimensions(horizon_pbr_normals).x > 1u;
|
||||
}
|
||||
fn horizon_block_pbr_size() -> vec2<f32> {
|
||||
return vec2<f32>(textureDimensions(horizon_pbr_normals).xy);
|
||||
}
|
||||
fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
|
||||
if !horizon_block_pbr_present() || layer >= textureNumLayers(horizon_pbr_normals) {
|
||||
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
|
||||
}
|
||||
let s = horizon_terrain_sprite_sampler;
|
||||
if map == 0u {
|
||||
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), lod);
|
||||
}
|
||||
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), lod);
|
||||
}
|
||||
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
|
||||
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
horizon_block_layer = in.layer & 0x7fffu;
|
||||
// The mip level of this point in the maps: their own texels, as the sampler would pick.
|
||||
let texels = in.uv * horizon_block_pbr_size();
|
||||
let footprint = max(dot(dpdx(texels), dpdx(texels)), dot(dpdy(texels), dpdy(texels)));
|
||||
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
|
||||
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
|
||||
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
|
||||
let normal = horizon_material_normal(in.position);
|
||||
let tangent = horizon_material_tangent(in.position, in.uv, normal);
|
||||
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
|
||||
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
in.face, fluid, in.emission, normal, tangent)).color;
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, false);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
}
|
||||
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, true);
|
||||
if color.a < 0.01 { discard; }
|
||||
return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical);
|
||||
return horizon_material_fogged(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
";
|
||||
|
||||
@@ -115,6 +149,16 @@ pub struct TerrainShape<'a> {
|
||||
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
|
||||
/// (`Manifest::sprite_tags`).
|
||||
pub sprite_tags: &'a [(u32, u32)],
|
||||
/// The pack's material id of each sprite layer with one, as `(layer, id)`
|
||||
/// (`Manifest::sprite_materials`).
|
||||
pub sprite_materials: &'a [(u32, u32)],
|
||||
/// The entities' group 0 (their atlas), for the shadow pass's entity draw.
|
||||
pub entities: &'a wgpu::BindGroupLayout,
|
||||
/// The weather's groups 0 and 1 (the entities' atlas, the fall clock), for the weather
|
||||
/// stage.
|
||||
pub weather_groups: [&'a wgpu::BindGroupLayout; 2],
|
||||
/// The resource packs' LabPBR maps (normals, specular), when they ship any.
|
||||
pub pbr: Option<&'a [wgpu::Texture; 2]>,
|
||||
}
|
||||
|
||||
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
|
||||
@@ -134,12 +178,14 @@ fn vanilla_terrain() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
|
||||
fn sprite_tags(tags: &[(u32, u32)]) -> String {
|
||||
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
|
||||
let mut tags = tags.to_vec();
|
||||
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
|
||||
for group in tags.chunk_by(|a, b| a.1 == b.1) {
|
||||
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
|
||||
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
|
||||
/// (`MaterialInput.material`).
|
||||
fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
|
||||
let mut table = table.to_vec();
|
||||
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
|
||||
for group in table.chunk_by(|a, b| a.1 == b.1) {
|
||||
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
|
||||
out += &format!(
|
||||
" case {}: {{ return {}u; }}\n",
|
||||
@@ -150,18 +196,24 @@ fn sprite_tags(tags: &[(u32, u32)]) -> String {
|
||||
out + " default: { return 0u; }\n }\n}\n"
|
||||
}
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
|
||||
/// `materials`.
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
|
||||
vanilla_terrain(),
|
||||
sprite_tags(tags)
|
||||
layer_table("horizon_sprite_tags", tags),
|
||||
layer_table("horizon_sprite_material", materials)
|
||||
)
|
||||
}
|
||||
|
||||
/// The material's naga module, its errors in the stage's file.
|
||||
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags);
|
||||
fn module(
|
||||
stage: &CheckedStage,
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags, materials);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
|
||||
}
|
||||
@@ -174,14 +226,16 @@ impl NativeMaterial {
|
||||
frame: &NativeFrame,
|
||||
stage: &CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
fog: bool,
|
||||
) -> Result<Self, String> {
|
||||
let module = module(stage, shape.sprite_tags)?;
|
||||
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
"horizon_terrain_SPRITE_ARRAYS",
|
||||
"horizon_terrain_SPRITE_SPLIT",
|
||||
];
|
||||
constants.extend(sprite_names.into_iter().zip(shape.sprites));
|
||||
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.label),
|
||||
@@ -346,14 +400,20 @@ mod tests {
|
||||
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
|
||||
let stage = stage(own);
|
||||
let tags = [(7, 1), (3, 1), (9, 2)];
|
||||
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags).unwrap();
|
||||
let materials = [(5, 1000)];
|
||||
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags, &materials).unwrap();
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"{}",
|
||||
e.emit_to_string(&shader_source(&stage.source, &tags, &materials))
|
||||
)
|
||||
});
|
||||
let entries: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
|
||||
@@ -5,6 +5,7 @@ use horizon_shader_api::{Report, StageKind};
|
||||
|
||||
use super::native_material::{NativeMaterial, TerrainShape};
|
||||
use super::native_post::{NativeFrame, NativePost};
|
||||
use super::native_shadow::NativeShadow;
|
||||
use super::native_sky::NativeSky;
|
||||
use super::pack_timing::PassTiming;
|
||||
|
||||
@@ -13,6 +14,13 @@ pub struct NativePack {
|
||||
pub sky: Option<NativeSky>,
|
||||
pub material: Option<NativeMaterial>,
|
||||
pub post: Option<NativePost>,
|
||||
/// With a sky stage, what of Minecraft's sky still draws over it (`[sky]`).
|
||||
pub vanilla_clouds: bool,
|
||||
pub vanilla_sun: bool,
|
||||
pub vanilla_stars: bool,
|
||||
pub weather: Option<wgpu::RenderPipeline>,
|
||||
/// The draw of the shadow map the stages read, if the pack asks for one.
|
||||
pub shadow: Option<NativeShadow>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<PassTiming>,
|
||||
}
|
||||
@@ -24,12 +32,24 @@ impl NativePack {
|
||||
/// refused.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
terrain: &TerrainShape<'_>,
|
||||
report: Report,
|
||||
constants: &[(String, f64)],
|
||||
lights: super::block_light::LightTable,
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
let (globals, format) = (terrain.globals, terrain.format);
|
||||
let mut frame = NativeFrame::new(device);
|
||||
let textures = super::native_post::pack_textures(device, queue, &report);
|
||||
let settings = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.map(|settings| super::native_shadow::fitted(device, settings));
|
||||
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
|
||||
let shadow = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.map(|s| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
|
||||
.transpose()?;
|
||||
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
|
||||
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
|
||||
frame.reflects = report
|
||||
@@ -43,6 +63,34 @@ impl NativePack {
|
||||
.find(|(k, _)| k == id)
|
||||
.is_none_or(|(_, v)| *v != 0.0)
|
||||
});
|
||||
let fog = report.material_fog;
|
||||
let keeps = |key: fn(&horizon_shader_api::Stage) -> Option<bool>| {
|
||||
report.sky.as_ref().and_then(key).unwrap_or(true)
|
||||
};
|
||||
let vanilla_clouds = keeps(|s| s.clouds);
|
||||
let vanilla_sun = keeps(|s| s.sun);
|
||||
let vanilla_stars = keeps(|s| s.stars);
|
||||
let weather = report
|
||||
.stages
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Weather)
|
||||
.map(|stage| {
|
||||
super::weather::native_pipeline(
|
||||
device,
|
||||
format,
|
||||
terrain.weather_groups[0],
|
||||
terrain.weather_groups[1],
|
||||
&frame,
|
||||
stage,
|
||||
constants,
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
let weather_label = report
|
||||
.stages
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Weather)
|
||||
.map(|s| s.label.clone());
|
||||
let (post, others): (Vec<_>, Vec<_>) = report
|
||||
.stages
|
||||
.into_iter()
|
||||
@@ -55,10 +103,10 @@ impl NativePack {
|
||||
let material = others
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Material)
|
||||
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants))
|
||||
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants, fog))
|
||||
.transpose()?;
|
||||
let post = (!post.is_empty())
|
||||
.then(|| NativePost::new(device, post, constants, format, &frame))
|
||||
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
|
||||
.transpose()?;
|
||||
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
|
||||
labels.extend(
|
||||
@@ -67,6 +115,7 @@ impl NativePack {
|
||||
.filter(|s| material.is_some() && s.kind == StageKind::Material)
|
||||
.map(|s| s.label.clone()),
|
||||
);
|
||||
labels.extend(weather_label);
|
||||
if let Some(post) = &post {
|
||||
labels.extend(post.labels().into_iter().map(str::to_owned));
|
||||
}
|
||||
@@ -75,6 +124,11 @@ impl NativePack {
|
||||
sky,
|
||||
material,
|
||||
post,
|
||||
vanilla_clouds,
|
||||
vanilla_sun,
|
||||
vanilla_stars,
|
||||
weather,
|
||||
shadow,
|
||||
timing: None,
|
||||
};
|
||||
Ok((pack, labels))
|
||||
@@ -82,6 +136,9 @@ impl NativePack {
|
||||
|
||||
/// Whether the pack draws anything the game runs today.
|
||||
pub fn draws(&self) -> bool {
|
||||
self.sky.is_some() || self.material.is_some() || self.post.is_some()
|
||||
self.sky.is_some()
|
||||
|| self.material.is_some()
|
||||
|| self.weather.is_some()
|
||||
|| self.post.is_some()
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,72 @@ const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
/// The world's colour and depth views a set of bind groups was made for.
|
||||
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
|
||||
|
||||
/// Where a post pass draws.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Writes {
|
||||
/// One of the chain's two targets.
|
||||
Chain(usize),
|
||||
/// A pack target (`[[post]] output`).
|
||||
Target(usize),
|
||||
/// The screen: the last pass.
|
||||
Screen,
|
||||
}
|
||||
|
||||
/// A post pass's target, and the chain's picture it reads as `previous` (the world's for
|
||||
/// none).
|
||||
#[derive(Clone, Copy)]
|
||||
struct PassPlan {
|
||||
writes: Writes,
|
||||
reads: Option<usize>,
|
||||
}
|
||||
|
||||
/// The passes' plan for `outputs` (each pass's `[[post]] output`, by target index): the chain's
|
||||
/// passes write its targets in turn, each reading what the one before wrote; a pass with an
|
||||
/// output writes that and reads the chain's picture, which goes on past it.
|
||||
fn plan(outputs: &[Option<usize>]) -> Vec<PassPlan> {
|
||||
let mut current = None;
|
||||
outputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, output)| {
|
||||
let reads = current;
|
||||
let writes = match output {
|
||||
_ if i + 1 == outputs.len() => Writes::Screen,
|
||||
Some(t) => Writes::Target(*t),
|
||||
None => {
|
||||
let slot = usize::from(current == Some(0));
|
||||
current = Some(slot);
|
||||
Writes::Chain(slot)
|
||||
}
|
||||
};
|
||||
PassPlan { writes, reads }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pack target's size: `scale` of the world's `size`, at least a texel.
|
||||
fn scaled(size: [u32; 2], scale: f32) -> [u32; 2] {
|
||||
size.map(|s| ((s as f32 * scale).ceil() as u32).max(1))
|
||||
}
|
||||
|
||||
pub struct NativePost {
|
||||
passes: Vec<(String, wgpu::RenderPipeline)>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// The pack's textures.
|
||||
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
|
||||
frame: wgpu::Buffer,
|
||||
linear: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
/// Two targets of the world's size, written in turn (none with a single pass).
|
||||
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
|
||||
/// The frame's shadow map, its comparison sampler (bindings 6 and 7), the translucent map
|
||||
/// and its colour (bindings 9 and 10).
|
||||
shadow: (
|
||||
wgpu::TextureView,
|
||||
wgpu::Sampler,
|
||||
wgpu::TextureView,
|
||||
wgpu::TextureView,
|
||||
),
|
||||
/// Last frame's input to the last pass (`history`, binding 8), made with the targets,
|
||||
/// and a black 1×1 in its place before then.
|
||||
history: Option<wgpu::Texture>,
|
||||
@@ -32,6 +90,12 @@ pub struct NativePost {
|
||||
groups: Option<(WorldViews, bool, Vec<wgpu::BindGroup>)>,
|
||||
/// Whether `history` contains the last frame's input to the last pass.
|
||||
history_valid: bool,
|
||||
/// What each pass writes and reads (`[[post]] output`).
|
||||
plan: Vec<PassPlan>,
|
||||
/// The pack's targets' scales (`Manifest::post_targets`), and their views for the world's
|
||||
/// size.
|
||||
scales: Vec<f32>,
|
||||
named: Option<([u32; 2], Vec<wgpu::TextureView>)>,
|
||||
/// The screen's colour format, the world target's too.
|
||||
format: wgpu::TextureFormat,
|
||||
}
|
||||
@@ -42,6 +106,10 @@ pub struct NativeFrame {
|
||||
pub(super) buffer: wgpu::Buffer,
|
||||
/// Group 3 of the sky and material stages.
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
/// The pack's `[[textures]]` and their samplers, bound for every stage.
|
||||
pub textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
|
||||
/// The colour of block light around the camera (`[[lights]]`), bindings 9 and 10.
|
||||
pub block_light: super::block_light::BlockLight,
|
||||
pub group: wgpu::BindGroup,
|
||||
/// The world's depth before translucent terrain (the material stage's `opaque_depth`,
|
||||
/// binding 6), copied each frame.
|
||||
@@ -49,11 +117,17 @@ pub struct NativeFrame {
|
||||
/// The world's colour before translucent terrain (`opaque_color`, binding 11), copied
|
||||
/// with it; 1×1 while the world draws straight to the window.
|
||||
pub opaque_color: wgpu::Texture,
|
||||
/// The resource packs' LabPBR maps (bindings 12 and 13: normals, specular), a blank 1x1
|
||||
/// without them.
|
||||
pbr: [wgpu::TextureView; 2],
|
||||
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
|
||||
/// `blank`; and its sampler (binding 2).
|
||||
reflection: wgpu::TextureView,
|
||||
blank: wgpu::TextureView,
|
||||
reflection_sampler: wgpu::Sampler,
|
||||
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
|
||||
pub shadow_map: super::native_shadow::ShadowMap,
|
||||
shadow_camera: Option<horizon_shaders::Shadow>,
|
||||
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
|
||||
/// is drawn and `has_reflection` stays 0.
|
||||
pub reflects: bool,
|
||||
@@ -140,22 +214,61 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
|
||||
}
|
||||
|
||||
impl NativeFrame {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
/// The frame of a pack with the `[shadow]` settings `shadow`, if any, and the
|
||||
/// block light colours `lights`.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shadow: Option<&horizon_shader_api::manifest::Shadow>,
|
||||
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
|
||||
lights: super::block_light::LightTable,
|
||||
pbr: Option<&[wgpu::Texture; 2]>,
|
||||
) -> Self {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Native shader pack frame"),
|
||||
size: size_of::<FrameInputs>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut entries = vec![
|
||||
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
|
||||
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
|
||||
sampler_entry(2),
|
||||
texture_entry(3, wgpu::TextureSampleType::Depth),
|
||||
comparison_entry(4),
|
||||
texture_entry(6, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(7, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(8, wgpu::TextureSampleType::Float { filterable: true }),
|
||||
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
|
||||
pbr_entry(12),
|
||||
pbr_entry(13),
|
||||
];
|
||||
let block_light_entries = [
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 9,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D3,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 10,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
];
|
||||
entries.extend(block_light_entries);
|
||||
entries.extend(pack_texture_entries(textures.len()));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack frame"),
|
||||
entries: &[
|
||||
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
|
||||
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
|
||||
sampler_entry(2),
|
||||
texture_entry(6, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
|
||||
],
|
||||
entries: &entries,
|
||||
});
|
||||
let opaque_depth = depth_copy(device, [1, 1]);
|
||||
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
|
||||
@@ -172,15 +285,50 @@ impl NativeFrame {
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow);
|
||||
let block_light = super::block_light::BlockLight::new(device, lights);
|
||||
let array_view = |texture: &wgpu::Texture| {
|
||||
texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let pbr = match pbr {
|
||||
Some(maps) => maps.each_ref().map(array_view),
|
||||
None => {
|
||||
// Two layers: one would make a 2D texture on the OpenGL backend.
|
||||
let blank = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Native shader pack LabPBR (none)"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 2,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
[array_view(&blank), array_view(&blank)]
|
||||
}
|
||||
};
|
||||
let group = Self::group(
|
||||
device,
|
||||
&layout,
|
||||
&buffer,
|
||||
(&blank, &reflection_sampler),
|
||||
&opaque_depth,
|
||||
&opaque_color,
|
||||
&shadow_map,
|
||||
(&opaque_depth, &opaque_color),
|
||||
&textures,
|
||||
&block_light,
|
||||
&pbr,
|
||||
);
|
||||
Self {
|
||||
textures,
|
||||
block_light,
|
||||
pbr,
|
||||
buffer,
|
||||
layout,
|
||||
group,
|
||||
@@ -189,6 +337,8 @@ impl NativeFrame {
|
||||
reflection: blank.clone(),
|
||||
blank,
|
||||
reflection_sampler,
|
||||
shadow_map,
|
||||
shadow_camera: shadow.map(super::native_shadow::camera),
|
||||
reflects: false,
|
||||
last: None,
|
||||
eye: [f32::NAN; 4],
|
||||
@@ -199,43 +349,93 @@ impl NativeFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the shadow camera sees this frame, for the renderer's shadow lists.
|
||||
pub fn shadow_view(&self, frame: &Frame) -> Option<super::pack_post::ShadowView> {
|
||||
let camera = self.shadow_camera.as_ref()?;
|
||||
Some(super::pack_post::ShadowView {
|
||||
cull: super::pack_uniforms::shadow_culling(frame, camera),
|
||||
light: super::pack_uniforms::shadow_light(frame, camera),
|
||||
// The light's box culls: on the ground it reaches further than `distance` along
|
||||
// the sun's azimuth while the sun is low.
|
||||
distance: None,
|
||||
unculled: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn group(
|
||||
device: &wgpu::Device,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
buffer: &wgpu::Buffer,
|
||||
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
|
||||
opaque_depth: &wgpu::Texture,
|
||||
opaque_color: &wgpu::Texture,
|
||||
shadow: &super::native_shadow::ShadowMap,
|
||||
(opaque_depth, opaque_color): (&wgpu::Texture, &wgpu::Texture),
|
||||
textures: &[(wgpu::TextureView, wgpu::Sampler)],
|
||||
block_light: &super::block_light::BlockLight,
|
||||
pbr: &[wgpu::TextureView; 2],
|
||||
) -> wgpu::BindGroup {
|
||||
let opaque_depth = opaque_depth.create_view(&Default::default());
|
||||
let opaque_color = opaque_color.create_view(&Default::default());
|
||||
let mut entries = vec![
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(reflection),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&shadow.view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&shadow.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&shadow.translucent_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(&shadow.color_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(&opaque_depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::TextureView(&opaque_color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 12,
|
||||
resource: wgpu::BindingResource::TextureView(&pbr[0]),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 13,
|
||||
resource: wgpu::BindingResource::TextureView(&pbr[1]),
|
||||
},
|
||||
];
|
||||
entries.extend([
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(&block_light.view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: block_light.grid.as_entire_binding(),
|
||||
},
|
||||
]);
|
||||
entries.extend(pack_texture_group_entries(textures));
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack frame"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(reflection),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
&opaque_depth.create_view(&Default::default()),
|
||||
),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
&opaque_color.create_view(&Default::default()),
|
||||
),
|
||||
},
|
||||
],
|
||||
entries: &entries,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -284,8 +484,11 @@ impl NativeFrame {
|
||||
&self.layout,
|
||||
&self.buffer,
|
||||
(&self.reflection, &self.reflection_sampler),
|
||||
&self.opaque_depth,
|
||||
&self.opaque_color,
|
||||
&self.shadow_map,
|
||||
(&self.opaque_depth, &self.opaque_color),
|
||||
&self.textures,
|
||||
&self.block_light,
|
||||
&self.pbr,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,7 +498,13 @@ impl NativeFrame {
|
||||
let now = std::time::Instant::now();
|
||||
let since = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
|
||||
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
|
||||
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
|
||||
let mut inputs = frame_inputs(
|
||||
frame,
|
||||
size,
|
||||
seconds,
|
||||
self.frames,
|
||||
self.shadow_camera.as_ref(),
|
||||
);
|
||||
inputs.wetness = self.wetness.0;
|
||||
if !self.reflects {
|
||||
inputs.has_reflection = 0;
|
||||
@@ -377,6 +586,29 @@ fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::Bi
|
||||
}
|
||||
}
|
||||
|
||||
/// A LabPBR map's array, read by the material entries (`native_material.rs`).
|
||||
fn pbr_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn comparison_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
@@ -392,22 +624,43 @@ impl NativePost {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
stages: Vec<CheckedStage>,
|
||||
manifest: &horizon_shader_api::Manifest,
|
||||
constants: &[(String, f64)],
|
||||
format: wgpu::TextureFormat,
|
||||
frame: &NativeFrame,
|
||||
) -> Result<Self, String> {
|
||||
let targets = manifest.post_targets();
|
||||
let outputs: Vec<Option<usize>> = manifest
|
||||
.post
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let name = p.output.as_deref()?;
|
||||
targets.iter().position(|t| t.0 == name)
|
||||
})
|
||||
.collect();
|
||||
let plan = plan(&outputs);
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let mut entries = vec![
|
||||
frame_entry(wgpu::ShaderStages::FRAGMENT),
|
||||
texture_entry(1, float),
|
||||
texture_entry(2, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(3, float),
|
||||
sampler_entry(4),
|
||||
sampler_entry(5),
|
||||
texture_entry(6, wgpu::TextureSampleType::Depth),
|
||||
comparison_entry(7),
|
||||
texture_entry(8, float),
|
||||
texture_entry(9, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(10, float),
|
||||
];
|
||||
entries.extend(pack_texture_entries(frame.textures.len()));
|
||||
entries
|
||||
.extend((0..targets.len() as u32).map(|j| {
|
||||
texture_entry(horizon_shader_api::stages::POST_TARGET_BINDING + j, float)
|
||||
}));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
entries: &[
|
||||
frame_entry(wgpu::ShaderStages::FRAGMENT),
|
||||
texture_entry(1, float),
|
||||
texture_entry(2, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(3, float),
|
||||
sampler_entry(4),
|
||||
sampler_entry(5),
|
||||
texture_entry(8, float),
|
||||
],
|
||||
entries: &entries,
|
||||
});
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
groups[GROUP as usize] = Some(&layout);
|
||||
@@ -446,7 +699,11 @@ impl NativePost {
|
||||
entry_point: Some("horizon_fs_post"),
|
||||
compilation_options: options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: if i + 1 == count { format } else { INTERMEDIATE },
|
||||
format: if plan[i].writes == Writes::Screen {
|
||||
format
|
||||
} else {
|
||||
INTERMEDIATE
|
||||
},
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -470,7 +727,14 @@ impl NativePost {
|
||||
Ok(Self {
|
||||
passes,
|
||||
layout,
|
||||
textures: frame.textures.clone(),
|
||||
frame: frame.buffer.clone(),
|
||||
shadow: (
|
||||
frame.shadow_map.view.clone(),
|
||||
frame.shadow_map.sampler.clone(),
|
||||
frame.shadow_map.translucent_view.clone(),
|
||||
frame.shadow_map.color_view.clone(),
|
||||
),
|
||||
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
|
||||
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
|
||||
targets: None,
|
||||
@@ -478,6 +742,9 @@ impl NativePost {
|
||||
history_blank: history_target(device, "Native shader pack history blank", [1, 1]),
|
||||
groups: None,
|
||||
history_valid: false,
|
||||
scales: targets.iter().map(|t| t.1).collect(),
|
||||
named: None,
|
||||
plan,
|
||||
format,
|
||||
})
|
||||
}
|
||||
@@ -494,7 +761,39 @@ impl NativePost {
|
||||
size: [u32; 2],
|
||||
history_reset: bool,
|
||||
) {
|
||||
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
|
||||
if !self.scales.is_empty() && self.named.as_ref().is_none_or(|n| n.0 != size) {
|
||||
let views = self
|
||||
.scales
|
||||
.iter()
|
||||
.map(|&scale| {
|
||||
let size = scaled(size, scale);
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Native shader pack target"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: INTERMEDIATE,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
})
|
||||
.collect();
|
||||
self.named = Some((size, views));
|
||||
self.groups = None;
|
||||
}
|
||||
let chain = self
|
||||
.plan
|
||||
.iter()
|
||||
.any(|p| matches!(p.writes, Writes::Chain(_)));
|
||||
if chain && self.targets.as_ref().is_none_or(|t| t.0 != size) {
|
||||
let target = |label| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -545,46 +844,79 @@ impl NativePost {
|
||||
.expect("history exists after a post stage has more than one pass")
|
||||
}
|
||||
.create_view(&Default::default());
|
||||
let blank = self.history_blank.create_view(&Default::default());
|
||||
let groups = (0..self.passes.len())
|
||||
.map(|i| {
|
||||
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
|
||||
let previous = match (&self.targets, i) {
|
||||
(Some((_, t)), 1..) => &t[(i - 1) % 2],
|
||||
// The chain's picture so far: the world's before its first pass.
|
||||
let previous = match (&self.targets, self.plan[i].reads) {
|
||||
(Some((_, t)), Some(slot)) => &t[slot],
|
||||
_ => world.0,
|
||||
};
|
||||
let mut entries = vec![
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.frame.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(world.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(world.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(previous),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(&history),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
|
||||
},
|
||||
];
|
||||
entries.extend(pack_texture_group_entries(&self.textures));
|
||||
// The pack's targets, a blank one in place of the one this pass writes.
|
||||
if let Some((_, views)) = &self.named {
|
||||
entries.extend(views.iter().enumerate().map(|(j, view)| {
|
||||
let view = if self.plan[i].writes == Writes::Target(j) {
|
||||
&blank
|
||||
} else {
|
||||
view
|
||||
};
|
||||
wgpu::BindGroupEntry {
|
||||
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
}
|
||||
}));
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.frame.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(world.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(world.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(previous),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(&history),
|
||||
},
|
||||
],
|
||||
entries: &entries,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -611,8 +943,9 @@ impl NativePost {
|
||||
return;
|
||||
};
|
||||
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
|
||||
let view = match &self.targets {
|
||||
Some((_, t)) if i + 1 < self.passes.len() => &t[i % 2],
|
||||
let view = match (self.plan[i].writes, &self.targets, &self.named) {
|
||||
(Writes::Chain(slot), Some((_, t)), _) => &t[slot],
|
||||
(Writes::Target(j), _, Some((_, n))) => &n[j],
|
||||
_ => screen,
|
||||
};
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -634,14 +967,17 @@ impl NativePost {
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
// The last pass's input becomes next frame's `history`, once that pass has read this
|
||||
// frame's.
|
||||
let count = self.passes.len();
|
||||
if count > 1
|
||||
// The chain's last picture becomes next frame's `history`, once every pass has read
|
||||
// this frame's.
|
||||
let before_last = &self.plan[..self.plan.len() - 1];
|
||||
if let Some(Writes::Chain(slot)) = before_last
|
||||
.iter()
|
||||
.rfind(|p| matches!(p.writes, Writes::Chain(_)))
|
||||
.map(|p| p.writes)
|
||||
&& let (Some(history), Some((_, targets))) = (&self.history, &self.targets)
|
||||
{
|
||||
encoder.copy_texture_to_texture(
|
||||
targets[(count - 2) % 2].texture().as_image_copy(),
|
||||
targets[slot].texture().as_image_copy(),
|
||||
history.as_image_copy(),
|
||||
history.size(),
|
||||
);
|
||||
@@ -676,7 +1012,13 @@ fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
|
||||
}
|
||||
|
||||
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
|
||||
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
|
||||
pub fn frame_inputs(
|
||||
frame: &Frame,
|
||||
size: [u32; 2],
|
||||
seconds: f32,
|
||||
index: u32,
|
||||
shadow: Option<&horizon_shaders::Shadow>,
|
||||
) -> FrameInputs {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
|
||||
@@ -748,11 +1090,147 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
|
||||
water_plane: frame.water_plane.unwrap_or(0.0),
|
||||
has_reflection: u32::from(frame.water_plane.is_some()),
|
||||
reflection_resolution: reflection_size(size).map(|s| s as f32),
|
||||
mods: pack.mod_params,
|
||||
light_direction: match shadow {
|
||||
Some(s) => super::pack_uniforms::shadow_light(frame, s),
|
||||
None => Vec3::Y,
|
||||
}
|
||||
.to_array(),
|
||||
has_shadow: u32::from(shadow.is_some()),
|
||||
shadow_view_proj: m(shadow.map_or(Mat4::IDENTITY, |s| {
|
||||
super::pack_uniforms::shadow_culling(frame, s)
|
||||
})),
|
||||
shadow_distance: shadow.map_or(0.0, |s| s.distance),
|
||||
shadow_distortion: shadow.map_or(0.0, |s| {
|
||||
super::native_shadow::distortion(s.distance, s.resolution)
|
||||
}),
|
||||
shadow_resolution: shadow.map_or(0.0, |s| s.resolution as f32),
|
||||
shadow_center_texel: shadow.map_or(0.0, |s| {
|
||||
let k = super::native_shadow::distortion(s.distance, s.resolution);
|
||||
2.0 * s.distance / s.resolution.max(1) as f32 * (1.0 - k)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pack's `[[textures]]` (`check::Image`s), uploaded with samplers as the manifest says.
|
||||
pub fn pack_textures(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
report: &horizon_shader_api::Report,
|
||||
) -> Vec<(wgpu::TextureView, wgpu::Sampler)> {
|
||||
report
|
||||
.textures
|
||||
.iter()
|
||||
.map(|(t, image)| {
|
||||
let texture = wgpu::util::DeviceExt::create_texture_with_data(
|
||||
device,
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some(&t.id),
|
||||
size: wgpu::Extent3d {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&image.rgba,
|
||||
);
|
||||
let filter = if t.linear {
|
||||
wgpu::FilterMode::Linear
|
||||
} else {
|
||||
wgpu::FilterMode::Nearest
|
||||
};
|
||||
let address = if t.repeat {
|
||||
wgpu::AddressMode::Repeat
|
||||
} else {
|
||||
wgpu::AddressMode::ClampToEdge
|
||||
};
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some(&t.id),
|
||||
address_mode_u: address,
|
||||
address_mode_v: address,
|
||||
mag_filter: filter,
|
||||
min_filter: filter,
|
||||
..Default::default()
|
||||
});
|
||||
(texture.create_view(&Default::default()), sampler)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Layout entries of `count` pack textures and their samplers.
|
||||
pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEntry> {
|
||||
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
|
||||
(0..count as u32)
|
||||
.flat_map(|i| {
|
||||
[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: TEXTURE_BINDING + i,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: TEXTURE_SAMPLER_BINDING + i,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Group entries of the pack's textures, as `pack_texture_entries` lays them out.
|
||||
pub(super) fn pack_texture_group_entries(
|
||||
textures: &[(wgpu::TextureView, wgpu::Sampler)],
|
||||
) -> Vec<wgpu::BindGroupEntry<'_>> {
|
||||
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
|
||||
textures
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, (view, sampler))| {
|
||||
[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: TEXTURE_BINDING + i as u32,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: TEXTURE_SAMPLER_BINDING + i as u32,
|
||||
resource: wgpu::BindingResource::Sampler(sampler),
|
||||
},
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_pass_with_an_output_leaves_the_chain_as_it_was() {
|
||||
use super::{Writes, plan};
|
||||
// Bloom into a target, a pass of the chain, a second target, the picture.
|
||||
let p = plan(&[Some(0), None, Some(1), None, None]);
|
||||
let writes: Vec<_> = p.iter().map(|p| p.writes).collect();
|
||||
assert!(writes[0] == Writes::Target(0));
|
||||
assert!(writes[1] == Writes::Chain(0));
|
||||
assert!(writes[2] == Writes::Target(1));
|
||||
assert!(writes[3] == Writes::Chain(1));
|
||||
assert!(writes[4] == Writes::Screen);
|
||||
let reads: Vec<_> = p.iter().map(|p| p.reads).collect();
|
||||
assert_eq!(reads, [None, None, Some(0), Some(0), Some(1)]);
|
||||
}
|
||||
#[test]
|
||||
fn eased_values_start_at_the_target_and_halve_the_distance() {
|
||||
use super::eased;
|
||||
@@ -814,7 +1292,7 @@ mod tests {
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let frame = NativeFrame::new(&device);
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
@@ -823,6 +1301,7 @@ mod tests {
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
report.stages,
|
||||
&report.manifest,
|
||||
&[("gain".into(), 1.0)],
|
||||
format,
|
||||
&frame,
|
||||
@@ -933,8 +1412,16 @@ file = \"b.wgsl\"
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let frame = NativeFrame::new(&device);
|
||||
let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
report.stages,
|
||||
&report.manifest,
|
||||
&[],
|
||||
format,
|
||||
&frame,
|
||||
)
|
||||
.unwrap();
|
||||
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = texture(&device, format, usage).create_view(&Default::default());
|
||||
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
|
||||
//! solid terrain and entities the light (the sun by day, the moon by night) sees are drawn into a square depth
|
||||
//! map, which the material and post stages read as `shadow_map`; translucent terrain goes over a
|
||||
//! copy of it, `shadow_map_translucent`, with its colour in `shadow_color`. The shadow camera is the one
|
||||
//! `shaders/` packs use (`pack_uniforms::shadow_camera`), so the renderer's shadow visibility and
|
||||
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
|
||||
//! prelude's `shadow_position` undoes.
|
||||
|
||||
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::native_material::TerrainShape;
|
||||
use super::wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
|
||||
const PREFIX: &str = "horizon_terrain_";
|
||||
|
||||
/// The shadow pass's entries: terrain's vertices through the shadow camera, distorted, and the
|
||||
/// cutout test (leaves' and plants' holes let the light through).
|
||||
const ENTRIES: &str = "
|
||||
struct HorizonShadowOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) @interpolate(flat) layer: u32,
|
||||
@location(2) tint: vec3<f32>,
|
||||
@location(3) @interpolate(flat) fluid: u32,
|
||||
}
|
||||
// `color` is the vertex colour: the tint, and the fluid flag in its alpha's high nibble
|
||||
// (mesher.rs's `fluid_face`).
|
||||
fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>)
|
||||
-> HorizonShadowOut {
|
||||
let clip = frame.shadow_view_proj * vec4(pos, 1.0);
|
||||
let xy = shadow_distort(clip.xy);
|
||||
var out: HorizonShadowOut;
|
||||
out.clip = vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0);
|
||||
out.uv = uv;
|
||||
out.layer = layer;
|
||||
out.tint = color.rgb;
|
||||
let high = u32(round(color.a * 255.0)) >> 4u;
|
||||
out.fluid = u32(high >= 8u && (high & 7u) < 6u);
|
||||
return out;
|
||||
}
|
||||
@vertex fn horizon_shadow_vs(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>,
|
||||
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
|
||||
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
|
||||
}
|
||||
@vertex fn horizon_shadow_vs_flat(
|
||||
@builtin(vertex_index) index: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
|
||||
@location(3) color: vec4<f32>
|
||||
) -> HorizonShadowOut {
|
||||
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
|
||||
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
|
||||
}
|
||||
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
|
||||
if alpha < 0.5 && (in.layer & 0x8000u) == 0u && !horizon_shadow_solid(in.layer & 0x7fffu) { discard; }
|
||||
}
|
||||
// Translucent terrain: its colour where it is the nearest thing the light passes through;
|
||||
// alpha 1 marks a fluid, other surfaces keep their alpha below it.
|
||||
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
if color.a < 0.01 { discard; }
|
||||
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
|
||||
}
|
||||
";
|
||||
|
||||
/// The entity draw of the shadow pass: the renderer's opaque entity quads (`EntityBatch`'s first
|
||||
/// material) through the shadow camera, with their texture's cutout. The sky's bodies, clouds,
|
||||
/// beacon beams and breaking cracks share that buffer but are unlit (entity.wgsl's overlays) and
|
||||
/// cast nothing. A quad's layer holds the overlay in its low 16 bits.
|
||||
const ENTITY_ENTRIES: &str = "
|
||||
@group(0) @binding(1) var horizon_atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var horizon_atlas_sampler: sampler;
|
||||
struct HorizonEntityShadowOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) alpha: f32,
|
||||
}
|
||||
@vertex fn horizon_entity_shadow_vs(@location(0) position: vec3<f32>,
|
||||
@location(1) uv: vec2<f32>, @location(2) layer: u32, @location(3) color: vec4<f32>)
|
||||
-> HorizonEntityShadowOut {
|
||||
let clip = frame.shadow_view_proj * vec4(position, 1.0);
|
||||
let xy = shadow_distort(clip.xy);
|
||||
let overlay = layer & 0xffffu;
|
||||
// As entity.wgsl's `unlit`.
|
||||
let unlit = overlay == 2u || overlay == 4u || overlay == 6u
|
||||
|| (overlay >= 9u && overlay <= 12u);
|
||||
var out: HorizonEntityShadowOut;
|
||||
// Unlit geometry lands outside the clip volume.
|
||||
out.clip = select(vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0), vec4(2.0, 2.0, 2.0, 1.0), unlit);
|
||||
out.uv = uv;
|
||||
out.alpha = color.a;
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_entity_shadow_fs(in: HorizonEntityShadowOut) {
|
||||
let alpha = textureSampleLevel(horizon_atlas, horizon_atlas_sampler, in.uv, 0.0).a;
|
||||
if alpha * in.alpha < 0.5 { discard; }
|
||||
}
|
||||
";
|
||||
|
||||
/// `shadow_color`'s format.
|
||||
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
|
||||
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
|
||||
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
|
||||
/// the nearest each frame and they shimmer; a still one maps the world to the same texels until
|
||||
/// the next cell. Its depth starts 256 blocks further toward the light than `shaders/` packs'
|
||||
/// (the projection's near plane is at `-near`), so the ground over a deep cave still casts its
|
||||
/// shadow, and ends past the build height and the slant across the map below the camera.
|
||||
pub fn camera(settings: &Settings) -> Shadow {
|
||||
Shadow {
|
||||
resolution: settings.resolution,
|
||||
distance: settings.distance,
|
||||
render_mul: 1.0,
|
||||
interval: SNAP,
|
||||
euclidean_snap: true,
|
||||
near: 256.0,
|
||||
far: super::pack_uniforms::SHADOW_EYE + 2.0 * settings.distance + 384.0,
|
||||
..Shadow::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `settings` with the resolution `device` can make.
|
||||
pub fn fitted(device: &wgpu::Device, settings: &Settings) -> Settings {
|
||||
Settings {
|
||||
resolution: settings
|
||||
.resolution
|
||||
.min(device.limits().max_texture_dimension_2d),
|
||||
..settings.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell the shadow camera keeps to, in blocks along the world's axes: the player stays
|
||||
/// within 7 blocks of the centre (half the cell on each axis), where the map is still over
|
||||
/// three times as fine as at its edge.
|
||||
const SNAP: f32 = 8.0;
|
||||
|
||||
/// The ground one shadow texel covers right under the camera, in blocks: a 32nd, half a texture
|
||||
/// pixel, so shadow edges near the player stay as sharp as the blocks casting them.
|
||||
const CENTRE_TEXEL: f32 = 1.0 / 32.0;
|
||||
|
||||
/// How strongly the map is distorted toward its centre (`Frame::shadow_distortion`).
|
||||
/// `xy / (|xy| k + 1 - k)` magnifies the centre by 1 / (1 - k); an undistorted texel covers
|
||||
/// 2 distance / resolution blocks, so k is what brings that down to `CENTRE_TEXEL`. None when
|
||||
/// the map is already that fine; at most 0.95, past which the edge of the map gets too few texels.
|
||||
pub fn distortion(distance: f32, resolution: u32) -> f32 {
|
||||
let texel = 2.0 * distance / resolution.max(1) as f32;
|
||||
(1.0 - CENTRE_TEXEL / texel).clamp(0.0, 0.95)
|
||||
}
|
||||
|
||||
/// The shadow map: a square depth map of the pack's resolution, or a 1×1 one (cleared to "far",
|
||||
/// so everything is lit) for a pack without `[shadow]`; the same with translucent terrain drawn
|
||||
/// in, and that terrain's colour.
|
||||
pub struct ShadowMap {
|
||||
depth: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
translucent: wgpu::Texture,
|
||||
pub translucent_view: wgpu::TextureView,
|
||||
pub color_view: wgpu::TextureView,
|
||||
pub sampler: wgpu::Sampler,
|
||||
/// The 1×1 map still holds the zeroes it was made with.
|
||||
blank_uncleared: bool,
|
||||
}
|
||||
|
||||
impl ShadowMap {
|
||||
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
|
||||
let size = settings.map_or(1, |s| s.resolution);
|
||||
let texture = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let depth = texture(
|
||||
"Native shader pack shadow map",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let translucent = texture(
|
||||
"Native shader pack translucent shadow map",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
let color = texture(
|
||||
"Native shader pack shadow colour",
|
||||
SHADOW_COLOR,
|
||||
wgpu::TextureUsages::empty(),
|
||||
);
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Native shader pack shadow comparison"),
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
Self {
|
||||
view: depth.create_view(&Default::default()),
|
||||
depth,
|
||||
translucent_view: translucent.create_view(&Default::default()),
|
||||
translucent,
|
||||
color_view: color.create_view(&Default::default()),
|
||||
sampler,
|
||||
blank_uncleared: settings.is_none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A depth pass into the map that clears it to "far".
|
||||
pub fn begin<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0))
|
||||
}
|
||||
|
||||
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
|
||||
/// copied: where one casts, the solid map is the darker and decides.
|
||||
pub fn begin_entities<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
self.depth_pass(encoder, label, wgpu::LoadOp::Load)
|
||||
}
|
||||
|
||||
fn depth_pass<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
load: wgpu::LoadOp<f32>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The translucent pass: the solid terrain's map copied into the translucent one, which it
|
||||
/// goes on drawing into, and the colour cleared to white (nothing to pass through).
|
||||
pub fn begin_translucent<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.copy_texture_to_texture(
|
||||
self.depth.as_image_copy(),
|
||||
self.translucent.as_image_copy(),
|
||||
self.depth.size(),
|
||||
);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.color_view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.translucent_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Clears the 1×1 map of a pack without `[shadow]` once.
|
||||
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
if std::mem::take(&mut self.blank_uncleared) {
|
||||
drop(self.begin(encoder, "Native shader pack shadow map clear"));
|
||||
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pipelines that draw terrain into the shadow map, for the renderer's instanced draws and,
|
||||
/// where they run, its flat ones, and the frame they read (group 3, the uniform alone: the
|
||||
/// stages' own group 3 also holds the map being drawn).
|
||||
pub struct NativeShadow {
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub flat: Option<wgpu::RenderPipeline>,
|
||||
/// The translucent terrain's draws into the translucent map and the colour, instanced and
|
||||
/// flat.
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub flat_translucent: Option<wgpu::RenderPipeline>,
|
||||
/// The entities' draw, with the entities' group 0 (`ENTITY_ENTRIES`).
|
||||
pub entity: wgpu::RenderPipeline,
|
||||
pub group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// The entity draw's shader.
|
||||
fn entity_source() -> String {
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
)
|
||||
}
|
||||
|
||||
/// The shadow pass's entity pipeline: the entities' vertices and group 0, the frame at group 3.
|
||||
fn entity_pipeline(
|
||||
device: &wgpu::Device,
|
||||
entities: &wgpu::BindGroupLayout,
|
||||
frame_layout: &wgpu::BindGroupLayout,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Native shader pack entity shadow"),
|
||||
source: wgpu::ShaderSource::Wgsl(entity_source().into()),
|
||||
});
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
groups[0] = Some(entities);
|
||||
groups[GROUP as usize] = Some(frame_layout);
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Native shader pack entity shadow"),
|
||||
bind_group_layouts: &groups,
|
||||
..Default::default()
|
||||
});
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Native shader pack entity shadow"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_entity_shadow_vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
// Both sides, as the terrain: thin parts (ears, item quads) cast from either.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 1.5,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_entity_shadow_fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `horizon_shadow_solid(layer)`: whether the sprite layer's id, out of `materials`
|
||||
/// (`(layer, id)`), is one of the pack's `solid` ones.
|
||||
fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
|
||||
let layers: Vec<String> = materials
|
||||
.iter()
|
||||
.filter(|(_, id)| solid.contains(id))
|
||||
.map(|(layer, _)| format!("{layer}u"))
|
||||
.collect();
|
||||
let cases = if layers.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" case {}: {{ return true; }}\n", layers.join(", "))
|
||||
};
|
||||
format!(
|
||||
"fn horizon_shadow_solid(layer: u32) -> bool {{\n switch layer {{\n{cases} \
|
||||
default: {{ return false; }}\n }}\n}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
|
||||
/// `solid` of `shadow_solid`.
|
||||
fn shader_source(solid: &str) -> String {
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
|
||||
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
prefixed(
|
||||
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
|
||||
PREFIX
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
impl NativeShadow {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shape: &TerrainShape<'_>,
|
||||
frame: &super::native_post::NativeFrame,
|
||||
solid_materials: &[u32],
|
||||
) -> Result<Self, String> {
|
||||
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
|
||||
let constants = [
|
||||
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
|
||||
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
|
||||
];
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let frame_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack shadow frame"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack shadow frame"),
|
||||
layout: &frame_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: frame.buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
|
||||
Default::default();
|
||||
groups[0] = Some(shape.globals);
|
||||
groups[1] = flat;
|
||||
groups[GROUP as usize] = Some(&frame_layout);
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
bind_group_layouts: &groups,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let vertices = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &super::TERRAIN_ATTRIBUTES,
|
||||
};
|
||||
let instances = wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance_attributes,
|
||||
};
|
||||
let color_target = [Some(wgpu::ColorTargetState {
|
||||
format: SHADOW_COLOR,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})];
|
||||
let pipeline = |layout: &wgpu::PipelineLayout,
|
||||
vertex: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
translucent: bool| {
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: options.clone(),
|
||||
buffers,
|
||||
},
|
||||
// Both sides: the quads that face the light are drawn (`facing_runs`), and
|
||||
// plants' crossed quads cast from either side.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 1.5,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(if translucent {
|
||||
"horizon_shadow_fs_translucent"
|
||||
} else {
|
||||
"horizon_shadow_fs"
|
||||
}),
|
||||
compilation_options: options,
|
||||
targets: if translucent { &color_target } else { &[] },
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let instanced = layout(None);
|
||||
let solid = pipeline(
|
||||
&instanced,
|
||||
"horizon_shadow_vs",
|
||||
&[Some(vertices.clone()), Some(instances.clone())],
|
||||
false,
|
||||
);
|
||||
let translucent = pipeline(
|
||||
&instanced,
|
||||
"horizon_shadow_vs",
|
||||
&[Some(vertices.clone()), Some(instances)],
|
||||
true,
|
||||
);
|
||||
let flat_layout = shape.flat.map(|group| layout(Some(group)));
|
||||
let flat_pipeline = |translucent| {
|
||||
flat_layout.as_ref().map(|layout| {
|
||||
pipeline(
|
||||
layout,
|
||||
"horizon_shadow_vs_flat",
|
||||
&[Some(vertices.clone())],
|
||||
translucent,
|
||||
)
|
||||
})
|
||||
};
|
||||
let flat = flat_pipeline(false);
|
||||
let flat_translucent = flat_pipeline(true);
|
||||
let entity = entity_pipeline(device, shape.entities, &frame_layout);
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(format!("shadow pass: {e}"));
|
||||
}
|
||||
Ok(Self {
|
||||
solid,
|
||||
flat,
|
||||
translucent,
|
||||
flat_translucent,
|
||||
entity,
|
||||
group,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_shadow_shader_validates() {
|
||||
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
|
||||
let text = shader_source(&shadow_solid(materials, &[7]));
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_layers_of_solid_ids_lose_their_cutouts() {
|
||||
let materials = [(3, 7), (4, 8), (9, 7)];
|
||||
assert!(shadow_solid(&materials, &[7]).contains("case 3u, 9u: { return true; }"));
|
||||
assert!(!shadow_solid(&materials, &[]).contains("case"));
|
||||
assert!(!shadow_solid(&materials, &[5]).contains("case"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_entity_shader_validates() {
|
||||
let text = entity_source();
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
|
||||
let settings = Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
};
|
||||
let shadow = camera(&settings);
|
||||
let (view, projection) = super::super::pack_uniforms::shadow_camera(
|
||||
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
|
||||
glam::DVec3::new(-2064.5, 246.0, -4304.5),
|
||||
&shadow,
|
||||
);
|
||||
// Up to the build height's span below a player flying high, as well as above them.
|
||||
for y in [64.0, 0.0, -64.0, -200.0, -384.0] {
|
||||
let clip = projection * view * glam::Vec4::new(0.0, y, 0.0, 1.0);
|
||||
assert!(clip.is_finite(), "{clip}");
|
||||
assert!((-1.0..=1.0).contains(&clip.z), "{clip}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
|
||||
let shadow = camera(&Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
});
|
||||
let light = glam::Vec3::Y;
|
||||
for x in [5.5, -5.5, -2064.5, 2064.5] {
|
||||
let camera = glam::DVec3::new(x, 64.0, x);
|
||||
let (view, _) = super::super::pack_uniforms::shadow_camera(light, camera, &shadow);
|
||||
let centre = view.inverse().w_axis.truncate();
|
||||
assert!(centre.x.abs() <= SNAP / 2.0, "{x}: {centre}");
|
||||
assert!(centre.z.abs() <= SNAP / 2.0, "{x}: {centre}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_distortion_brings_the_centre_texel_to_a_32nd_of_a_block() {
|
||||
// 2048 texels over 256 blocks: an 8th of a block each, magnified 4 times.
|
||||
assert!((distortion(128.0, 2048) - 0.75).abs() < 1e-6);
|
||||
// Already that fine: no distortion.
|
||||
assert_eq!(distortion(32.0, 2048), 0.0);
|
||||
assert_eq!(distortion(10_000.0, 1024), 0.95);
|
||||
}
|
||||
}
|
||||
@@ -252,7 +252,7 @@ mod tests {
|
||||
resource: globals_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let frame = NativeFrame::new(&device);
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
|
||||
@@ -176,7 +176,7 @@ impl Features {
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("text", "entity", None, offset),
|
||||
kind("text", "entity", Some(wgpu::Face::Back), offset),
|
||||
),
|
||||
crumbling: set(
|
||||
&entity_module,
|
||||
@@ -217,7 +217,12 @@ impl Features {
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("additive entities", "lightning", None, PLAIN),
|
||||
kind(
|
||||
"additive entities",
|
||||
"lightning",
|
||||
Some(wgpu::Face::Back),
|
||||
PLAIN,
|
||||
),
|
||||
),
|
||||
weather: set(
|
||||
&entity_module,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use glam::{DMat4, DVec3};
|
||||
use horizon_shaders::{Format, Group, Kind, Post, Val};
|
||||
|
||||
use super::pack_draw::DrawUniforms;
|
||||
@@ -434,9 +435,33 @@ pub(super) struct Weather {
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// Shadow cache: the key of the shadow map the shadow targets hold. Only the
|
||||
/// shadow passes write those targets, so while the key matches they still hold
|
||||
/// it and the pass is skipped.
|
||||
#[derive(Default)]
|
||||
struct ShadowCache {
|
||||
key: Option<ShadowCacheKey>,
|
||||
}
|
||||
|
||||
/// Key for validating the shadow cache.
|
||||
/// Must match the cache state exactly to be considered valid.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct ShadowCacheKey {
|
||||
sun_angle: f32,
|
||||
moon_angle: f32,
|
||||
snapped_camera: Option<DVec3>,
|
||||
shadow_settings_hash: u64,
|
||||
shadow_scene_revision: (u64, u64),
|
||||
ticks: u64,
|
||||
day_time: i64,
|
||||
shadow_uniforms_hash: u64,
|
||||
}
|
||||
|
||||
pub struct PackPost {
|
||||
pub notes: Vec<String>,
|
||||
passes: Vec<Pass>,
|
||||
/// Shadow cache for temporal reprojection.
|
||||
shadow_cache: ShadowCache,
|
||||
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`.
|
||||
terrain: Option<Pass>,
|
||||
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
|
||||
@@ -2330,6 +2355,7 @@ impl PackPost {
|
||||
u.shadow = shadow_config;
|
||||
u
|
||||
},
|
||||
shadow_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3322,6 +3348,45 @@ impl PackPost {
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
frame: &super::Frame,
|
||||
) {
|
||||
// Opt-in only (`horizon-graphics.txt` shadowCache). Off: always redraw, drop any cache.
|
||||
if !frame.shadow_cache {
|
||||
self.shadow_cache = ShadowCache::default();
|
||||
self.draw_shadow_pass(device, queue, encoder, terrain);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the current frame's cache key before borrowing shadow_targets mutably.
|
||||
let cache_key = self.compute_shadow_cache_key(frame);
|
||||
|
||||
// Entity vertices are interpolated each frame, so their shadows cannot be reused.
|
||||
let has_shadow_entities = self
|
||||
.entities
|
||||
.as_ref()
|
||||
.is_some_and(|entities| entities.geometry.draws.iter().any(|draw| draw.shadow));
|
||||
// Custom images are cleared each frame (`pack_images::clear`) before the
|
||||
// shadow pass writes them, so a pack with any redraws it too.
|
||||
let cache_hit = !has_shadow_entities
|
||||
&& self.images.is_empty()
|
||||
&& self.shadow_cache.key == Some(cache_key)
|
||||
&& self.shadow_targets.is_some();
|
||||
if cache_hit {
|
||||
// The targets still hold this map (and `main`, the A/B side
|
||||
// `shadowcomp` left current).
|
||||
return;
|
||||
}
|
||||
self.draw_shadow_pass(device, queue, encoder, terrain);
|
||||
self.shadow_cache.key = self.shadow_targets.is_some().then_some(cache_key);
|
||||
}
|
||||
|
||||
/// The shadow pass body: terrain, entities, clears, and `shadowcomp`.
|
||||
fn draw_shadow_pass(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
let Some(t) = &mut self.shadow_targets else {
|
||||
return;
|
||||
@@ -3397,6 +3462,106 @@ impl PackPost {
|
||||
self.shadow_comp = comp;
|
||||
}
|
||||
|
||||
/// Computes the cache key for the current frame.
|
||||
fn compute_shadow_cache_key(&self, frame: &super::Frame) -> ShadowCacheKey {
|
||||
let shadow = &self.uniforms.shadow;
|
||||
// Use the same logic as shadow_camera to compute the light direction and snapped camera.
|
||||
// Use the public shadow_light function which returns the normalized light direction.
|
||||
let light = super::pack_uniforms::shadow_light(frame, shadow);
|
||||
let light = light.as_dvec3();
|
||||
let up = if light.z.abs() > 0.99 {
|
||||
DVec3::Y
|
||||
} else {
|
||||
DVec3::Z
|
||||
};
|
||||
let rotation = DMat4::look_to_rh(DVec3::ZERO, -light, up);
|
||||
let at = rotation.transform_vector3(frame.camera);
|
||||
let step = f64::from(shadow.interval);
|
||||
let snapped_camera = if step > 0.0 {
|
||||
Some((at / step).floor() * step)
|
||||
} else {
|
||||
Some(at)
|
||||
};
|
||||
|
||||
// Hash the shadow settings that affect the shadow map.
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
shadow.resolution.hash(&mut hasher);
|
||||
shadow.distance.to_bits().hash(&mut hasher);
|
||||
shadow.render_mul.to_bits().hash(&mut hasher);
|
||||
shadow.interval.to_bits().hash(&mut hasher);
|
||||
shadow.sun_path_rotation.to_bits().hash(&mut hasher);
|
||||
shadow.fov.map(|f| f.to_bits()).hash(&mut hasher);
|
||||
shadow.near.to_bits().hash(&mut hasher);
|
||||
shadow.far.to_bits().hash(&mut hasher);
|
||||
for hf in &shadow.hardware_filtering {
|
||||
hf.hash(&mut hasher);
|
||||
}
|
||||
for color in &shadow.colors {
|
||||
format!("{:?}", color.format).hash(&mut hasher);
|
||||
color.clear.hash(&mut hasher);
|
||||
if let Some(cc) = color.clear_color {
|
||||
for c in cc {
|
||||
c.to_bits().hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
color.mipmap.hash(&mut hasher);
|
||||
}
|
||||
let shadow_settings_hash = hasher.finish();
|
||||
let shadow_uniforms_hash = self.shadow_uniforms_hash();
|
||||
|
||||
ShadowCacheKey {
|
||||
sun_angle: frame.sky.sun_angle,
|
||||
moon_angle: frame.sky.moon_angle,
|
||||
snapped_camera,
|
||||
shadow_settings_hash,
|
||||
shadow_scene_revision: frame.shadow_scene_revision,
|
||||
ticks: frame.ticks,
|
||||
day_time: frame.pack.day_time,
|
||||
shadow_uniforms_hash,
|
||||
}
|
||||
}
|
||||
|
||||
fn shadow_uniforms_hash(&self) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
let passes = self
|
||||
.shadow_terrain
|
||||
.iter()
|
||||
.chain(self.shadow_water.iter())
|
||||
.chain(self.shadow_comp.iter())
|
||||
.chain(
|
||||
self.entities
|
||||
.iter()
|
||||
.flat_map(|entities| entities.shadow.iter().flatten()),
|
||||
);
|
||||
for pass in passes {
|
||||
pass.name.hash(&mut hasher);
|
||||
for (index, ty, offset) in &pass.uniforms {
|
||||
index.hash(&mut hasher);
|
||||
ty.hash(&mut hasher);
|
||||
offset.hash(&mut hasher);
|
||||
let Some(value) = index.and_then(|index| self.uniforms.values.get(index)) else {
|
||||
continue;
|
||||
};
|
||||
match value {
|
||||
pack_uniforms::Value::F(values) => {
|
||||
0u8.hash(&mut hasher);
|
||||
for value in values {
|
||||
value.to_bits().hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
pack_uniforms::Value::I(values) => {
|
||||
1u8.hash(&mut hasher);
|
||||
values.hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Runs the pack over the world drawn into `world` (colour) and `world_depth`, then draws
|
||||
/// the result onto `screen`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -3464,7 +3629,7 @@ impl PackPost {
|
||||
self.gpu.clear,
|
||||
&mut self.zeros,
|
||||
);
|
||||
self.run_shadow(device, queue, encoder, terrain);
|
||||
self.run_shadow(device, queue, encoder, terrain, frame);
|
||||
let Some(targets) = &self.targets else { return };
|
||||
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
@@ -3720,6 +3885,36 @@ mod tests {
|
||||
use super::*;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
fn shadow_cache_key_tracks_world_and_time_changes() {
|
||||
let key = ShadowCacheKey {
|
||||
sun_angle: 0.0,
|
||||
moon_angle: 180.0,
|
||||
snapped_camera: Some(DVec3::ZERO),
|
||||
shadow_settings_hash: 1,
|
||||
shadow_scene_revision: (2, 3),
|
||||
ticks: 4,
|
||||
day_time: 5,
|
||||
shadow_uniforms_hash: 6,
|
||||
};
|
||||
|
||||
assert_ne!(
|
||||
key,
|
||||
ShadowCacheKey {
|
||||
shadow_scene_revision: (8, 3),
|
||||
..key
|
||||
}
|
||||
);
|
||||
assert_ne!(key, ShadowCacheKey { day_time: 9, ..key });
|
||||
assert_ne!(
|
||||
key,
|
||||
ShadowCacheKey {
|
||||
shadow_uniforms_hash: 10,
|
||||
..key
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Two textures with a sampler each become two textures sharing one sampler variable.
|
||||
#[test]
|
||||
fn samplers_of_a_kind_share_one_variable() {
|
||||
|
||||
@@ -35,6 +35,8 @@ pub struct PackInputs {
|
||||
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
|
||||
pub held_items: [i32; 2],
|
||||
pub held_light: [i32; 2],
|
||||
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
|
||||
pub mod_params: [[f32; 4]; 8],
|
||||
}
|
||||
|
||||
/// One uniform's value: up to 16 floats or 3 integers.
|
||||
@@ -245,7 +247,7 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
|
||||
|
||||
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
|
||||
/// not of `shadowModelView` (Iris's matrices, measured).
|
||||
const SHADOW_EYE: f32 = 100.0;
|
||||
pub const SHADOW_EYE: f32 = 100.0;
|
||||
|
||||
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
|
||||
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
|
||||
@@ -264,7 +266,12 @@ pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4
|
||||
* DMat4::from_rotation_x(tilt);
|
||||
let step = f64::from(shadow.interval);
|
||||
let origin = if step > 0.0 {
|
||||
DVec3::splat(step / 2.0) - camera % step
|
||||
let remainder = if shadow.euclidean_snap {
|
||||
camera.rem_euclid(DVec3::splat(step))
|
||||
} else {
|
||||
camera % step
|
||||
};
|
||||
DVec3::splat(step / 2.0) - remainder
|
||||
} else {
|
||||
DVec3::ZERO
|
||||
};
|
||||
|
||||
@@ -61,6 +61,9 @@ impl<T> Resident<T> {
|
||||
pub fn values(&self) -> impl Iterator<Item = &T> {
|
||||
self.items.iter().map(|(_, v)| v)
|
||||
}
|
||||
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
|
||||
self.items.iter_mut().map(|(_, v)| v)
|
||||
}
|
||||
/// `key`'s value, when it is resident.
|
||||
pub fn find(&self, key: SectionKey) -> Option<&T> {
|
||||
self.slots.get(&key).map(|&slot| self.get(slot))
|
||||
|
||||
@@ -3,6 +3,10 @@ use crate::weather::{Time, Vertex};
|
||||
|
||||
pub(super) struct Weather {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// The layouts of groups 0 (the entities') and 1 (the fall clock), for a native pack's
|
||||
/// weather pipeline.
|
||||
pub entity_layout: wgpu::BindGroupLayout,
|
||||
pub time_layout: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
vertices: wgpu::Buffer,
|
||||
@@ -53,13 +57,7 @@ impl Weather {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
depth_stencil: Some(depth_stencil()),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
@@ -90,6 +88,8 @@ impl Weather {
|
||||
});
|
||||
Self {
|
||||
pipeline,
|
||||
entity_layout: entities.clone(),
|
||||
time_layout: bindings,
|
||||
group,
|
||||
uniform,
|
||||
vertices: Self::buffer(device, 4),
|
||||
@@ -144,11 +144,24 @@ impl Weather {
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
|
||||
}
|
||||
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
|
||||
/// Draws the streaks, through a native pack's weather stage when `native` is given (its
|
||||
/// pipeline and the pack's frame group).
|
||||
pub fn draw(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
entities: &wgpu::BindGroup,
|
||||
native: Option<(&wgpu::RenderPipeline, &wgpu::BindGroup)>,
|
||||
) {
|
||||
if self.count == 0 {
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
match native {
|
||||
Some((pipeline, frame)) => {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, frame, &[]);
|
||||
}
|
||||
None => pass.set_pipeline(&self.pipeline),
|
||||
}
|
||||
pass.set_bind_group(0, entities, &[]);
|
||||
pass.set_bind_group(1, &self.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
@@ -157,11 +170,181 @@ impl Weather {
|
||||
}
|
||||
}
|
||||
|
||||
/// RenderPipelines.WEATHER: depth-tested, but no depth write, so the water
|
||||
/// and glass drawn behind the rain still show through it.
|
||||
fn depth_stencil() -> wgpu::DepthStencilState {
|
||||
wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The entries around a native pack's `weather`: Horizon's weather vertex, then the texture
|
||||
/// as `fs_weather` reads it, handed to the pack with the light and the position.
|
||||
const NATIVE_ENTRIES: &str = "
|
||||
struct HorizonWeatherOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
@location(2) @interpolate(flat) rect: vec4<f32>,
|
||||
@location(3) light: vec2<f32>,
|
||||
@location(4) position: vec3<f32>,
|
||||
@location(5) @interpolate(flat) snow: u32,
|
||||
}
|
||||
@vertex fn horizon_weather_vs(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
|
||||
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
|
||||
@location(6) light: vec4<u32>, @location(7) snow: u32) -> HorizonWeatherOut {
|
||||
let phase = horizon_entity_weather_phase(animation, bases, snow,
|
||||
horizon_entity_weather_time.values);
|
||||
let v = horizon_entity_entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0));
|
||||
var out: HorizonWeatherOut;
|
||||
out.clip = v.clip;
|
||||
out.uv = v.uv;
|
||||
out.color = v.color;
|
||||
out.rect = rect;
|
||||
// entity_vertex's light: block in y, sky in x, 0..240.
|
||||
out.light = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
|
||||
out.position = position;
|
||||
out.snow = snow;
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_weather_fs(in: HorizonWeatherOut) -> @location(0) vec4<f32> {
|
||||
let texel = horizon_entity_weather_texel(in.uv, in.rect,
|
||||
textureDimensions(horizon_entity_atlas));
|
||||
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
|
||||
if color.a < 0.1 { discard; }
|
||||
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
|
||||
}
|
||||
";
|
||||
|
||||
/// The shader of a native pack's weather stage `source`.
|
||||
fn native_source(source: &str) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
|
||||
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
|
||||
)
|
||||
}
|
||||
|
||||
/// A native pack's weather pipeline: its `stage` (with the option values `constants`), then
|
||||
/// entity.wgsl with every name prefixed, then the entries above. Group 0 is the entities',
|
||||
/// 1 the fall clock (`time_layout`), 3 the pack's frame.
|
||||
pub(super) fn native_pipeline(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
entities: &wgpu::BindGroupLayout,
|
||||
time_layout: &wgpu::BindGroupLayout,
|
||||
frame: &super::native_post::NativeFrame,
|
||||
stage: &horizon_shader_api::check::CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
) -> Result<wgpu::RenderPipeline, String> {
|
||||
use horizon_shader_api::stages::GROUP;
|
||||
let text = native_source(&stage.source);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
|
||||
let present = super::native_post::present_constants(&module, constants);
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
groups[0] = Some(entities);
|
||||
groups[1] = Some(time_layout);
|
||||
groups[GROUP as usize] = Some(&frame.layout);
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Native shader pack weather"),
|
||||
bind_group_layouts: &groups,
|
||||
..Default::default()
|
||||
});
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.label),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants: &present,
|
||||
..Default::default()
|
||||
};
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
|
||||
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Native shader pack weather"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_weather_vs"),
|
||||
compilation_options: options.clone(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(depth_stencil()),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_weather_fs"),
|
||||
compilation_options: options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendState::ALPHA_BLENDING.color,
|
||||
// The frame's alpha stays as the surfaces left it (entities mark it 0).
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(format!("{}: {e}", stage.file));
|
||||
}
|
||||
Ok(pipeline)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::native_source;
|
||||
|
||||
#[test]
|
||||
fn a_native_weather_stage_validates() {
|
||||
use horizon_shader_api::stages::StageKind;
|
||||
let source = StageKind::Weather.prelude()
|
||||
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
|
||||
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
|
||||
let text = native_source(&source);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
}
|
||||
use crate::weather::{Parameters, Time};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
#[test]
|
||||
fn weather_writes_no_depth() {
|
||||
let depth = super::depth_stencil();
|
||||
assert_eq!(depth.depth_write_enabled, Some(false));
|
||||
assert_eq!(
|
||||
depth.depth_compare,
|
||||
Some(wgpu::CompareFunction::GreaterEqual)
|
||||
);
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Case {
|
||||
|
||||
@@ -313,6 +313,8 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
|
||||
/// The name the options screen waits on to bind `Graphics::hdr_key`.
|
||||
pub const HDR_KEY: &str = "horizon.hdrSettings";
|
||||
/// The Screenshot Quality choices, times the game's resolution.
|
||||
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
|
||||
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
|
||||
/// opens its surface; the settings open HDR's screen only then.
|
||||
@@ -362,10 +364,16 @@ pub struct Graphics {
|
||||
pub chunk_updates: u32,
|
||||
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
|
||||
pub upload_budget_kib: u32,
|
||||
/// The block textures' video memory (their top mip level), in MiB; 0 leaves it to
|
||||
/// Horizon (`sprites::DEFAULT_TEXTURE_BUDGET`). Applies when resources next load.
|
||||
pub texture_budget_mib: u32,
|
||||
pub sky: bool,
|
||||
pub fog: bool,
|
||||
pub weather: bool,
|
||||
pub animated_textures: bool,
|
||||
/// Dithers the small mip levels of cutouts from 64 texels up, so a high-resolution pack's
|
||||
/// fine plants keep their coverage in the distance; off is vanilla's look.
|
||||
pub dither_cutout_mips: bool,
|
||||
pub present_mode: PresentMode,
|
||||
pub pacing: Pacing,
|
||||
/// Chunk worker threads; 0 uses every core but one. Applies at the next start.
|
||||
@@ -379,6 +387,8 @@ pub struct Graphics {
|
||||
pub taa: bool,
|
||||
/// TAA upscaling; anything but `Native` comes with `taa` on.
|
||||
pub upscaling: Upscaling,
|
||||
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
|
||||
pub screenshot_quality: u32,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
@@ -399,6 +409,10 @@ pub struct Graphics {
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
pub battery: bool,
|
||||
/// Reuse the shader pack's last shadow map when sun, view, scene revision and
|
||||
/// pack uniforms still match. Off by default: vanilla loaders redraw every frame
|
||||
/// (or every `shadowInterval`), and a hit with a stale key shows old shadows.
|
||||
pub shadow_cache: bool,
|
||||
/// The graphics API (Windows only; see `GraphicsApi`).
|
||||
pub api: GraphicsApi,
|
||||
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
|
||||
@@ -416,10 +430,12 @@ impl Default for Graphics {
|
||||
entity_culling: false,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
texture_budget_mib: 0,
|
||||
sky: true,
|
||||
fog: true,
|
||||
weather: true,
|
||||
animated_textures: true,
|
||||
dither_cutout_mips: false,
|
||||
present_mode: PresentMode::Auto,
|
||||
pacing: Pacing::Efficient,
|
||||
workers: 0,
|
||||
@@ -428,6 +444,7 @@ impl Default for Graphics {
|
||||
fxaa: false,
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
screenshot_quality: 1,
|
||||
hdr: false,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
@@ -435,6 +452,7 @@ impl Default for Graphics {
|
||||
hdr_ui_brightness: 100,
|
||||
hdr_key: "key.keyboard.unknown".into(),
|
||||
battery: false,
|
||||
shadow_cache: false,
|
||||
api: GraphicsApi::Auto,
|
||||
shader_pack: String::new(),
|
||||
}
|
||||
@@ -480,10 +498,14 @@ impl Graphics {
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
"textureBudgetMiB" => {
|
||||
g.texture_budget_mib = number(g.texture_budget_mib).min(16384);
|
||||
}
|
||||
"sky" => g.sky = flag(g.sky),
|
||||
"fog" => g.fog = flag(g.fog),
|
||||
"weather" => g.weather = flag(g.weather),
|
||||
"animatedTextures" => g.animated_textures = flag(g.animated_textures),
|
||||
"ditherCutoutMips" => g.dither_cutout_mips = flag(g.dither_cutout_mips),
|
||||
"presentMode" => g.present_mode = PresentMode::parse(value),
|
||||
"framePacing" => {
|
||||
g.pacing = if value == "precise" {
|
||||
@@ -497,6 +519,11 @@ impl Graphics {
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"taa" => g.taa = flag(g.taa),
|
||||
"upscaling" => g.upscaling = Upscaling::parse(value),
|
||||
"screenshotQuality" => {
|
||||
g.screenshot_quality = Some(number(0))
|
||||
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
@@ -516,6 +543,7 @@ impl Graphics {
|
||||
}
|
||||
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
"overlay" => {
|
||||
@@ -532,7 +560,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -540,10 +568,12 @@ impl Graphics {
|
||||
bool_text(self.entity_culling),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
self.texture_budget_mib,
|
||||
bool_text(self.sky),
|
||||
bool_text(self.fog),
|
||||
bool_text(self.weather),
|
||||
bool_text(self.animated_textures),
|
||||
bool_text(self.dither_cutout_mips),
|
||||
self.present_mode.name(),
|
||||
match self.pacing {
|
||||
Pacing::Efficient => "efficient",
|
||||
@@ -559,6 +589,7 @@ impl Graphics {
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.taa),
|
||||
self.upscaling.name(),
|
||||
self.screenshot_quality,
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
@@ -566,6 +597,7 @@ impl Graphics {
|
||||
self.hdr_ui_brightness,
|
||||
self.hdr_key,
|
||||
bool_text(self.battery),
|
||||
bool_text(self.shadow_cache),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
)
|
||||
@@ -691,10 +723,12 @@ mod tests {
|
||||
entity_culling: true,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
texture_budget_mib: 1536,
|
||||
sky: false,
|
||||
fog: false,
|
||||
weather: false,
|
||||
animated_textures: false,
|
||||
dither_cutout_mips: true,
|
||||
present_mode: PresentMode::Mailbox,
|
||||
pacing: Pacing::Precise,
|
||||
workers: 6,
|
||||
@@ -703,6 +737,7 @@ mod tests {
|
||||
fxaa: true,
|
||||
taa: true,
|
||||
upscaling: Upscaling::Balanced,
|
||||
screenshot_quality: 4,
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
@@ -710,6 +745,7 @@ mod tests {
|
||||
hdr_ui_brightness: 60,
|
||||
hdr_key: "key.keyboard.h".into(),
|
||||
battery: true,
|
||||
shadow_cache: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
@@ -720,6 +756,19 @@ mod tests {
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
|
||||
// Only the button's choices.
|
||||
assert_eq!(
|
||||
Graphics::parse("screenshotQuality:8\n").screenshot_quality,
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("screenshotQuality:3\n").screenshot_quality,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("screenshotQuality:16\n").screenshot_quality,
|
||||
1
|
||||
);
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
|
||||
@@ -33,6 +33,7 @@ pub enum H {
|
||||
Fog,
|
||||
Weather,
|
||||
AnimatedTextures,
|
||||
DitherCutoutMips,
|
||||
PresentMode,
|
||||
Pacing,
|
||||
Workers,
|
||||
@@ -40,6 +41,7 @@ pub enum H {
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
ScreenshotQuality,
|
||||
Hdr,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
@@ -52,6 +54,7 @@ pub enum H {
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
BatteryMode,
|
||||
ShadowCache,
|
||||
GraphicsApi,
|
||||
}
|
||||
|
||||
@@ -259,6 +262,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Low,
|
||||
"Animates water, lava, fire and other moving textures.",
|
||||
),
|
||||
h(
|
||||
H::DitherCutoutMips,
|
||||
Low,
|
||||
"Keeps fine plants in high-res packs from fading far away.",
|
||||
),
|
||||
Row::Header("Display"),
|
||||
fov,
|
||||
fps,
|
||||
@@ -278,6 +286,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
High,
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
),
|
||||
h(
|
||||
H::ScreenshotQuality,
|
||||
None,
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
|
||||
),
|
||||
h(
|
||||
H::HdrSettings,
|
||||
None,
|
||||
@@ -313,6 +326,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.",
|
||||
),
|
||||
h(
|
||||
H::ShadowCache,
|
||||
Saves,
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.",
|
||||
),
|
||||
h(
|
||||
H::Workers,
|
||||
None,
|
||||
@@ -513,6 +531,7 @@ impl H {
|
||||
H::Fog => labelled("Fog", on_off(g.fog)),
|
||||
H::Weather => labelled("Weather", on_off(g.weather)),
|
||||
H::AnimatedTextures => labelled("Animated Textures", on_off(g.animated_textures)),
|
||||
H::DitherCutoutMips => labelled("Distant Plant Dither", on_off(g.dither_cutout_mips)),
|
||||
H::PresentMode => labelled("Present Mode", g.present_mode.label()),
|
||||
H::Pacing => labelled(
|
||||
"Frame Pacing",
|
||||
@@ -538,6 +557,9 @@ impl H {
|
||||
},
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::ScreenshotQuality => {
|
||||
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
|
||||
}
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
|
||||
@@ -571,6 +593,7 @@ impl H {
|
||||
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
|
||||
}
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::Overlay => labelled(
|
||||
"Performance Overlay",
|
||||
@@ -630,6 +653,7 @@ impl H {
|
||||
H::Fog => g.fog = !g.fog,
|
||||
H::Weather => g.weather = !g.weather,
|
||||
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
|
||||
H::DitherCutoutMips => g.dither_cutout_mips = !g.dither_cutout_mips,
|
||||
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
|
||||
// Off, FXAA, TAA.
|
||||
H::Antialiasing => {
|
||||
@@ -647,8 +671,16 @@ impl H {
|
||||
(g.fxaa, g.taa) = (false, true);
|
||||
}
|
||||
}
|
||||
H::ScreenshotQuality => {
|
||||
g.screenshot_quality = step(
|
||||
&crate::graphics::SCREENSHOT_QUALITIES,
|
||||
g.screenshot_quality,
|
||||
back,
|
||||
)
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
|
||||
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
|
||||
H::Pacing => {
|
||||
g.pacing = match g.pacing {
|
||||
@@ -709,8 +741,10 @@ impl Item {
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
Item::Horizon(H::Weather) => g.weather,
|
||||
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
|
||||
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Horizon(H::ShadowCache) => g.shadow_cache,
|
||||
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
|
||||
Item::Vanilla(Opt::Vsync) => s.vsync,
|
||||
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
|
||||
@@ -766,10 +800,12 @@ impl Item {
|
||||
Item::Horizon(
|
||||
H::Preset
|
||||
| H::Overlay
|
||||
| H::ScreenshotQuality
|
||||
| H::Pacing
|
||||
| H::PresentMode
|
||||
| H::Workers
|
||||
| H::BatteryMode
|
||||
| H::ShadowCache
|
||||
| H::GraphicsApi
|
||||
) | Item::Vanilla(
|
||||
Opt::GuiScale
|
||||
@@ -1733,6 +1769,21 @@ mod tests {
|
||||
assert_eq!(s.horizon.preset, Preset::Ultra);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_quality_cycles_1_2_4_8_and_keeps_the_preset() {
|
||||
let mut s = Settings::default();
|
||||
Item::Horizon(H::Preset).cycle(&mut s, false, [854, 480]);
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..4 {
|
||||
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, false, [854, 480]);
|
||||
seen.push(s.horizon.screenshot_quality);
|
||||
}
|
||||
assert_eq!(seen, [2, 4, 8, 1]);
|
||||
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(s.horizon.screenshot_quality, 8);
|
||||
assert_eq!(s.horizon.preset, Preset::Performance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_budget_slider_steps() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -528,20 +528,33 @@ impl HudAtlas {
|
||||
for folder in folders {
|
||||
walk(&textures.join(folder), &mut files);
|
||||
}
|
||||
// Content mods' equipment textures, which their equipment assets name as
|
||||
// `<ns>:<path>`, and their mob effects' icons, `<ns>:mob_effect/<path>`.
|
||||
// Content mods' entity textures: their equipment assets name them as
|
||||
// `<ns>:<path>`, and native mods draw their block entities with them
|
||||
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
|
||||
// icons, `<ns>:mob_effect/<path>`.
|
||||
let namespaces = horizon_items::content_namespaces::namespaces(root);
|
||||
for (folder, textures) in [("entity", "entity/equipment"), ("mob_effect", "mob_effect")] {
|
||||
for folder in ["entity", "mob_effect"] {
|
||||
if folders.contains(&folder) {
|
||||
for namespace in &namespaces {
|
||||
walk(
|
||||
&root.join(format!("assets/{namespace}/textures/{textures}")),
|
||||
&root.join(format!("assets/{namespace}/textures/{folder}")),
|
||||
&mut files,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let assets = root.join("assets");
|
||||
// A native mod's particle type names its textures `<ns>:particle/<path>`
|
||||
// and needn't ship content, so every namespace's folder is packed.
|
||||
if folders.contains(&"particle")
|
||||
&& let Ok(namespaces) = fs::read_dir(&assets)
|
||||
{
|
||||
for ns in namespaces.flatten() {
|
||||
if ns.file_name() != "minecraft" {
|
||||
walk(&ns.path().join("textures/particle"), &mut files);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Content mods' GUI textures, for their screens: `<namespace>:gui/...`.
|
||||
if full && let Ok(namespaces) = fs::read_dir(&assets) {
|
||||
for ns in namespaces.flatten() {
|
||||
@@ -565,6 +578,12 @@ impl HudAtlas {
|
||||
format!("{namespace}:{rest}")
|
||||
}
|
||||
};
|
||||
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
|
||||
if let Some(base) = name.strip_suffix("_n").or_else(|| name.strip_suffix("_s"))
|
||||
&& textures.join(format!("{base}.png")).is_file()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let image = decode(path)
|
||||
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
|
||||
.ok()?;
|
||||
@@ -1604,6 +1623,9 @@ pub struct HudRenderer {
|
||||
atlas_view: wgpu::TextureView,
|
||||
/// The resource pack's text shader, when it has one that works.
|
||||
pack_text: Option<pack_text::PackText>,
|
||||
/// Target pixels per window pixel: above 1 for a screenshot drawn larger than the
|
||||
/// window (`Renderer::screenshot_quality`).
|
||||
pub zoom: f32,
|
||||
}
|
||||
impl HudRenderer {
|
||||
pub fn new(
|
||||
@@ -1833,6 +1855,7 @@ impl HudRenderer {
|
||||
format,
|
||||
atlas_view: view.clone(),
|
||||
pack_text: None,
|
||||
zoom: 1.0,
|
||||
})
|
||||
}
|
||||
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
|
||||
@@ -1968,14 +1991,14 @@ impl HudRenderer {
|
||||
[
|
||||
size[0] as f32,
|
||||
size[1] as f32,
|
||||
batch.scale,
|
||||
batch.scale * self.zoom,
|
||||
batch.glint_alpha,
|
||||
],
|
||||
batch.end_portal,
|
||||
)),
|
||||
);
|
||||
if let Some(pack_text) = &self.pack_text {
|
||||
pack_text.prepare(uploads, encoder, batch, size);
|
||||
pack_text.prepare(uploads, encoder, batch, size, batch.scale * self.zoom);
|
||||
}
|
||||
}
|
||||
/// Draw the batch `prepare` staged.
|
||||
@@ -2048,7 +2071,8 @@ impl HudRenderer {
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.buffer.slice(..));
|
||||
if let Some([x, y, w, h]) = clip {
|
||||
if let Some(clip) = clip {
|
||||
let [x, y, w, h] = clip.map(|v| (v as f32 * self.zoom) as u32);
|
||||
let (x, y) = (x.min(size[0]), y.min(size[1]));
|
||||
let (w, h) = (w.min(size[0] - x), h.min(size[1] - y));
|
||||
if w == 0 || h == 0 {
|
||||
@@ -2186,7 +2210,7 @@ mod tests {
|
||||
atlas.pixels[at..at + 4].try_into().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn content_mod_equipment_textures_pack_under_their_namespace() {
|
||||
fn content_mod_entity_textures_pack_under_their_namespace() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
|
||||
@@ -2206,10 +2230,17 @@ mod tests {
|
||||
br#"["gems"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A block entity's texture, for a native mod's renderer.
|
||||
let machine = root.join("assets/gems/textures/entity/machine");
|
||||
fs::create_dir_all(&machine).unwrap();
|
||||
write_strip(&machine.join("cog.png"), 32, 32, &[[0, 0, 200, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
|
||||
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
|
||||
let rect = atlas.sprites["gems:entity/machine/cog"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (32, 32));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
|
||||
// Only entity textures: the mods' others reach the atlas through atlas definitions.
|
||||
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
@@ -216,8 +216,9 @@ impl PackText {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
batch: &HudBatch,
|
||||
size: [u32; 2],
|
||||
scale: f32,
|
||||
) {
|
||||
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size));
|
||||
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size, scale));
|
||||
}
|
||||
|
||||
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
|
||||
@@ -233,7 +234,8 @@ impl PackText {
|
||||
/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI
|
||||
/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view
|
||||
/// moving 11000 back, colour modulator white) and an empty `Fog`.
|
||||
fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
|
||||
/// `scale`: target pixels per GUI pixel (`batch.scale`, times `HudRenderer::zoom`).
|
||||
fn uniforms(batch: &HudBatch, size: [u32; 2], scale: f32) -> Vec<u8> {
|
||||
let mut data = vec![0f32; 256];
|
||||
let globals = 0;
|
||||
data[globals + 3] = batch.glint_alpha;
|
||||
@@ -241,7 +243,7 @@ fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
|
||||
data[globals + 8] = size[0] as f32;
|
||||
data[globals + 9] = size[1] as f32;
|
||||
// Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns.
|
||||
let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale);
|
||||
let (w, h) = (size[0] as f32 / scale, size[1] as f32 / scale);
|
||||
let (near, far) = (1000.0f32, 11000.0f32);
|
||||
let projection = 64;
|
||||
data[projection] = 2.0 / w;
|
||||
@@ -287,7 +289,8 @@ mod tests {
|
||||
fn the_gui_projection_maps_gui_pixels_to_the_screen() {
|
||||
let mut batch = HudBatch::new([1920, 1080]);
|
||||
batch.scale = 4.0;
|
||||
let data: Vec<f32> = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec();
|
||||
let data: Vec<f32> =
|
||||
bytemuck::cast_slice(&uniforms(&batch, [1920, 1080], batch.scale)).to_vec();
|
||||
let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4];
|
||||
let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] {
|
||||
std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum())
|
||||
|
||||
@@ -385,9 +385,40 @@ pub fn draw(
|
||||
}
|
||||
}
|
||||
|
||||
/// `LoadingOverlay.MOJANG_STUDIOS_LOGO_LOCATION`, from the player's own resources.
|
||||
pub const MOJANG_STUDIOS_LOGO: &str = "gui/title/mojangstudios";
|
||||
|
||||
/// `LoadingOverlay.extractRenderState`'s logo on a GUI `w` x `h`: the left
|
||||
/// half's corner, then each half's width and height.
|
||||
pub fn logo_layout(w: f32, h: f32) -> (i32, i32, i32, i32) {
|
||||
let (w, h) = (f64::from(w), f64::from(h));
|
||||
let (content_x, logo_y) = ((w * 0.5) as i32, (h * 0.5) as i32);
|
||||
let logo_height = (w * 0.75).min(h) * 0.25;
|
||||
let half_height = (logo_height * 0.5) as i32;
|
||||
let half_width = (logo_height * 4.0 * 0.5) as i32;
|
||||
(
|
||||
content_x - half_width,
|
||||
logo_y - half_height,
|
||||
half_width,
|
||||
logo_height as i32,
|
||||
)
|
||||
}
|
||||
|
||||
/// `GuiGraphicsExtractor.fill(x0, y0, x1, y1, color)`: the corners in either order.
|
||||
fn fill_corners(batch: &mut HudBatch, atlas: &HudAtlas, [x0, y0, x1, y1]: [i32; 4], color: u32) {
|
||||
let (left, right) = (x0.min(x1), x0.max(x1));
|
||||
let (top, bottom) = (y0.min(y1), y0.max(y1));
|
||||
let (w, h) = ((right - left) as f32, (bottom - top) as f32);
|
||||
batch.fill(atlas, left as f32, top as f32, w, h, color);
|
||||
}
|
||||
|
||||
/// `LoadingOverlay.extractRenderState`: fills the frame with `BRAND_BACKGROUND`
|
||||
/// (Mojang red, or black when `darkMojangStudiosBackground` is set) and draws the
|
||||
/// progress line and bar.
|
||||
/// (Mojang red, or black when `darkMojangStudiosBackground` is set), draws the
|
||||
/// Mojang Studios logo as its two halves side by side, and the progress bar
|
||||
/// at 83.25% of the height (`extractProgressBar`, unfaded), with the stage's
|
||||
/// line above it. The logo's `MOJANG_LOGO` pipeline adds it (`SRC_ALPHA, ONE`):
|
||||
/// its texels are white, which alpha blending matches but for the red channel
|
||||
/// of the logo's soft edges, a 16/255 difference at most.
|
||||
pub fn draw_loading_overlay(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
@@ -395,15 +426,58 @@ pub fn draw_loading_overlay(
|
||||
progress: f32,
|
||||
dark: bool,
|
||||
) {
|
||||
batch.fill(
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, brand_background(dark));
|
||||
let (x, y, half_width, logo_height) = logo_layout(w, h);
|
||||
if let Some(r) = atlas.sprites.get(MOJANG_STUDIOS_LOGO).copied() {
|
||||
// blit(.., u, v, width, height, 120, 60, 120, 120): the left half
|
||||
// samples the texture's top half from u = -0.0625, the right half its
|
||||
// bottom half from u = 0.0625 (clamped, as the logo's sampler is).
|
||||
let at = |u: f32, v: f32| {
|
||||
let (u, v) = ((u / 120.0).clamp(0.0, 1.0), v / 120.0);
|
||||
(r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v)
|
||||
};
|
||||
for (i, (u, v)) in [(-0.0625, 0.0), (0.0625, 60.0)].into_iter().enumerate() {
|
||||
let (u0, v0) = at(u, v);
|
||||
let (u1, v1) = at(u + 120.0, v + 60.0);
|
||||
let half = crate::hud::Rect {
|
||||
u0,
|
||||
v0,
|
||||
u1,
|
||||
v1,
|
||||
..r
|
||||
};
|
||||
let left = x + i as i32 * half_width;
|
||||
let (lw, lh) = (half_width as f32, logo_height as f32);
|
||||
batch.rect_uv(left as f32, y as f32, lw, lh, half, u32::MAX);
|
||||
}
|
||||
}
|
||||
let bar_y = (f64::from(h) * 0.8325) as i32;
|
||||
let x_center = (w as i32) / 2;
|
||||
let [x0, y0, x1, y1] = [
|
||||
x_center - half_width,
|
||||
bar_y - 5,
|
||||
x_center + half_width,
|
||||
bar_y + 5,
|
||||
];
|
||||
let filled = (((x1 - x0) - 2) as f32 * progress.clamp(0.0, 1.0)).ceil() as i32;
|
||||
let white = u32::MAX;
|
||||
fill_corners(batch, atlas, [x0 + 2, y0 + 2, x0 + filled, y1 - 2], white);
|
||||
fill_corners(batch, atlas, [x0 + 1, y0, x1 - 1, y0 + 1], white);
|
||||
fill_corners(batch, atlas, [x0 + 1, y1, x1 - 1, y1 - 1], white);
|
||||
fill_corners(batch, atlas, [x0, y0, x0 + 1, y1], white);
|
||||
fill_corners(batch, atlas, [x1, y0, x1 - 1, y1], white);
|
||||
let text = Text::literal(line);
|
||||
let tw = atlas.font.width_text(&text) as i32;
|
||||
let text_top = (y0 - 9 - 3) as f32;
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
batch.width,
|
||||
batch.height,
|
||||
brand_background(dark),
|
||||
&text,
|
||||
(x_center - tw / 2) as f32,
|
||||
text_top,
|
||||
white,
|
||||
true,
|
||||
);
|
||||
draw_progress(batch, atlas, line, progress);
|
||||
}
|
||||
|
||||
/// The startup loading screen: the stage's line and a progress bar, centred
|
||||
@@ -572,6 +646,14 @@ pub fn draw_file_fixer(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_mojang_logo_is_a_quarter_of_the_shorter_side_and_four_times_as_wide() {
|
||||
// 320x240: min(240, 240) / 4 = 60 high, two halves of 120.
|
||||
assert_eq!(logo_layout(320.0, 240.0), (40, 90, 120, 60));
|
||||
// A tall frame sizes the logo by three quarters of its width.
|
||||
assert_eq!(logo_layout(400.0, 500.0), (50, 213, 150, 75));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_weighs_the_prepared_server_and_the_player_chunks() {
|
||||
assert_eq!(progress(0), 10.0 / 59.0);
|
||||
|
||||
@@ -231,6 +231,9 @@ pub struct SectionMesh {
|
||||
/// each frame by `block_entity_render` like vanilla's per-section
|
||||
/// `SectionCompiler.Results.blockEntities`.
|
||||
pub block_entities: Vec<([i32; 3], u32)>,
|
||||
/// Blocks that give off light (world position, state id), for the colour of block light
|
||||
/// Horizon's shader packs read (`gfx::block_light`).
|
||||
pub lights: Vec<([i32; 3], u32)>,
|
||||
/// Sprite layers emitted while building this mesh. Kept distinct before
|
||||
/// upload so the renderer does not rescan every terrain vertex.
|
||||
pub sprite_layers: Vec<u16>,
|
||||
@@ -248,6 +251,7 @@ impl Default for SectionMesh {
|
||||
translucent: Vec::new(),
|
||||
visibility: ALL_VISIBLE,
|
||||
block_entities: Vec::new(),
|
||||
lights: Vec::new(),
|
||||
sprite_layers: Vec::new(),
|
||||
bounds: None,
|
||||
keep: false,
|
||||
@@ -278,6 +282,7 @@ impl SectionMesh {
|
||||
self.solid.clear();
|
||||
self.translucent.clear();
|
||||
self.block_entities.clear();
|
||||
self.lights.clear();
|
||||
self.sprite_layers.clear();
|
||||
self.sides = Sides::default();
|
||||
self.visibility = ALL_VISIBLE;
|
||||
@@ -288,6 +293,7 @@ impl SectionMesh {
|
||||
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
|
||||
+ self.lights.capacity() * std::mem::size_of::<([i32; 3], u32)>()
|
||||
+ self.sprite_layers.capacity() * std::mem::size_of::<u16>()
|
||||
}
|
||||
/// Sorts the solid quads by the side they face, for the renderer to
|
||||
@@ -1124,6 +1130,10 @@ impl Mesher<'_> {
|
||||
out.block_entities
|
||||
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
|
||||
}
|
||||
if state.emission > 0 {
|
||||
out.lights
|
||||
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
|
||||
}
|
||||
if state.solid
|
||||
&& Direction::ALL.iter().all(|d| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
//!
|
||||
//! The buckets lie one after another in the mesh, in an order chosen so that
|
||||
//! the buckets an eye can see are usually one or two runs of quads: one draw
|
||||
//! each. Inside a bucket quads keep their model order, so coplanar quads
|
||||
//! facing one way (an overlay on its base) still draw in that order.
|
||||
use super::TerrainVertex;
|
||||
//! each. Coplanar quads facing one way keep their model order, so an overlay
|
||||
//! on its base still draws in that order. Solid cutout quads are otherwise
|
||||
//! ordered from the outside toward the section interior to reduce overdraw.
|
||||
use super::{OPAQUE_LAYER, TerrainVertex};
|
||||
|
||||
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
|
||||
/// above (+Y, the commonest case), each pair of horizontal sides with a
|
||||
@@ -138,11 +139,14 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
|
||||
OTHER
|
||||
}
|
||||
|
||||
/// Reorders `solid`'s quads by bucket, keeping their order inside each.
|
||||
/// Reorders `solid`'s quads by bucket. Solid cutout quads are also ordered
|
||||
/// front to back within each axis-facing bucket; stable sorting preserves the
|
||||
/// mesher's order for coplanar overlays.
|
||||
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
let (quads, rest) = solid.as_chunks::<4>();
|
||||
debug_assert!(rest.is_empty(), "solid vertices come in quads");
|
||||
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
|
||||
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
|
||||
let mut counts = [0u32; 7];
|
||||
let mut planes = [0f32; 7];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
@@ -163,7 +167,7 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
total += count;
|
||||
*end = total;
|
||||
}
|
||||
if counts.iter().filter(|&&c| c > 0).count() > 1 {
|
||||
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
|
||||
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
|
||||
let mut sorted = vec![quads[0]; quads.len()];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
@@ -171,6 +175,25 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
sorted[*slot as usize] = *quad;
|
||||
*slot += 1;
|
||||
}
|
||||
if has_cutout {
|
||||
let mut start = 0;
|
||||
for (i, bucket) in BUCKETS.iter().enumerate() {
|
||||
let end = ends[i] as usize;
|
||||
let slice = &mut sorted[start..end];
|
||||
if let Bucket::Side(axis, positive) = *bucket {
|
||||
slice.sort_by(|a, b| {
|
||||
let a = a[0].pos[axis];
|
||||
let b = b[0].pos[axis];
|
||||
if positive {
|
||||
b.total_cmp(&a)
|
||||
} else {
|
||||
a.total_cmp(&b)
|
||||
}
|
||||
});
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
*solid = sorted.into_flattened();
|
||||
}
|
||||
Sides { ends, planes }
|
||||
@@ -368,4 +391,46 @@ mod tests {
|
||||
before.iter().map(|v| v.layer).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_to_back_sort_orders_positive_faces_and_keeps_ties_stable() {
|
||||
let mut solid: Vec<_> = [(1.0, 10), (3.0, 30), (3.0, 31)]
|
||||
.into_iter()
|
||||
.flat_map(|(x, layer)| {
|
||||
quad(
|
||||
[[x, 0.0, 0.0], [x, 1.0, 0.0], [x, 1.0, 1.0], [x, 0.0, 1.0]],
|
||||
layer,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
sort(&mut solid);
|
||||
let layers: Vec<u16> = solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| q[0].layer)
|
||||
.collect();
|
||||
assert_eq!(layers, [30, 31, 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_to_back_sort_orders_negative_faces_toward_the_eye() {
|
||||
let mut solid: Vec<_> = [(3.0, 30), (1.0, 10), (2.0, 20)]
|
||||
.into_iter()
|
||||
.flat_map(|(x, layer)| {
|
||||
quad(
|
||||
[[x, 0.0, 0.0], [x, 0.0, 1.0], [x, 1.0, 1.0], [x, 1.0, 0.0]],
|
||||
layer,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
sort(&mut solid);
|
||||
let layers: Vec<u16> = solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| q[0].layer)
|
||||
.collect();
|
||||
assert_eq!(layers, [10, 20, 30]);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,21 @@ pub struct Settings {
|
||||
pub alpha_cutoff_bias: f32,
|
||||
pub has_transparent: bool,
|
||||
pub item: bool,
|
||||
/// Whether a high-resolution cutout's small levels are dithered ([`dither_cutout_levels`]).
|
||||
pub dither_cutouts: bool,
|
||||
}
|
||||
|
||||
/// The `ditherCutoutMips` setting, for textures loaded from now on.
|
||||
static DITHER_CUTOUTS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Sets `ditherCutoutMips`; true if that changes it, when the textures must load again.
|
||||
pub fn set_dither_cutouts(on: bool) -> bool {
|
||||
DITHER_CUTOUTS.swap(on, std::sync::atomic::Ordering::Relaxed) != on
|
||||
}
|
||||
|
||||
/// The `ditherCutoutMips` setting.
|
||||
pub fn dither_cutouts() -> bool {
|
||||
DITHER_CUTOUTS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// A square or rectangular RGBA8 image, rows top to bottom.
|
||||
@@ -362,5 +377,113 @@ fn generate_native(mut base: Image, levels: u32, settings: Settings) -> Vec<Imag
|
||||
}
|
||||
out.push(data);
|
||||
}
|
||||
if settings.dither_cutouts && cutout && out[0].width >= DITHERED_CUTOUT_SIZE {
|
||||
dither_cutout_levels(&mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// From this size a cutout texture's mips can be dithered ([`dither_cutout_levels`]).
|
||||
const DITHERED_CUTOUT_SIZE: u32 = 64;
|
||||
/// A high-resolution cutout's detail is finer than its small mips: grass blades a few
|
||||
/// texels wide at 2048 average to about the same alpha everywhere at 64, and no scale of
|
||||
/// that alpha can keep the coverage, so the alpha test passes the whole quad or none of it.
|
||||
/// Each level below the base takes the base's mean alpha through an ordered dither instead:
|
||||
/// its texels are in or out, about as many in as the base has.
|
||||
fn dither_cutout_levels(levels: &mut [Image]) {
|
||||
const BAYER: [[f32; 4]; 4] = [
|
||||
[0.0, 8.0, 2.0, 10.0],
|
||||
[12.0, 4.0, 14.0, 6.0],
|
||||
[3.0, 11.0, 1.0, 9.0],
|
||||
[15.0, 7.0, 13.0, 5.0],
|
||||
];
|
||||
let (mut w, mut h) = (levels[0].width as usize, levels[0].height as usize);
|
||||
let mut alpha: Vec<f32> = levels[0]
|
||||
.pixels
|
||||
.iter()
|
||||
.map(|p| f32::from(p[3]) / 255.0)
|
||||
.collect();
|
||||
for level in &mut levels[1..] {
|
||||
let (nw, nh) = (level.width as usize, level.height as usize);
|
||||
let mut next = vec![0.0; nw * nh];
|
||||
for y in 0..nh {
|
||||
for x in 0..nw {
|
||||
let at = |dx: usize, dy: usize| {
|
||||
alpha[(y * 2 + dy).min(h - 1) * w + (x * 2 + dx).min(w - 1)]
|
||||
};
|
||||
let mean = (at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1)) / 4.0;
|
||||
next[y * nw + x] = mean;
|
||||
let threshold = (BAYER[y % 4][x % 4] + 0.5) / 16.0;
|
||||
level.pixels[y * nw + x][3] = if mean > threshold { 255 } else { 0 };
|
||||
}
|
||||
}
|
||||
alpha = next;
|
||||
(w, h) = (nw, nh);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dither_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn the_dither_only_runs_when_asked_for_and_from_its_size() {
|
||||
let cutout = |side: u32| Image {
|
||||
width: side,
|
||||
height: side,
|
||||
pixels: (0..side * side)
|
||||
.map(|i| {
|
||||
[
|
||||
90,
|
||||
140,
|
||||
40,
|
||||
if (i % side).is_multiple_of(4) { 255 } else { 0 },
|
||||
]
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let settings = |dither_cutouts| Settings {
|
||||
has_transparent: true,
|
||||
dither_cutouts,
|
||||
..Settings::default()
|
||||
};
|
||||
assert_ne!(
|
||||
generate(cutout(128), 4, settings(false)),
|
||||
generate(cutout(128), 4, settings(true))
|
||||
);
|
||||
assert_eq!(
|
||||
generate(cutout(32), 4, settings(false)),
|
||||
generate(cutout(32), 4, settings(true))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_high_resolution_cutouts_fine_detail_keeps_its_coverage_in_small_mips() {
|
||||
// Blades one texel wide, one column in four: too fine for the mips to hold.
|
||||
let side = 128u32;
|
||||
let pixels = (0..side * side)
|
||||
.map(|i| {
|
||||
[
|
||||
90,
|
||||
140,
|
||||
40,
|
||||
if (i % side).is_multiple_of(4) { 255 } else { 0 },
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let base = Image {
|
||||
width: side,
|
||||
height: side,
|
||||
pixels,
|
||||
};
|
||||
let settings = Settings {
|
||||
has_transparent: true,
|
||||
dither_cutouts: true,
|
||||
..Settings::default()
|
||||
};
|
||||
let mips = generate(base, 4, settings);
|
||||
for level in &mips[3..] {
|
||||
let passing = level.pixels.iter().filter(|p| p[3] >= 128).count() as f32;
|
||||
let coverage = passing / level.pixels.len() as f32;
|
||||
assert!((0.15..0.35).contains(&coverage), "{coverage}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1236,6 +1236,9 @@ pub enum Page {
|
||||
NativeMod(usize),
|
||||
/// Horizon's shader packs, either kind (`shader_pack_list`).
|
||||
ShaderPacks,
|
||||
/// The options of `s.shader_packs[.0]`, a Horizon pack: its `[[pages]]` entry `.1`, the
|
||||
/// first the one the button opens.
|
||||
NativePackOptions(usize, u8),
|
||||
/// The picked `shaders/` pack's options (`shader_options`), one of its screens at a time.
|
||||
ShaderOptions,
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
@@ -1261,6 +1264,8 @@ pub enum Page {
|
||||
Online,
|
||||
/// Splitscreen: the profile another player joins as (`split_profiles`).
|
||||
SplitProfiles,
|
||||
/// Splitscreen: enable joining on this computer.
|
||||
SplitSetup,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Act {
|
||||
@@ -1282,6 +1287,12 @@ enum Act {
|
||||
OpenShaderPackFolder,
|
||||
/// Open the picked pack's Shader Options page.
|
||||
ShaderOptions,
|
||||
/// Open the options page of the Horizon pack `s.shader_packs[.0]`.
|
||||
NativePackOptions(usize),
|
||||
/// Option `.1` of that pack: its next value.
|
||||
NativePackOption(usize, usize),
|
||||
/// Every option of that pack back to its default.
|
||||
NativePackReset(usize),
|
||||
/// Shader Options: the next value of an option, a screen of the pack, the next profile,
|
||||
/// every option back to its default.
|
||||
ShaderOption(String),
|
||||
@@ -1411,6 +1422,9 @@ pub enum Event {
|
||||
OpenShaderOptions,
|
||||
/// The Shader Options page closed with changes: save them and load the pack again.
|
||||
ShaderOptions,
|
||||
/// The options of `shader_packs[.0]`, a Horizon pack, changed: save them (the pack reloads
|
||||
/// with them).
|
||||
NativePackOptions(usize),
|
||||
/// Native mod `.0`'s page opened: the game asks the mod for its labels
|
||||
/// again (they were made with the list, maybe in another language).
|
||||
NativeSettings(usize),
|
||||
@@ -1514,6 +1528,8 @@ pub struct OptionsUi {
|
||||
/// The list's rows as last laid out: each row's top and bottom (GUI
|
||||
/// pixels), for the narration's row under the mouse.
|
||||
row_bands: Vec<[f32; 2]>,
|
||||
/// Other local players are joined: leave before turning splitscreen off.
|
||||
pub split_joined: bool,
|
||||
}
|
||||
impl Default for OptionsUi {
|
||||
fn default() -> Self {
|
||||
@@ -1559,6 +1575,7 @@ impl Default for OptionsUi {
|
||||
framebuffer: [1280, 720],
|
||||
split_guest: false,
|
||||
row_bands: Vec::new(),
|
||||
split_joined: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1622,23 +1639,18 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// The open screen of the picked pack: its title, then its items two to a row (one when the
|
||||
/// pack asks for a single column).
|
||||
fn shader_option_rows(s: &Settings) -> Vec<Row> {
|
||||
let Some(options) = &s.shader_options else {
|
||||
return vec![Row::Header("horizon.shader_options.loading")];
|
||||
};
|
||||
let columns = options
|
||||
.screens
|
||||
.screen(options.screen())
|
||||
.map_or(2, |(_, c)| c);
|
||||
let blank = || Widget {
|
||||
/// A list's slot with nothing in it: text takes no clicks.
|
||||
fn blank_slot(w: f32) -> Widget {
|
||||
Widget {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: 150.0,
|
||||
w,
|
||||
h: 20.0,
|
||||
kind: WKind::Text {
|
||||
text: Text::literal(""),
|
||||
@@ -1647,7 +1659,55 @@ fn shader_option_rows(s: &Settings) -> Vec<Row> {
|
||||
},
|
||||
list: false,
|
||||
row: 0,
|
||||
}
|
||||
}
|
||||
/// Page `at` of the options of the Horizon pack `s.shader_packs[i]`: the pack's own layout,
|
||||
/// else every option two a row.
|
||||
fn native_pack_option_rows(s: &Settings, i: usize, at: u8) -> Vec<Row> {
|
||||
use horizon_shader_api::PageItem;
|
||||
let Some(pack) = s.shader_packs.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let count = pack.options.len().min(pack.values.len());
|
||||
let (columns, items) = match pack.pages.get(usize::from(at)) {
|
||||
Some(page) => (usize::from(page.columns), page.items.clone()),
|
||||
None => (2, (0..count).map(PageItem::Option).collect()),
|
||||
};
|
||||
let width = if columns == 1 { 310.0 } else { 150.0 };
|
||||
let widget = |item: &PageItem| match *item {
|
||||
PageItem::Option(o) if o < count => {
|
||||
let option = &pack.options[o];
|
||||
let value = crate::shader_pack_list::show_value(&option.kind, pack.values[o]);
|
||||
let label = Text::literal(format!("{}: {value}", option.name));
|
||||
button(0.0, 0.0, width, label, Act::NativePackOption(i, o))
|
||||
}
|
||||
PageItem::Page(p) if p < pack.pages.len() => {
|
||||
let label = Text::literal(format!("{}...", pack.pages[p].name));
|
||||
let page = Page::NativePackOptions(i, p as u8);
|
||||
button(0.0, 0.0, width, label, Act::Push(page))
|
||||
}
|
||||
_ => blank_slot(width),
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
if at == 0 {
|
||||
rows.push(Row::Note(pack.title.clone()));
|
||||
}
|
||||
rows.extend(
|
||||
items
|
||||
.chunks(columns)
|
||||
.map(|row| Row::Buttons(widget(&row[0]), row.get(1).map(widget))),
|
||||
);
|
||||
rows
|
||||
}
|
||||
fn shader_option_rows(s: &Settings) -> Vec<Row> {
|
||||
let Some(options) = &s.shader_options else {
|
||||
return vec![Row::Header("horizon.shader_options.loading")];
|
||||
};
|
||||
let columns = options
|
||||
.screens
|
||||
.screen(options.screen())
|
||||
.map_or(2, |(_, c)| c);
|
||||
let blank = || blank_slot(150.0);
|
||||
let width = if columns == 1 { 310.0 } else { 150.0 };
|
||||
let widget = |item: &horizon_shaders::ScreenItem| -> Option<Widget> {
|
||||
use horizon_shaders::ScreenItem;
|
||||
@@ -1712,6 +1772,26 @@ enum Row {
|
||||
/// A widget's tooltip (`setTooltip`), unwrapped: drawn while hovered, and
|
||||
/// its narration's hint.
|
||||
fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
let pack_text = match &wd.kind {
|
||||
WKind::Button {
|
||||
act: Act::NativePackOption(pack, option),
|
||||
..
|
||||
} => Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description),
|
||||
WKind::Button {
|
||||
act: Act::Push(Page::NativePackOptions(pack, at)),
|
||||
..
|
||||
} => Some(
|
||||
&s.shader_packs
|
||||
.get(*pack)?
|
||||
.pages
|
||||
.get(usize::from(*at))?
|
||||
.description,
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(text) = pack_text {
|
||||
return (!text.is_empty()).then(|| Text::literal(text));
|
||||
}
|
||||
let WKind::Button { act, .. } = &wd.kind else {
|
||||
let WKind::Slider { opt, .. } = &wd.kind else {
|
||||
return None;
|
||||
@@ -2055,6 +2135,14 @@ impl OptionsUi {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The name of a Horizon pack's options page past its first, as the page's title.
|
||||
fn pack_page_title(s: &Settings, page: Page) -> Option<Text> {
|
||||
let Page::NativePackOptions(pack, at @ 1..) = page else {
|
||||
return None;
|
||||
};
|
||||
let page = s.shader_packs.get(pack)?.pages.get(usize::from(at))?;
|
||||
Some(Text::literal(&page.name))
|
||||
}
|
||||
fn title(page: Page) -> &'static str {
|
||||
match page {
|
||||
Page::Main => "options.title",
|
||||
@@ -2073,7 +2161,7 @@ impl OptionsUi {
|
||||
Page::Mods => "horizon.pages.mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::ShaderOptions => "horizon.pages.shader_options",
|
||||
Page::ShaderOptions | Page::NativePackOptions(..) => "horizon.pages.shader_options",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
|
||||
"options.videoTitle"
|
||||
}
|
||||
@@ -2084,6 +2172,7 @@ impl OptionsUi {
|
||||
Page::Horizon => "horizon.pages.settings",
|
||||
Page::Agent => "horizon.pages.agent",
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles",
|
||||
Page::SplitSetup => "horizon.pages.split_setup",
|
||||
}
|
||||
}
|
||||
fn scrollbar_x(&self, width: f32) -> f32 {
|
||||
@@ -2173,6 +2262,14 @@ impl OptionsUi {
|
||||
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::SplitSetup => vec![Row::Buttons(
|
||||
if self.split_guest || self.split_joined {
|
||||
inactive(self.extra_button(s, crate::extras::Item::Splitscreen))
|
||||
} else {
|
||||
self.extra_button(s, crate::extras::Item::Splitscreen)
|
||||
},
|
||||
None,
|
||||
)],
|
||||
Page::Extras => {
|
||||
// Each section's switches two to a row, as vanilla's
|
||||
// OptionsList.addSmall lays out an options screen.
|
||||
@@ -2239,6 +2336,17 @@ impl OptionsUi {
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.mods", Page::Mods)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open(
|
||||
"horizon.pages.open.split_setup",
|
||||
if self.split_guest {
|
||||
Page::SplitProfiles
|
||||
} else {
|
||||
Page::SplitSetup
|
||||
},
|
||||
),
|
||||
None,
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::DiscordPresence),
|
||||
@@ -2502,13 +2610,19 @@ impl OptionsUi {
|
||||
if s.shader_packs.is_empty() {
|
||||
return vec![Row::Header("horizon.shader_packs.empty")];
|
||||
}
|
||||
let options = s.shader_packs.iter().any(|p| {
|
||||
p.file == s.horizon.shader_pack
|
||||
&& p.kind == crate::shader_pack_list::Kind::Classic
|
||||
});
|
||||
let options = options.then(|| {
|
||||
let picked = s
|
||||
.shader_packs
|
||||
.iter()
|
||||
.position(|p| p.file == s.horizon.shader_pack);
|
||||
let options = picked.and_then(|i| {
|
||||
let pack = &s.shader_packs[i];
|
||||
let act = match pack.kind {
|
||||
crate::shader_pack_list::Kind::Classic => Act::ShaderOptions,
|
||||
_ if pack.options.is_empty() => return None,
|
||||
crate::shader_pack_list::Kind::Horizon => Act::NativePackOptions(i),
|
||||
};
|
||||
let label = tr("horizon.shader_options.open");
|
||||
Row::Buttons(button(0.0, 0.0, 310.0, label, Act::ShaderOptions), None)
|
||||
Some(Row::Buttons(button(0.0, 0.0, 310.0, label, act), None))
|
||||
});
|
||||
options
|
||||
.into_iter()
|
||||
@@ -2526,6 +2640,7 @@ impl OptionsUi {
|
||||
.collect()
|
||||
}
|
||||
Page::ShaderOptions => shader_option_rows(s),
|
||||
Page::NativePackOptions(i, at) => native_pack_option_rows(s, i, at),
|
||||
_ => {
|
||||
let _ = s;
|
||||
Vec::new()
|
||||
@@ -2977,7 +3092,8 @@ impl OptionsUi {
|
||||
w,
|
||||
h: 9.0,
|
||||
kind: WKind::Text {
|
||||
text: tr(Self::title(page)),
|
||||
text: Self::pack_page_title(s, page)
|
||||
.unwrap_or_else(|| tr(Self::title(page))),
|
||||
color: u32::MAX,
|
||||
centered: true,
|
||||
},
|
||||
@@ -3062,6 +3178,9 @@ impl OptionsUi {
|
||||
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
|
||||
Page::ShaderOptions => Some(("controls.reset", Act::ShaderReset)),
|
||||
Page::NativePackOptions(i, _) => {
|
||||
Some(("controls.reset", Act::NativePackReset(i)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((label, act)) = folder {
|
||||
@@ -3722,6 +3841,25 @@ impl OptionsUi {
|
||||
options.cycle(&name, true);
|
||||
events.push(Event::ButtonClick);
|
||||
}
|
||||
let native = widgets.iter().find_map(|wd| match wd.kind {
|
||||
WKind::Button {
|
||||
active: true,
|
||||
act: Act::NativePackOption(pack, option),
|
||||
..
|
||||
} if ui::hovered((x, y), wd.x, wd.y, wd.w, wd.h)
|
||||
&& !(wd.list && (y < top || y >= bottom)) =>
|
||||
{
|
||||
Some((pack, option))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some((pack, option)) = native
|
||||
&& let Some(entry) = s.shader_packs.get_mut(pack)
|
||||
&& entry.step_option(option, true, false)
|
||||
{
|
||||
events.push(Event::ButtonClick);
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
if ui::scrollbar_thumb(top, bottom, content, self.scroll).is_some()
|
||||
@@ -3838,6 +3976,22 @@ impl OptionsUi {
|
||||
options.reset();
|
||||
}
|
||||
}
|
||||
Act::NativePackOptions(i) => {
|
||||
self.push(Page::NativePackOptions(i, 0));
|
||||
}
|
||||
Act::NativePackOption(pack, option) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack)
|
||||
&& entry.step_option(option, false, self.shift)
|
||||
{
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
}
|
||||
Act::NativePackReset(pack) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack) {
|
||||
entry.reset_options();
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::None => {}
|
||||
@@ -4745,6 +4899,22 @@ impl OptionsUi {
|
||||
options.reset();
|
||||
}
|
||||
}
|
||||
Act::NativePackOptions(i) => {
|
||||
self.push(Page::NativePackOptions(i, 0));
|
||||
}
|
||||
Act::NativePackOption(pack, option) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack)
|
||||
&& entry.step_option(option, false, shift)
|
||||
{
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
}
|
||||
Act::NativePackReset(pack) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack) {
|
||||
entry.reset_options();
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::None => {}
|
||||
@@ -5032,6 +5202,82 @@ mod tests {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_horizon_packs_options_step_both_ways_from_the_picked_packs_button() {
|
||||
let atlas = atlas();
|
||||
let size = (960.0, 540.0);
|
||||
let mut s = Settings::default();
|
||||
s.horizon.shader_pack = "p".into();
|
||||
s.shader_packs = vec![crate::shader_pack_list::Entry {
|
||||
file: "p".into(),
|
||||
title: "P".into(),
|
||||
kind: crate::shader_pack_list::Kind::Horizon,
|
||||
options: vec![horizon_shader_api::PackOption {
|
||||
id: "a".into(),
|
||||
name: "A".into(),
|
||||
description: String::new(),
|
||||
kind: horizon_shader_api::OptionKind::Float {
|
||||
default: 1.0,
|
||||
min: 0.0,
|
||||
max: 2.0,
|
||||
step: 0.5,
|
||||
},
|
||||
}],
|
||||
values: vec![1.0],
|
||||
pages: vec![
|
||||
horizon_shader_api::OptionPage {
|
||||
id: "main".into(),
|
||||
name: "P".into(),
|
||||
description: String::new(),
|
||||
columns: 2,
|
||||
items: vec![horizon_shader_api::PageItem::Page(1)],
|
||||
},
|
||||
horizon_shader_api::OptionPage {
|
||||
id: "more".into(),
|
||||
name: "More".into(),
|
||||
description: String::new(),
|
||||
columns: 1,
|
||||
items: vec![horizon_shader_api::PageItem::Option(0)],
|
||||
},
|
||||
],
|
||||
}];
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::ShaderPacks);
|
||||
let find = |ui: &mut OptionsUi, s: &Settings, want: &Act| {
|
||||
let (widgets, ..) = ui.layout(s, size.0, size.1);
|
||||
let w = widgets
|
||||
.into_iter()
|
||||
.find(|w| matches!(&w.kind, WKind::Button { act, .. } if act == want))
|
||||
.expect("button");
|
||||
(w.x + w.w / 2.0, w.y + w.h / 2.0)
|
||||
};
|
||||
let (x, y) = find(&mut ui, &s, &Act::NativePackOptions(0));
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
|
||||
// The option is on the pack's second page, behind a button on its first.
|
||||
let (x, y) = find(&mut ui, &s, &Act::Push(Page::NativePackOptions(0, 1)));
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
assert_eq!(ui.page(), Page::NativePackOptions(0, 1));
|
||||
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
|
||||
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
assert!(events.contains(&Event::NativePackOptions(0)));
|
||||
assert_eq!(s.shader_packs[0].values, [1.5]);
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
|
||||
assert_eq!(s.shader_packs[0].values, [0.5]);
|
||||
// Shift-click: the option's default.
|
||||
ui.shift = true;
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
ui.shift = false;
|
||||
assert_eq!(s.shader_packs[0].values, [1.0]);
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
let (x, y) = find(&mut ui, &s, &Act::NativePackReset(0));
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
assert_eq!(s.shader_packs[0].values, [1.0]);
|
||||
ui.back(&mut s);
|
||||
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_reload_error_blocks_options_until_dismissed() {
|
||||
use winit::keyboard::KeyCode;
|
||||
@@ -6568,6 +6814,7 @@ mod tests {
|
||||
Page::Controller,
|
||||
Page::Extras,
|
||||
Page::Mods,
|
||||
Page::SplitSetup,
|
||||
Page::Agent,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -47,6 +47,9 @@ pub struct ParticleOptions {
|
||||
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
|
||||
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
|
||||
pub geyser: Option<(i32, f32)>,
|
||||
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
|
||||
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
|
||||
pub mod_particle: Option<String>,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -59,6 +62,15 @@ impl ParticleOptions {
|
||||
trail: None,
|
||||
item: None,
|
||||
geyser: None,
|
||||
mod_particle: None,
|
||||
}
|
||||
}
|
||||
/// A native mod's `SimpleParticleType`: the sprite set of
|
||||
/// `assets/<namespace>/particles/<path>.json`, drawn as a plain billboard.
|
||||
pub fn named(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
mod_particle: Some(name.into()),
|
||||
..Self::simple(ParticleType::Smoke)
|
||||
}
|
||||
}
|
||||
/// `GeyserParticleOptions(type, waterBlocks)`.
|
||||
@@ -1055,6 +1067,8 @@ pub struct ParticleEngine {
|
||||
/// it on the translucent layer (a texel with 0 < alpha < 255).
|
||||
atlas: HashMap<String, (Rect, bool)>,
|
||||
sets: HashMap<ParticleType, u32>,
|
||||
/// A native mod's sprite sets, by namespaced particle name.
|
||||
mod_sets: HashMap<String, u32>,
|
||||
set_sprites: Vec<Vec<u32>>,
|
||||
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
|
||||
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
|
||||
@@ -1092,6 +1106,7 @@ impl ParticleEngine {
|
||||
rects: Vec::new(),
|
||||
atlas: HashMap::new(),
|
||||
sets: HashMap::new(),
|
||||
mod_sets: HashMap::new(),
|
||||
set_sprites: Vec::new(),
|
||||
emitters: Vec::new(),
|
||||
starters: Vec::new(),
|
||||
@@ -1123,12 +1138,24 @@ impl ParticleEngine {
|
||||
.map(|name| self.atlas.get(name).map(|&(rect, _)| rect))
|
||||
.collect();
|
||||
self.sets.clear();
|
||||
self.mod_sets.clear();
|
||||
self.set_sprites.clear();
|
||||
self.item_sprites.clear();
|
||||
self.load_sprites(root);
|
||||
}
|
||||
/// The sprite sets of every `particles/*.json` under `root`.
|
||||
/// The sprite sets of every `particles/*.json` under `root`: vanilla's own
|
||||
/// and a native mod's (`assets/<namespace>/particles/<name>.json`).
|
||||
fn load_sprites(&mut self, root: &Path) {
|
||||
let assets = root.join("assets");
|
||||
if let Ok(namespaces) = std::fs::read_dir(&assets) {
|
||||
for namespace in namespaces.flatten() {
|
||||
let ns = namespace.file_name().to_string_lossy().into_owned();
|
||||
if ns == "minecraft" || !namespace.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
self.load_mod_sprites(&ns, &namespace.path().join("particles"));
|
||||
}
|
||||
}
|
||||
let dir = root.join("assets/minecraft/particles");
|
||||
for &ty in ParticleType::ALL {
|
||||
let Ok(bytes) = std::fs::read(dir.join(format!("{}.json", ty.name()))) else {
|
||||
@@ -1154,6 +1181,56 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A namespace's particle JSONs that vanilla has no type for: each becomes
|
||||
/// one sprite set under `<ns>:<file name>`.
|
||||
fn load_mod_sprites(&mut self, ns: &str, dir: &Path) {
|
||||
let Ok(files) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for file in files.flatten() {
|
||||
let path = file.path();
|
||||
if path.extension().is_none_or(|e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
|
||||
continue;
|
||||
};
|
||||
let key = format!("{ns}:{stem}");
|
||||
if ParticleType::from_name(&key).is_some() {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let sprites: Vec<u32> = json["textures"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|t| t.as_str())
|
||||
.map(|t| {
|
||||
// A texture is `<ns>:<path>` (bare means
|
||||
// `minecraft:`), and the atlas names a sprite
|
||||
// `<ns>:particle/<path>` — `minecraft`'s own
|
||||
// without its namespace.
|
||||
let name = match t.split_once(':') {
|
||||
Some(("minecraft", path)) => format!("particle/{path}"),
|
||||
Some((ns, path)) => format!("{ns}:particle/{path}"),
|
||||
None => format!("particle/{t}"),
|
||||
};
|
||||
self.intern(&name)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !sprites.is_empty() {
|
||||
self.mod_sets.insert(key, self.set_sprites.len() as u32);
|
||||
self.set_sprites.push(sprites);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn intern(&mut self, name: &str) -> u32 {
|
||||
if let Some(i) = self.names.iter().position(|n| n == name) {
|
||||
return i as u32;
|
||||
@@ -1626,6 +1703,23 @@ impl ParticleEngine {
|
||||
) -> Option<Particle> {
|
||||
use ParticleType as T;
|
||||
let ty = o.ty;
|
||||
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
|
||||
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
|
||||
if let Some(name) = &o.mod_particle {
|
||||
let set = *self.mod_sets.get(name)?;
|
||||
let mut r = LegacyRandom::new(seed);
|
||||
let mut q = Particle::moving(&mut r, ParticleType::Smoke, p, v);
|
||||
q.ty = ParticleType::Smoke;
|
||||
q.gravity = 0.0;
|
||||
q.rgba = [1.0; 4];
|
||||
q.lifetime = 20 + r.next_int_bound(20).unwrap_or(0);
|
||||
q.set = Some(set);
|
||||
let sprites = &self.set_sprites[set as usize];
|
||||
q.sprite = Sprite::Index(
|
||||
sprites[r.next_int_bound(sprites.len() as i32).unwrap_or(0) as usize],
|
||||
);
|
||||
return Some(q);
|
||||
}
|
||||
let set = self.set_of(ty);
|
||||
let mut local = LegacyRandom::new(seed);
|
||||
let r = &mut local;
|
||||
@@ -4222,6 +4316,48 @@ const CREEPER: [[f64; 2]; 12] = [
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_mod_particles_json_becomes_its_sprite_set() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-mod-particles-{}", std::process::id()));
|
||||
let particles = dir.join("assets/mymod/particles");
|
||||
std::fs::create_dir_all(&particles).unwrap();
|
||||
std::fs::write(
|
||||
particles.join("spark.json"),
|
||||
br#"{"textures":["mymod:spark/1","mymod:spark/2"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(particles.join("spark.json").exists());
|
||||
let mut engine = ParticleEngine::new(&dir, &HashMap::new(), &HashSet::new());
|
||||
let set = *engine
|
||||
.mod_sets
|
||||
.get("mymod:spark")
|
||||
.expect("the set is loaded");
|
||||
assert_eq!(engine.set_sprites[set as usize].len(), 2);
|
||||
// The named option spawns a billboard from that set, not a vanilla type.
|
||||
let made = engine
|
||||
.make(
|
||||
&ParticleOptions::named("mymod:spark"),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
7,
|
||||
)
|
||||
.expect("a mod particle is made");
|
||||
assert_eq!(made.set, Some(set));
|
||||
// A name no pack knows draws nothing, like a missing vanilla sprite set.
|
||||
assert!(
|
||||
engine
|
||||
.make(
|
||||
&ParticleOptions::named("mymod:missing"),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
7
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
|
||||
@@ -332,6 +332,7 @@ impl Settings {
|
||||
.parent()
|
||||
.map(crate::graphics::Graphics::load)
|
||||
.unwrap_or_default();
|
||||
crate::mipmap::set_dither_cutouts(horizon.dither_cutout_mips);
|
||||
let controller = path
|
||||
.parent()
|
||||
.map(crate::gamepad::PadSettings::load)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! Settings > Shader Packs lists them: Horizon's own (docs/shaders.md) and those in the
|
||||
//! `shaders/` format, side by side.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use horizon_shader_api::{OptionKind, OptionPage, PackOption};
|
||||
|
||||
pub const DIR: &str = "shaderpacks";
|
||||
|
||||
@@ -14,13 +16,48 @@ pub enum Kind {
|
||||
Classic,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Entry {
|
||||
/// The folder's or file's name in `shaderpacks`: what the pick saves.
|
||||
pub file: String,
|
||||
/// The pack's own name for Horizon's packs, else `file`.
|
||||
pub title: String,
|
||||
pub kind: Kind,
|
||||
/// A Horizon pack's `[[options]]`, and the value of each the player set (`values_path`).
|
||||
pub options: Vec<PackOption>,
|
||||
pub values: Vec<f64>,
|
||||
/// Its options page's `[[pages]]`; empty for one page of every option.
|
||||
pub pages: Vec<OptionPage>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// Saves the player's option values, for the pack in the folder `dir`.
|
||||
pub fn save_values(&self, dir: &Path) {
|
||||
let path = dir.join(&self.file);
|
||||
if let Err(e) = save_values(&path, &self.options, &self.values) {
|
||||
log::warn!("shader pack options of {}: {e}", self.file);
|
||||
}
|
||||
}
|
||||
|
||||
/// The option `i` one value on (`back`: one before), or at its default (`reset`). Whether
|
||||
/// the pack has that option.
|
||||
pub fn step_option(&mut self, i: usize, back: bool, reset: bool) -> bool {
|
||||
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
|
||||
return false;
|
||||
};
|
||||
*v = if reset {
|
||||
o.kind.default_constant()
|
||||
} else {
|
||||
step_value(&o.kind, *v, back)
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
/// Every option back to its default.
|
||||
pub fn reset_options(&mut self) {
|
||||
let defaults = self.options.iter().map(|o| o.kind.default_constant());
|
||||
self.values = defaults.collect();
|
||||
}
|
||||
}
|
||||
|
||||
/// The packs in `dir`, Horizon's first, each kind by title. A missing folder has none.
|
||||
@@ -53,11 +90,18 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
.ok()
|
||||
.map(|p| p.manifest)
|
||||
};
|
||||
let title = manifest.map_or_else(|| file.clone(), |m| m.name);
|
||||
let (title, options, pages) = manifest.map_or_else(
|
||||
|| (file.clone(), Vec::new(), Vec::new()),
|
||||
|m| (m.name, m.options, m.pages),
|
||||
);
|
||||
let values = load_values(path, &options);
|
||||
return Some(Entry {
|
||||
file,
|
||||
title,
|
||||
kind: Kind::Horizon,
|
||||
options,
|
||||
values,
|
||||
pages,
|
||||
});
|
||||
}
|
||||
let zip = path.is_file()
|
||||
@@ -68,9 +112,128 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
title: file.clone(),
|
||||
file,
|
||||
kind: Kind::Classic,
|
||||
options: Vec::new(),
|
||||
values: Vec::new(),
|
||||
pages: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
|
||||
/// one `id=value` a line.
|
||||
pub fn values_path(pack: &Path) -> PathBuf {
|
||||
let mut path = pack.as_os_str().to_owned();
|
||||
path.push(".txt");
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
/// The value of each of `options` saved for the pack at `pack`, else its default; a value
|
||||
/// out of the option's range is brought into it.
|
||||
pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
|
||||
let text = std::fs::read_to_string(values_path(pack)).unwrap_or_default();
|
||||
let saved: std::collections::HashMap<&str, f64> = text
|
||||
.lines()
|
||||
.filter_map(|l| l.split_once('='))
|
||||
.filter_map(|(k, v)| Some((k.trim(), v.trim().parse().ok()?)))
|
||||
.collect();
|
||||
options
|
||||
.iter()
|
||||
.map(|o| {
|
||||
saved
|
||||
.get(o.id.as_str())
|
||||
.map_or_else(|| o.kind.default_constant(), |&v| clamp(&o.kind, v))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Saves `values` of `options`; values at their defaults are left out, and with none left
|
||||
/// the file goes.
|
||||
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
|
||||
let text: String = options
|
||||
.iter()
|
||||
.zip(values)
|
||||
.filter(|(o, v)| o.kind.default_constant() != **v)
|
||||
.map(|(o, v)| format!("{}={v}\n", o.id))
|
||||
.collect();
|
||||
let path = values_path(pack);
|
||||
if text.is_empty() {
|
||||
match std::fs::remove_file(&path) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
|
||||
_ => Ok(()),
|
||||
}
|
||||
} else {
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp(kind: &OptionKind, v: f64) -> f64 {
|
||||
match kind {
|
||||
OptionKind::Bool { .. } => f64::from(u8::from(v != 0.0)),
|
||||
OptionKind::Float { min, max, .. } => v.clamp(f64::from(*min), f64::from(*max)),
|
||||
OptionKind::Int { min, max, .. } => v.round().clamp(f64::from(*min), f64::from(*max)),
|
||||
OptionKind::Choice { values, .. } => v.round().clamp(0.0, values.len() as f64 - 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The option's next value (`back`: the one before), wrapping around at the ends.
|
||||
fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
|
||||
let dir = if back { -1.0 } else { 1.0 };
|
||||
match kind {
|
||||
OptionKind::Bool { .. } => f64::from(u8::from(v == 0.0)),
|
||||
OptionKind::Float { min, max, step, .. } => {
|
||||
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
|
||||
if !back && v >= max - step * 0.01 {
|
||||
min
|
||||
} else if back && v <= min + step * 0.01 {
|
||||
max
|
||||
} else {
|
||||
// On the grid of steps from the minimum, so float error never piles up.
|
||||
let n = ((v - min) / step).round() + dir;
|
||||
(min + n * step).clamp(min, max)
|
||||
}
|
||||
}
|
||||
OptionKind::Int { min, max, .. } => {
|
||||
let (min, max) = (f64::from(*min), f64::from(*max));
|
||||
let n = v.round() + dir;
|
||||
if n > max {
|
||||
min
|
||||
} else if n < min {
|
||||
max
|
||||
} else {
|
||||
n
|
||||
}
|
||||
}
|
||||
OptionKind::Choice { values, .. } => {
|
||||
let n = values.len() as f64;
|
||||
(v.round() + dir).rem_euclid(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value as the settings page shows it.
|
||||
pub fn show_value(kind: &OptionKind, v: f64) -> String {
|
||||
match kind {
|
||||
OptionKind::Bool { .. } => crate::lang::get(if v != 0.0 {
|
||||
"options.on"
|
||||
} else {
|
||||
"options.off"
|
||||
}),
|
||||
OptionKind::Float { step, .. } => {
|
||||
let digits = if *step >= 1.0 {
|
||||
0
|
||||
} else if *step >= 0.1 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
format!("{v:.digits$}")
|
||||
}
|
||||
OptionKind::Int { .. } => format!("{v:.0}"),
|
||||
OptionKind::Choice { values, .. } => {
|
||||
values.get(v.round() as usize).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -106,4 +269,59 @@ mod tests {
|
||||
assert!(scan(&d.join("missing")).is_empty());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
fn option(id: &str, kind: OptionKind) -> PackOption {
|
||||
PackOption {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
description: String::new(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_step_wrap_and_round_trip() {
|
||||
let float = OptionKind::Float {
|
||||
default: 1.0,
|
||||
min: 0.0,
|
||||
max: 2.0,
|
||||
step: 0.5,
|
||||
};
|
||||
assert_eq!(step_value(&float, 1.0, false), 1.5);
|
||||
assert_eq!(step_value(&float, 2.0, false), 0.0);
|
||||
assert_eq!(step_value(&float, 0.0, true), 2.0);
|
||||
assert_eq!(show_value(&float, 1.5), "1.5");
|
||||
let choice = OptionKind::Choice {
|
||||
values: vec!["a".into(), "b".into(), "c".into()],
|
||||
default: 0,
|
||||
};
|
||||
assert_eq!(step_value(&choice, 2.0, false), 0.0);
|
||||
assert_eq!(step_value(&choice, 0.0, true), 2.0);
|
||||
assert_eq!(show_value(&choice, 1.0), "b");
|
||||
let int = OptionKind::Int {
|
||||
default: 0,
|
||||
min: -1,
|
||||
max: 1,
|
||||
};
|
||||
assert_eq!(step_value(&int, 1.0, false), -1.0);
|
||||
let on = OptionKind::Bool { default: true };
|
||||
assert_eq!(step_value(&on, 1.0, false), 0.0);
|
||||
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-shader-values-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pack = dir.join("p.hshader");
|
||||
let options = [option("a", float), option("b", choice)];
|
||||
// Only what differs from the defaults is written; out of range comes back clamped.
|
||||
save_values(&pack, &options, &[1.0, 2.0]).unwrap();
|
||||
let text = std::fs::read_to_string(values_path(&pack)).unwrap();
|
||||
assert_eq!(text, "b=2\n");
|
||||
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
|
||||
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
|
||||
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
|
||||
assert!(!values_path(&pack).exists());
|
||||
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,12 @@ pub fn profile_skin_url_nbt(profile: &horizon_world::nbt::Tag) -> Option<String>
|
||||
unpack(&value, Signature::Unsigned, &TEXTURE_URIS).skin
|
||||
}
|
||||
|
||||
/// `ResolvableProfile.skinPatch().body()` of a skull's `profile` NBT: the
|
||||
/// `texture` id that replaces its skin.
|
||||
pub fn profile_texture_nbt(profile: &horizon_world::nbt::Tag) -> Option<String> {
|
||||
nbt_text(profile.get("texture"))
|
||||
}
|
||||
|
||||
/// The profile's uuid as u128: `id` as an int array (block entity NBT) or a
|
||||
/// uuid string (older and component forms). Used for `DefaultPlayerSkin.get`
|
||||
/// when a head's skin is not downloaded yet.
|
||||
@@ -467,6 +473,11 @@ pub fn head_skin(
|
||||
downloads: &mut Downloader,
|
||||
atlas: impl Fn(&str) -> bool,
|
||||
) -> String {
|
||||
if let Some(texture) = profile.get("texture").and_then(|t| t.as_str())
|
||||
&& let Some(sprite) = patch_skin(texture, downloads, &atlas)
|
||||
{
|
||||
return sprite;
|
||||
}
|
||||
let p = parse_profile(profile);
|
||||
let property = p.textures.or_else(|| {
|
||||
let key = p.dynamic.as_ref()?;
|
||||
@@ -495,6 +506,68 @@ pub fn head_skin(
|
||||
crate::tab_list::default_skin(p.id).to_owned()
|
||||
}
|
||||
|
||||
/// `PlayerSkin.Patch.apply` for a head: the sprite of a profile's `texture`
|
||||
/// override (`ClientAsset.ResourceTexture`, a resource pack's
|
||||
/// `<namespace>:textures/<path>.png`), which replaces the downloaded or
|
||||
/// default skin. Vanilla textures are in the atlas already; another
|
||||
/// namespace's loads into a skin slot, and `None` answers until it is there
|
||||
/// (or when the packs lack it).
|
||||
pub fn patch_skin(
|
||||
texture: &str,
|
||||
downloads: &mut Downloader,
|
||||
atlas: impl Fn(&str) -> bool,
|
||||
) -> Option<String> {
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
if !valid_texture_id(namespace, path) {
|
||||
return None;
|
||||
}
|
||||
if namespace == "minecraft" && atlas(path) {
|
||||
return Some(path.to_owned());
|
||||
}
|
||||
let location = format!("patch/{namespace}/{path}");
|
||||
if atlas(&location) {
|
||||
downloads.drawn(&location);
|
||||
return Some(location);
|
||||
}
|
||||
downloads.resource(&location, namespace, path);
|
||||
None
|
||||
}
|
||||
|
||||
/// `Identifier`'s characters (`isValidNamespace`, `isValidPath`), and no
|
||||
/// `.`, `..` or empty segment (`FileUtil.decomposePath`, as the packs read
|
||||
/// it): a server's `texture` never names a file outside the packs.
|
||||
fn valid_texture_id(namespace: &str, path: &str) -> bool {
|
||||
let segment = |s: &str| !s.is_empty() && s != "." && s != "..";
|
||||
!namespace.is_empty()
|
||||
&& namespace
|
||||
.chars()
|
||||
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.'))
|
||||
&& path
|
||||
.chars()
|
||||
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '/'))
|
||||
&& segment(namespace)
|
||||
&& path.split('/').all(segment)
|
||||
}
|
||||
|
||||
/// A `width`x`height` RGBA image as the 64x64 skin it maps onto: each texel
|
||||
/// the nearest of the image's at the same normalised UV.
|
||||
fn resample_64(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![0u8; 64 * 64 * 4];
|
||||
for y in 0..64 {
|
||||
for x in 0..64 {
|
||||
let (sx, sy) = (x * w / 64, y * h / 64);
|
||||
let s = (sy * w + sx) * 4;
|
||||
let d = (y * 64 + x) * 4;
|
||||
out[d..d + 4].copy_from_slice(&rgba[s..s + 4]);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `MinecraftProfileTexture.getHash`: the base name of the URL's path.
|
||||
pub fn texture_hash(url: &str) -> Option<String> {
|
||||
let rest = url.split_once("://")?.1;
|
||||
@@ -732,6 +805,10 @@ pub struct Downloader {
|
||||
image_order: std::collections::VecDeque<String>,
|
||||
/// Skins and capes drawn from the atlas since `take_drawn`.
|
||||
drawn: std::collections::HashSet<String>,
|
||||
/// The resource packs' root, where a profile's `texture` patch is read.
|
||||
resource_root: std::path::PathBuf,
|
||||
/// How each `patch/` texture of the resource packs loaded, by location.
|
||||
resources: std::collections::HashMap<String, Download>,
|
||||
}
|
||||
/// `images` is bounded only so a long session on a busy server does not grow
|
||||
/// without end; the disk cache holds everything it drops.
|
||||
@@ -749,8 +826,48 @@ impl Downloader {
|
||||
images: Default::default(),
|
||||
image_order: Default::default(),
|
||||
drawn: Default::default(),
|
||||
resource_root: Default::default(),
|
||||
resources: Default::default(),
|
||||
}
|
||||
}
|
||||
/// The resource packs changed (or were first loaded) at `root`: patch
|
||||
/// textures load again from there.
|
||||
pub fn set_resource_root(&mut self, root: std::path::PathBuf) {
|
||||
for location in std::mem::take(&mut self.resources).into_keys() {
|
||||
self.images.remove(&location);
|
||||
self.image_order.retain(|l| *l != location);
|
||||
}
|
||||
self.resource_root = root;
|
||||
}
|
||||
/// `ClientAsset.ResourceTexture`: loads `<namespace>:textures/<path>.png`
|
||||
/// from the resource packs into a skin slot as `location`, once. A
|
||||
/// texture of another size is sampled as the model's normalised UVs would
|
||||
/// sample it (nearest texel) into the 64x64 slot.
|
||||
fn resource(&mut self, location: &str, namespace: &str, path: &str) {
|
||||
match self.resources.get(location) {
|
||||
Some(Download::Failed) => return,
|
||||
Some(_) => {
|
||||
self.again(location);
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let file = self
|
||||
.resource_root
|
||||
.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
let image = std::fs::read(file)
|
||||
.ok()
|
||||
.and_then(|b| crate::hud::decode_bytes(&b).ok())
|
||||
.and_then(|(w, h, rgba)| resample_64(w, h, &rgba));
|
||||
let state = match image {
|
||||
Some(rgba) => {
|
||||
self.queue(location.to_owned(), rgba);
|
||||
Download::Ready
|
||||
}
|
||||
None => Download::Failed,
|
||||
};
|
||||
self.resources.insert(location.to_owned(), state);
|
||||
}
|
||||
/// How the download of `url` stands, starting it the first time.
|
||||
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
|
||||
while let Ok((key, image)) = self.done.try_recv() {
|
||||
@@ -824,6 +941,15 @@ impl Downloader {
|
||||
if self.queued.contains(location) {
|
||||
return true;
|
||||
}
|
||||
if location.starts_with("patch/") {
|
||||
let Some(image) = self.images.get(location).cloned() else {
|
||||
// Dropped from memory: read from the packs again.
|
||||
self.resources.remove(location);
|
||||
return false;
|
||||
};
|
||||
self.queue(location.to_owned(), image);
|
||||
return true;
|
||||
}
|
||||
let (digest, process): (_, fn(u32, u32, &[u8]) -> Option<Vec<u8>>) =
|
||||
if let Some(digest) = location.strip_prefix("skins/") {
|
||||
(digest, process_legacy_skin)
|
||||
@@ -960,6 +1086,21 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A profile's `texture` names a pack texture, never a file outside the
|
||||
/// packs: bad characters and `..` segments draw nothing.
|
||||
#[test]
|
||||
fn a_texture_patch_stays_inside_the_packs() {
|
||||
use super::valid_texture_id;
|
||||
assert!(valid_texture_id("ethereal", "entity/plush_blank"));
|
||||
assert!(valid_texture_id("minecraft", "entity/player/wide/steve"));
|
||||
assert!(!valid_texture_id("ethereal", "../../../secret"));
|
||||
assert!(!valid_texture_id("..", "entity/x"));
|
||||
assert!(!valid_texture_id("ethereal", "entity//x"));
|
||||
assert!(!valid_texture_id("Ethereal", "entity/x"));
|
||||
assert!(!valid_texture_id("ethereal", "entity\\x"));
|
||||
assert!(!valid_texture_id("", "entity/x"));
|
||||
}
|
||||
|
||||
/// A pack reload swaps the atlas: its skin slots are blank, so a forgotten
|
||||
/// table holds nothing and hands out slots from the start again, even for
|
||||
/// skins drawn in the last frame.
|
||||
|
||||
@@ -25,6 +25,30 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
|
||||
&[4, 4, 4, 4],
|
||||
];
|
||||
|
||||
/// Where a window is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Placement {
|
||||
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
|
||||
/// where the system opens it).
|
||||
Free(Option<([i32; 2], [u32; 2])>),
|
||||
/// Borderless over the monitor of this name.
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
|
||||
/// centred, each next one a step further, as systems cascade new windows (back to the centre
|
||||
/// after six), so new windows never open exactly on one another.
|
||||
pub fn cascade(number: u8) -> f64 {
|
||||
f64::from(number.saturating_sub(2) % 6) * 32.0
|
||||
}
|
||||
|
||||
/// A window: its number (1 is the game's own) and where it is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Window {
|
||||
pub number: u8,
|
||||
pub placement: Placement,
|
||||
}
|
||||
|
||||
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
|
||||
/// others, P1 first.
|
||||
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
|
||||
|
||||
@@ -85,18 +85,93 @@ pub struct Sprites {
|
||||
/// Each layer's frames at the texture's own resolution (square), and the
|
||||
/// frame shown first.
|
||||
native: Vec<(u32, Arc<Vec<Vec<u8>>>, usize)>,
|
||||
/// Still textures by their pixels and mip settings: mods copy vanilla's
|
||||
/// textures under their own names, and one layer serves them all (the
|
||||
/// GPU's array may hold as few as 2048).
|
||||
still: std::collections::HashMap<(Vec<u8>, u32, [u32; 2], [bool; 4]), u32>,
|
||||
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their first
|
||||
/// frame at their own resolution (square).
|
||||
pbr: Vec<[Option<(u32, Vec<u8>)>; 2]>,
|
||||
/// The layers' normal and specular maps at `pbr_size`, layer after layer, set by
|
||||
/// [`Sprites::finish`]: empty when no texture has either.
|
||||
pub normals: Vec<u8>,
|
||||
pub specular: Vec<u8>,
|
||||
pub pbr_size: u32,
|
||||
/// Still textures by their pixels, their LabPBR maps and mip settings: mods copy vanilla's
|
||||
/// textures under their own names, and one layer serves them all (the GPU's array may
|
||||
/// hold as few as 2048).
|
||||
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
|
||||
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
|
||||
disk: Option<crate::sprite_cache::SpriteCache>,
|
||||
}
|
||||
/// Edge length of vanilla's block textures: the smallest array layer.
|
||||
pub const SPRITE_SIZE: u32 = 16;
|
||||
/// Largest sprite array (all layers of the top mip level): resource packs
|
||||
/// with bigger textures are sampled down to fit.
|
||||
const MAX_ARRAY_BYTES: u64 = 256 << 20;
|
||||
/// The largest sprite array (all layers of the top mip level) when the settings leave it to
|
||||
/// Horizon: resource packs with bigger textures are sampled down to fit.
|
||||
pub const DEFAULT_TEXTURE_BUDGET: u64 = 256 << 20;
|
||||
/// The budget in bytes the next resource load keeps to (`set_texture_budget`).
|
||||
static TEXTURE_BUDGET: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(DEFAULT_TEXTURE_BUDGET);
|
||||
/// The block textures' budget from the `textureBudgetMiB` setting (0 for the default), for
|
||||
/// resources loaded from now on.
|
||||
pub fn set_texture_budget(mib: u32) {
|
||||
let bytes = match mib {
|
||||
0 => DEFAULT_TEXTURE_BUDGET,
|
||||
mib => u64::from(mib) << 20,
|
||||
};
|
||||
TEXTURE_BUDGET.store(bytes, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// Whether resource loads read the resource packs' LabPBR maps (`set_labpbr_maps`).
|
||||
static LABPBR_MAPS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether resource loads from now on read the resource packs' LabPBR maps beside their block
|
||||
/// textures. Only a native shader pack's material stage reads them, so without one they cost
|
||||
/// neither loading time nor video memory.
|
||||
pub fn set_labpbr_maps(on: bool) {
|
||||
LABPBR_MAPS.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// Whether resource loads read the LabPBR maps (`set_labpbr_maps`).
|
||||
pub fn labpbr_maps() -> bool {
|
||||
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// Largest array of each LabPBR map, which only a native shader pack reads.
|
||||
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
|
||||
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
|
||||
/// loading assumes at least these many share the array.
|
||||
const LAYERS_AT_LEAST: u64 = 1024;
|
||||
/// A layer's normal map where its texture has none: flat, unoccluded, at full height.
|
||||
pub const FLAT_NORMAL: [u8; 4] = [128, 128, 255, 255];
|
||||
/// A layer's specular map where its texture has none: rough, not metal, not glowing.
|
||||
pub const NO_SPECULAR: [u8; 4] = [0, 0, 0, 0];
|
||||
/// The largest power-of-two side from `start` down (at least `SPRITE_SIZE`) at which `layers`
|
||||
/// square RGBA layers take at most `bytes`.
|
||||
fn side_within(start: u32, layers: u64, bytes: u64) -> u32 {
|
||||
let mut size = start.max(SPRITE_SIZE).next_power_of_two();
|
||||
while size > SPRITE_SIZE && layers * u64::from(size) * u64::from(size) * 4 > bytes {
|
||||
size /= 2;
|
||||
}
|
||||
size
|
||||
}
|
||||
/// A LabPBR map's square `frame` of `side` averaged down by whole powers of two to at most
|
||||
/// `max`, channel by channel: they are data, not colour.
|
||||
fn shrink_map(frame: Vec<u8>, side: u32, max: u32) -> (u32, Vec<u8>) {
|
||||
if side <= max || !side.is_power_of_two() || !max.is_power_of_two() {
|
||||
return (side, frame);
|
||||
}
|
||||
let (out_side, f, side) = (max as usize, (side / max) as usize, side as usize);
|
||||
let mut out = Vec::with_capacity(out_side * out_side * 4);
|
||||
for y in 0..out_side {
|
||||
for x in 0..out_side {
|
||||
let mut sum = [0u32; 4];
|
||||
for dy in 0..f {
|
||||
let row = ((y * f + dy) * side + x * f) * 4;
|
||||
for texel in frame[row..row + f * 4].as_chunks::<4>().0 {
|
||||
for (s, &v) in sum.iter_mut().zip(texel) {
|
||||
*s += u32::from(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = (f * f) as u32;
|
||||
out.extend(sum.map(|s| ((s + n / 2) / n) as u8));
|
||||
}
|
||||
}
|
||||
(max, out)
|
||||
}
|
||||
/// Nearest-resample a square RGBA frame to another square size.
|
||||
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
|
||||
if from == to {
|
||||
@@ -156,7 +231,7 @@ impl Sprites {
|
||||
}
|
||||
/// Lay every layer out at one size: the largest texture's (resource packs
|
||||
/// may ship 32x, 64x... textures, like vanilla's stitched atlas), rounded
|
||||
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
|
||||
/// up to a power of two and limited to the texture budget (`set_texture_budget`). Smaller
|
||||
/// textures are scaled up by whole pixels, so UVs stay per layer.
|
||||
pub fn finish(&mut self) {
|
||||
self.lay_out(true);
|
||||
@@ -170,13 +245,18 @@ impl Sprites {
|
||||
let layers = self.native.len().max(1) as u64;
|
||||
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
|
||||
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
|
||||
let mut size = largest.max(SPRITE_SIZE).next_power_of_two();
|
||||
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
|
||||
size /= 2;
|
||||
let budget = TEXTURE_BUDGET.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let size = side_within(largest, layers, budget);
|
||||
if pixels {
|
||||
log::info!(
|
||||
"Horizon: block sprites: {layers} layers at {size} px (largest {largest} px, budget {} MiB)",
|
||||
budget >> 20
|
||||
);
|
||||
}
|
||||
if !pixels {
|
||||
self.pixels = Vec::new();
|
||||
self.animations = Vec::new();
|
||||
self.pbr = Vec::new();
|
||||
self.size = size;
|
||||
return;
|
||||
}
|
||||
@@ -192,6 +272,30 @@ impl Sprites {
|
||||
animation.frames = resized[animation.layer as usize].clone();
|
||||
}
|
||||
self.size = size;
|
||||
self.normals.clear();
|
||||
self.specular.clear();
|
||||
let largest_map = self.pbr.iter().flatten().flatten().map(|m| m.0).max();
|
||||
self.pbr_size = largest_map.map_or(SPRITE_SIZE, |l| {
|
||||
side_within(l.min(size), layers, MAX_MAP_BYTES)
|
||||
});
|
||||
if largest_map.is_some() {
|
||||
let pbr_size = self.pbr_size;
|
||||
log::info!("Horizon: LabPBR maps at {pbr_size} px, {layers} layers");
|
||||
for maps in std::mem::take(&mut self.pbr) {
|
||||
for (map, (out, none)) in maps.into_iter().zip([
|
||||
(&mut self.normals, FLAT_NORMAL),
|
||||
(&mut self.specular, NO_SPECULAR),
|
||||
]) {
|
||||
match map {
|
||||
Some((from, pixels)) => {
|
||||
let (from, pixels) = shrink_map(pixels, from, pbr_size);
|
||||
out.extend(resample(&pixels, from, pbr_size));
|
||||
}
|
||||
None => out.extend(none.repeat((pbr_size * pbr_size) as usize)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The layer of a texture, added on first use. A texture that is missing
|
||||
/// or does not load (a resource pack's filter or a broken file) becomes
|
||||
@@ -222,6 +326,7 @@ impl Sprites {
|
||||
.collect();
|
||||
let layer = u32::try_from(self.native.len())?;
|
||||
self.opaque.push(true);
|
||||
self.pbr.push([None, None]);
|
||||
self.translucent.push(false);
|
||||
self.mips.push(crate::mipmap::Settings::default());
|
||||
self.layers.insert(name.to_owned(), layer);
|
||||
@@ -242,7 +347,7 @@ impl Sprites {
|
||||
decoded
|
||||
}
|
||||
};
|
||||
self.register(name, decoded)
|
||||
self.register(root, name, decoded)
|
||||
}
|
||||
/// Read and decode one texture and its `.mcmeta`.
|
||||
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
@@ -263,40 +368,19 @@ impl Sprites {
|
||||
.as_deref()
|
||||
.map(AnimationMetadata::from_json)
|
||||
.transpose()?;
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let mut bytes = vec![
|
||||
0;
|
||||
reader
|
||||
.output_buffer_size()
|
||||
.ok_or("sprite exceeds decoder limits")?
|
||||
];
|
||||
let info = reader.next_frame(&mut bytes)?;
|
||||
let (png_width, png_height, pixels) = read_rgba(&path)?;
|
||||
let (width, height) = if let Some(animation) = &animation {
|
||||
let [width, height] =
|
||||
animation.frame_size(i32::try_from(info.width)?, i32::try_from(info.height)?);
|
||||
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
|
||||
(u32::try_from(width)?, u32::try_from(height)?)
|
||||
} else {
|
||||
(info.width, info.height)
|
||||
(png_width, png_height)
|
||||
};
|
||||
if width == 0 || height == 0 || info.width % width != 0 || info.height % height != 0 {
|
||||
if width == 0 || height == 0 || png_width % width != 0 || png_height % height != 0 {
|
||||
return Err(format!("sprite {name} has invalid frame dimensions").into());
|
||||
}
|
||||
let channels = info.color_type.samples();
|
||||
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
|
||||
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
|
||||
let rgba = match info.color_type {
|
||||
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
|
||||
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
|
||||
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
|
||||
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
|
||||
_ => return Err("unsupported expanded sprite format".into()),
|
||||
};
|
||||
pixels.extend(rgba);
|
||||
}
|
||||
let columns = info.width / width;
|
||||
let count = columns * (info.height / height);
|
||||
let columns = png_width / width;
|
||||
let count = columns * (png_height / height);
|
||||
// Layers are square; a frame of another shape is nearest-resampled to
|
||||
// its longer side so UVs (normalized per layer) are unchanged.
|
||||
let side = width.max(height);
|
||||
@@ -309,7 +393,7 @@ impl Sprites {
|
||||
let source_row = y + row * height / side;
|
||||
for column in 0..side {
|
||||
let offset =
|
||||
((source_row * info.width + x + column * width / side) * 4) as usize;
|
||||
((source_row * png_width + x + column * width / side) * 4) as usize;
|
||||
frame.extend_from_slice(&pixels[offset..offset + 4]);
|
||||
}
|
||||
}
|
||||
@@ -339,7 +423,12 @@ impl Sprites {
|
||||
})
|
||||
}
|
||||
/// Give a decoded texture its layer (or the one an identical still has).
|
||||
fn register(&mut self, name: &str, decoded: crate::sprite_cache::Decoded) -> Result<u32> {
|
||||
fn register(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
name: &str,
|
||||
decoded: crate::sprite_cache::Decoded,
|
||||
) -> Result<u32> {
|
||||
let crate::sprite_cache::Decoded {
|
||||
side,
|
||||
frames,
|
||||
@@ -356,6 +445,13 @@ impl Sprites {
|
||||
.transpose()?;
|
||||
let count = frames.len() as u32;
|
||||
let layer = u32::try_from(self.native.len())?;
|
||||
let pbr = match name.split_once(':') {
|
||||
Some((namespace, path)) if labpbr_maps() => {
|
||||
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
|
||||
}
|
||||
_ => [None, None],
|
||||
};
|
||||
let settings = crate::mipmap::Settings {
|
||||
native_size: side,
|
||||
strategy,
|
||||
@@ -364,10 +460,15 @@ impl Sprites {
|
||||
item: name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("item/")),
|
||||
dither_cutouts: crate::mipmap::dither_cutouts(),
|
||||
};
|
||||
let still = (animation.is_none() && frames.len() == 1).then(|| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut maps = std::collections::hash_map::DefaultHasher::new();
|
||||
pbr.hash(&mut maps);
|
||||
(
|
||||
frames[0].clone(),
|
||||
maps.finish(),
|
||||
side,
|
||||
[
|
||||
settings.strategy as u32,
|
||||
@@ -401,12 +502,66 @@ impl Sprites {
|
||||
self.mips.push(settings);
|
||||
self.opaque.push(opaque);
|
||||
self.translucent.push(translucent);
|
||||
self.pbr.push(pbr);
|
||||
self.layers.insert(name.to_owned(), layer);
|
||||
self.native.push((side, frames, initial));
|
||||
Ok(layer)
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG's width, height and pixels as RGBA.
|
||||
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let mut bytes = vec![
|
||||
0;
|
||||
reader
|
||||
.output_buffer_size()
|
||||
.ok_or("sprite exceeds decoder limits")?
|
||||
];
|
||||
let info = reader.next_frame(&mut bytes)?;
|
||||
let channels = info.color_type.samples();
|
||||
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
|
||||
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
|
||||
let rgba = match info.color_type {
|
||||
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
|
||||
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
|
||||
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
|
||||
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
|
||||
_ => return Err("unsupported expanded sprite format".into()),
|
||||
};
|
||||
pixels.extend(rgba);
|
||||
}
|
||||
Ok((info.width, info.height, pixels))
|
||||
}
|
||||
|
||||
/// The LabPBR map with `suffix` beside the texture at `path`, whose frames lie in a column of
|
||||
/// `count`: its first frame, square, no larger than the maps' array allows any game's
|
||||
/// textures (a 512x pack's maps would otherwise fill the memory while loading).
|
||||
fn pbr_map(path: &Path, suffix: &str, count: u32) -> Option<(u32, Vec<u8>)> {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
let map = path.with_file_name(format!("{stem}{suffix}.png"));
|
||||
let (map_width, map_height, pixels) = read_rgba(&map).ok()?;
|
||||
let (w, h) = (map_width, map_height / count.max(1));
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let side = w.max(h);
|
||||
let mut frame = Vec::with_capacity((side * side * 4) as usize);
|
||||
for row in 0..side {
|
||||
for column in 0..side {
|
||||
let offset = (((row * h / side) * map_width + column * w / side) * 4) as usize;
|
||||
frame.extend_from_slice(&pixels[offset..offset + 4]);
|
||||
}
|
||||
}
|
||||
Some(shrink_map(
|
||||
frame,
|
||||
side,
|
||||
side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES),
|
||||
))
|
||||
}
|
||||
|
||||
/// SpriteContents: the `.mcmeta` `texture` section (TextureMetadataSection's
|
||||
/// mipmap strategy and alpha cutoff bias).
|
||||
fn texture_section(
|
||||
@@ -452,6 +607,54 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn the_texture_budget_picks_the_largest_side_that_fits() {
|
||||
// 1300 layers: vanilla's count, about.
|
||||
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
|
||||
assert_eq!(side_within(512, 1300, 1536 << 20), 512);
|
||||
assert_eq!(side_within(64, 1300, 1536 << 20), 64);
|
||||
// Never under vanilla's size, whatever the budget.
|
||||
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
|
||||
}
|
||||
#[test]
|
||||
fn a_labpbr_map_shrinks_by_plain_means_and_a_layer_without_one_gets_the_default() {
|
||||
// Alpha is data here: it does not weigh the other channels.
|
||||
let frame = [
|
||||
[200, 100, 0, 255],
|
||||
[0, 0, 0, 0],
|
||||
[200, 100, 0, 255],
|
||||
[0, 0, 0, 0],
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(shrink_map(frame, 2, 1), (1, vec![100, 50, 0, 128]));
|
||||
let mut sprites = Sprites {
|
||||
native: vec![
|
||||
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
|
||||
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
|
||||
],
|
||||
pbr: vec![
|
||||
[Some((16, [9, 8, 7, 6].repeat(16 * 16))), None],
|
||||
[None, None],
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
sprites.finish();
|
||||
assert_eq!(sprites.pbr_size, 16);
|
||||
assert_eq!(sprites.normals[..4], [9, 8, 7, 6]);
|
||||
assert_eq!(sprites.normals[16 * 16 * 4..][..4], FLAT_NORMAL);
|
||||
assert_eq!(sprites.specular[..4], NO_SPECULAR);
|
||||
assert_eq!(sprites.specular.len(), 2 * 16 * 16 * 4);
|
||||
}
|
||||
#[test]
|
||||
fn textures_without_labpbr_maps_keep_none() {
|
||||
let mut sprites = Sprites {
|
||||
native: vec![(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0)],
|
||||
pbr: vec![[None, None]],
|
||||
..Default::default()
|
||||
};
|
||||
sprites.finish();
|
||||
assert!(sprites.normals.is_empty() && sprites.specular.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn mipmap_option_keeps_real_16px_reductions() {
|
||||
for levels in 0..=4 {
|
||||
assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1);
|
||||
|
||||
@@ -215,6 +215,8 @@ enum Job {
|
||||
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
|
||||
/// off the main thread like vanilla's IOWorker.
|
||||
Save(Vec<((i32, i32), Proto)>),
|
||||
/// A proto chunk checked out for its light view (`light_region`).
|
||||
LightView(i32, i32, Proto),
|
||||
/// Meshes the renderer is done with (`Streaming::free`): their memory
|
||||
/// is given back here, not in the frame.
|
||||
Free(Vec<((i32, i32, i32), SectionMesh)>),
|
||||
@@ -235,6 +237,8 @@ pub enum Done {
|
||||
Lit(i32, i32, Vec<crate::chunk::Light>, u64),
|
||||
Meshed(i32, i32, i32, SectionMesh, u64),
|
||||
Saved(Vec<((i32, i32), Proto)>),
|
||||
/// A proto chunk back from `Job::LightView`, with its view.
|
||||
LightView(i32, i32, Proto, Option<Chunk>),
|
||||
}
|
||||
struct Queued {
|
||||
priority: i64,
|
||||
@@ -279,6 +283,8 @@ struct Shared {
|
||||
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
|
||||
/// job since: the last of them gives the freed memory back to the OS.
|
||||
released: AtomicUsize,
|
||||
/// The workers that started.
|
||||
workers: AtomicUsize,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct MeshPool {
|
||||
@@ -352,7 +358,6 @@ pub struct Streaming {
|
||||
/// Columns generated and saved as they were left behind: new when they
|
||||
/// next arrive, though read from the save (`World::loaded_chunks`).
|
||||
dropped_new: FxHashSet<(i32, i32)>,
|
||||
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
|
||||
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
@@ -377,6 +382,8 @@ pub struct Streaming {
|
||||
decorating_finals: HashSet<(i32, i32)>,
|
||||
/// Save jobs in flight.
|
||||
saving: usize,
|
||||
/// Proto chunks checked out to a worker for their light view.
|
||||
viewing: FxHashSet<(i32, i32)>,
|
||||
/// Vanilla's decoration order around the player, and the centre and
|
||||
/// radius it was last ranked for.
|
||||
decoration_order: DecorationOrder,
|
||||
@@ -618,8 +625,8 @@ impl Streaming {
|
||||
done: Mailbox::default(),
|
||||
meshes: Mutex::new(MeshPool::default()),
|
||||
released: AtomicUsize::new(0),
|
||||
workers: AtomicUsize::new(0),
|
||||
});
|
||||
let workers = threads.max(1);
|
||||
let light_tables = context.light.clone();
|
||||
let saves: Arc<dyn Saves> = generator.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
@@ -673,7 +680,7 @@ impl Streaming {
|
||||
// runs short: once every worker has freed its own,
|
||||
// hand them back.
|
||||
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
|
||||
== workers
|
||||
== shared.workers.load(Ordering::Relaxed)
|
||||
{
|
||||
// No worker needs retained mesh capacity now.
|
||||
// Drop it outside the pool lock before asking
|
||||
@@ -732,6 +739,10 @@ impl Streaming {
|
||||
generator.save_protos(&refs);
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::LightView(x, z, proto) => {
|
||||
let view = generator.light_view(&proto);
|
||||
Done::LightView(x, z, proto, view)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
for (_, mesh) in meshes {
|
||||
let rejected = shared.meshes.lock().unwrap().put(mesh);
|
||||
@@ -780,20 +791,22 @@ impl Streaming {
|
||||
shared.done.send(done);
|
||||
}
|
||||
})
|
||||
.expect("spawn worker")
|
||||
})
|
||||
.collect();
|
||||
let light_view = {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.light_view(p))
|
||||
};
|
||||
// A thread the system refuses (a container's process limit reached) leaves the pool a
|
||||
// worker short: a server that panicked here went down with its players' moves unsaved.
|
||||
.filter_map(|spawned| {
|
||||
spawned
|
||||
.inspect_err(|e| log::error!("Horizon: chunk worker not started: {e}"))
|
||||
.ok()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
shared.workers.store(threads.len(), Ordering::Relaxed);
|
||||
let non_air_sections = {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.non_air_sections(p))
|
||||
};
|
||||
Self {
|
||||
shared,
|
||||
light_view,
|
||||
non_air_sections,
|
||||
threads,
|
||||
order: 0,
|
||||
@@ -808,6 +821,7 @@ impl Streaming {
|
||||
decorating: 0,
|
||||
decorating_finals: HashSet::new(),
|
||||
saving: 0,
|
||||
viewing: Default::default(),
|
||||
decoration_order: DecorationOrder::default(),
|
||||
order_area: None,
|
||||
queue_basis: Default::default(),
|
||||
@@ -1092,28 +1106,42 @@ impl Streaming {
|
||||
/// The 3x3 a light job reads: the finished columns, and for a neighbour
|
||||
/// that will not finish (past the area), its decorated proto chunk, as
|
||||
/// vanilla lights its last FULL ring against neighbours at FEATURES.
|
||||
/// A proto neighbour without its view yet is checked out to a worker,
|
||||
/// which builds the view off the server thread (`Job::LightView`); the
|
||||
/// region is asked for again once it is back.
|
||||
fn light_region(&mut self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
let mut region: Region = Default::default();
|
||||
let mut complete = true;
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
let n = (x + dx as i32 - 1, z + dz as i32 - 1);
|
||||
region[dz][dx] = Some(match world.chunks.get(&n) {
|
||||
Some(chunk) => chunk.clone(),
|
||||
region[dz][dx] = match world.chunks.get(&n) {
|
||||
Some(chunk) => Some(chunk.clone()),
|
||||
None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => {
|
||||
if let Some(view) = self.light_views.get(&n) {
|
||||
view.clone()
|
||||
Some(view.clone())
|
||||
} else {
|
||||
let proto = self.protos.get(&n)?.as_ref()?;
|
||||
let view = Arc::new((self.light_view)(proto)?);
|
||||
self.light_views.insert(n, view.clone());
|
||||
view
|
||||
let proto = self.protos.get_mut(&n)?.take()?;
|
||||
self.viewing.insert(n);
|
||||
self.submit(Some((x, z)), 0, Job::LightView(n.0, n.1, proto));
|
||||
complete = false;
|
||||
None
|
||||
}
|
||||
}
|
||||
None => return None,
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
Some(Box::new(region))
|
||||
complete.then(|| Box::new(region))
|
||||
}
|
||||
/// A proto chunk back from its light view: the view waits in
|
||||
/// `light_views` for the light jobs that asked for it.
|
||||
fn receive_light_view(&mut self, x: i32, z: i32, proto: Proto, view: Option<Chunk>) {
|
||||
self.viewing.remove(&(x, z));
|
||||
self.protos.insert((x, z), Some(proto));
|
||||
if let Some(view) = view {
|
||||
self.light_views.insert((x, z), Arc::new(view));
|
||||
}
|
||||
}
|
||||
/// Whether `light_region` would find all nine columns: the same walk
|
||||
/// without building the snapshot, so the scheduler can rank the ready
|
||||
@@ -1201,6 +1229,7 @@ impl Streaming {
|
||||
pub fn busy(&self) -> bool {
|
||||
!(self.generating.is_empty()
|
||||
&& self.decorating == 0
|
||||
&& self.viewing.is_empty()
|
||||
&& self.finishing.is_empty()
|
||||
&& self.lighting.is_empty()
|
||||
&& self.meshing.is_empty()
|
||||
@@ -1264,6 +1293,7 @@ impl Streaming {
|
||||
}
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
Done::Saved(protos) => self.receive_saved(protos),
|
||||
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
|
||||
Done::Lit(x, z, sections, seq) => {
|
||||
if self.lighting.get(&(x, z)) != Some(&seq) {
|
||||
continue;
|
||||
@@ -1822,7 +1852,11 @@ impl Streaming {
|
||||
return;
|
||||
}
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
|
||||
while self.decorating > 0
|
||||
|| !self.finishing.is_empty()
|
||||
|| self.saving > 0
|
||||
|| !self.viewing.is_empty()
|
||||
{
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match self.shared.done.recv_timeout(left) {
|
||||
Some(Done::Decorated(x, z, region, finals, writes)) => {
|
||||
@@ -1830,6 +1864,9 @@ impl Streaming {
|
||||
}
|
||||
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
||||
Some(Done::Saved(protos)) => self.receive_saved(protos),
|
||||
Some(Done::LightView(x, z, proto, view)) => {
|
||||
self.receive_light_view(x, z, proto, view)
|
||||
}
|
||||
Some(other) => self.deferred.push(other),
|
||||
None => {
|
||||
log::warn!("Horizon: chunk generation did not finish before saving");
|
||||
@@ -1860,13 +1897,18 @@ impl Streaming {
|
||||
}
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
Done::Saved(protos) => self.receive_saved(protos),
|
||||
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
|
||||
other => self.deferred.push(other),
|
||||
}
|
||||
if started.elapsed() >= std::time::Duration::from_millis(2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
|
||||
if self.decorating > 0
|
||||
|| !self.finishing.is_empty()
|
||||
|| self.saving > 0
|
||||
|| !self.viewing.is_empty()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let written: Vec<_> = self.written.drain().collect();
|
||||
@@ -1940,7 +1982,11 @@ impl Streaming {
|
||||
counts[1] += 1;
|
||||
} else {
|
||||
counts[2] += 1;
|
||||
if self.light_region(world, key.0, key.1).is_some() {
|
||||
if self.light_region(world, key.0, key.1).is_some()
|
||||
|| (-1..=1).any(|dz| {
|
||||
(-1..=1).any(|dx| self.viewing.contains(&(key.0 + dx, key.1 + dz)))
|
||||
})
|
||||
{
|
||||
world.dirty_light.insert(key);
|
||||
}
|
||||
}
|
||||
@@ -2325,6 +2371,7 @@ mod tests {
|
||||
mesh.solid.resize(64, bytemuck::Zeroable::zeroed());
|
||||
mesh.translucent.resize(16, bytemuck::Zeroable::zeroed());
|
||||
mesh.block_entities.push(([1, 2, 3], 7));
|
||||
mesh.lights.push(([1, 2, 3], 7));
|
||||
mesh.visibility = 0;
|
||||
mesh.sides = crate::mesher::Sides::unsorted(16);
|
||||
let capacities = mesh.capacity_bytes();
|
||||
@@ -2341,6 +2388,7 @@ mod tests {
|
||||
assert!(reused.solid.is_empty());
|
||||
assert!(reused.translucent.is_empty());
|
||||
assert!(reused.block_entities.is_empty());
|
||||
assert!(reused.lights.is_empty());
|
||||
assert_eq!(reused.visibility, crate::mesher::ALL_VISIBLE);
|
||||
assert_eq!(reused.sides, crate::mesher::Sides::default());
|
||||
assert_eq!(pool.bytes, 0);
|
||||
|
||||
@@ -98,6 +98,25 @@ thread_local! {
|
||||
static TARGETS: RefCell<(Vec<Target>, Vec<Target>, u64)> = const { RefCell::new((Vec::new(), Vec::new(), 0)) };
|
||||
}
|
||||
|
||||
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
|
||||
/// runs on this thread ([`take`] and [`put`] around each turn).
|
||||
#[derive(Default)]
|
||||
pub struct Saved(
|
||||
(Vec<Widget>, Vec<Widget>, u64),
|
||||
(Vec<Target>, Vec<Target>, u64),
|
||||
);
|
||||
|
||||
/// This thread's buttons and targets, leaving none.
|
||||
pub fn take() -> Saved {
|
||||
Saved(WIDGETS.take(), TARGETS.take())
|
||||
}
|
||||
|
||||
/// Bring back what [`take`] kept.
|
||||
pub fn put(saved: Saved) {
|
||||
WIDGETS.set(saved.0);
|
||||
TARGETS.set(saved.1);
|
||||
}
|
||||
|
||||
/// An interactive rectangle (GUI pixels) a controller can focus and click.
|
||||
pub fn target(rect: [f32; 4]) {
|
||||
target_as(rect, Kind::Button);
|
||||
|
||||
@@ -83,6 +83,13 @@ open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# on the PATH.
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
|
||||
# `above_menu_bar`); all already in the tree through winit and wgpu.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.pages.split_settings": "Splitscreen Settings",
|
||||
"horizon.pages.split_setup": "Splitscreen",
|
||||
"horizon.pages.split_profiles": "Who's playing?",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
@@ -41,6 +43,7 @@
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
|
||||
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
"horizon.pages.split_setup.tooltip": "Players on this computer: controllers can join your world.",
|
||||
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
|
||||
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
|
||||
@@ -48,6 +51,7 @@
|
||||
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
|
||||
"horizon.pages.open.shader_packs": "Shader Packs...",
|
||||
"horizon.pages.open.controller": "Controller Settings...",
|
||||
"horizon.pages.open.split_setup": "Splitscreen...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "AI Agent...",
|
||||
@@ -205,5 +209,9 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
|
||||
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
|
||||
"horizon.loading.opening_world": "Opening world",
|
||||
"horizon.loading.cancelling_world": "Cancelling world"
|
||||
"horizon.loading.cancelling_world": "Cancelling world",
|
||||
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
|
||||
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay..."
|
||||
}
|
||||
@@ -199,5 +199,9 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
|
||||
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
|
||||
"horizon.loading.opening_world": "Abriendo el mundo",
|
||||
"horizon.loading.cancelling_world": "Cancelando"
|
||||
"horizon.loading.cancelling_world": "Cancelando",
|
||||
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
|
||||
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…"
|
||||
}
|
||||
@@ -14,5 +14,9 @@
|
||||
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
"horizon.loading.opening_world": "Otwieranie świata",
|
||||
"horizon.loading.cancelling_world": "Anulowanie"
|
||||
"horizon.loading.cancelling_world": "Anulowanie",
|
||||
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
|
||||
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…"
|
||||
}
|
||||
@@ -198,5 +198,9 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.loading.opening_world": "Открытие мира",
|
||||
"horizon.loading.cancelling_world": "Отмена"
|
||||
"horizon.loading.cancelling_world": "Отмена",
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…"
|
||||
}
|
||||
@@ -14,5 +14,9 @@
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
"horizon.loading.opening_world": "Відкриття світу",
|
||||
"horizon.loading.cancelling_world": "Скасування"
|
||||
"horizon.loading.cancelling_world": "Скасування",
|
||||
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
|
||||
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
|
||||
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…"
|
||||
}
|
||||
@@ -199,5 +199,9 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
|
||||
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
|
||||
"horizon.loading.opening_world": "正在打开世界",
|
||||
"horizon.loading.cancelling_world": "正在取消"
|
||||
"horizon.loading.cancelling_world": "正在取消",
|
||||
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
|
||||
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
|
||||
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
|
||||
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…"
|
||||
}
|
||||
@@ -5,6 +5,15 @@ fn main() {
|
||||
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
|
||||
println!("cargo:rerun-if-changed={ico}");
|
||||
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
|
||||
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
|
||||
Ok("msvc") => "/STACK:8388608",
|
||||
_ => "-Wl,--stack,8388608",
|
||||
};
|
||||
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let mut resource = winresource::WindowsResource::new();
|
||||
|
||||
@@ -133,7 +133,7 @@ fn main() -> Result<()> {
|
||||
.collect();
|
||||
let descriptor = BlockState {
|
||||
name: name.clone(),
|
||||
properties,
|
||||
properties: properties.into(),
|
||||
};
|
||||
let Some(id) = registry.id(&descriptor) else {
|
||||
states.insert(key.clone(), Value::Null);
|
||||
|
||||
@@ -291,6 +291,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for d in items {
|
||||
let _ = match d {
|
||||
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
|
||||
// The fixture's mobs keep no whole stacks.
|
||||
drops::Drop::Stack(s) => writeln!(out, "drop {} {}", s.item, s.count),
|
||||
drops::Drop::CarriedBlock(state) => {
|
||||
let stacks = enderman::carried_block_drops(
|
||||
&block_items,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use horizon_client::interaction_input::{
|
||||
Button, InteractionInput, InteractionWorld, TargetUseWorld, use_on_target,
|
||||
Button, InteractionInput, InteractionWorld, TargetUseWorld, release_on_screen_open,
|
||||
use_on_target,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::io::{BufRead, Write};
|
||||
@@ -103,14 +104,23 @@ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
passing: row["passing"].as_bool().unwrap_or(false),
|
||||
};
|
||||
let mut input = InteractionInput::default();
|
||||
for button in row["clicks"].as_array().unwrap() {
|
||||
input.press(match button.as_u64().unwrap() {
|
||||
0 => Button::Attack,
|
||||
1 => Button::Use,
|
||||
_ => Button::Pick,
|
||||
});
|
||||
let screen = row["screen"].as_bool().unwrap_or(false);
|
||||
if screen {
|
||||
// A keybind opened a screen: Gui.setScreen already ran
|
||||
// KeyMapping.releaseAll, so the same handleKeybinds call releases an
|
||||
// active use; with the screen open nothing else acts this tick.
|
||||
release_on_screen_open(&mut world);
|
||||
input.tick(&mut world, false, false);
|
||||
} else {
|
||||
for button in row["clicks"].as_array().unwrap() {
|
||||
input.press(match button.as_u64().unwrap() {
|
||||
0 => Button::Attack,
|
||||
1 => Button::Use,
|
||||
_ => Button::Pick,
|
||||
});
|
||||
}
|
||||
input.tick(&mut world, flag("attack_down"), flag("use_down"));
|
||||
}
|
||||
input.tick(&mut world, flag("attack_down"), flag("use_down"));
|
||||
writeln!(out, "== {i}")?;
|
||||
for event in world.events {
|
||||
writeln!(out, "{event}")?;
|
||||
|
||||
@@ -179,7 +179,7 @@ fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
|
||||
registry
|
||||
.resolve(&BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
properties: properties.into(),
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("{text}: {e}"))
|
||||
}
|
||||
@@ -1315,7 +1315,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
continue;
|
||||
}
|
||||
if let Some(Some((name, volume, pitch))) =
|
||||
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
|
||||
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random, None)
|
||||
{
|
||||
events.push(MobEvent::Sound(name, [0.0; 3], volume, pitch));
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ fn parse_state(spec: &str) -> BlockState {
|
||||
.collect();
|
||||
BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
properties: properties.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
))?;
|
||||
let parse_ms = parse_started.elapsed().as_secs_f64() * 1000.0;
|
||||
let facts_started = Instant::now();
|
||||
let (facts, gameplay) = BlockFacts::load_with_projection(root, assets.registry, &json)?;
|
||||
let (facts, gameplay) =
|
||||
BlockFacts::load_with_projection(root, assets.registry, &json, true)?;
|
||||
let facts_ms = facts_started.elapsed().as_secs_f64() * 1000.0;
|
||||
let behaviours_started = Instant::now();
|
||||
let behaviours = Behaviours::load_with_gameplay(assets.registry, root, &json, &gameplay)?;
|
||||
|
||||
@@ -1071,6 +1071,15 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
}
|
||||
effects.advance(step);
|
||||
let name = &*self.scene.facts.get(state).name;
|
||||
crate::block_contact::emit(crate::block_contact::Event::Inside(
|
||||
crate::block_contact::Contact {
|
||||
entity: self.p.id as i32,
|
||||
kind: self.p.kind.id(),
|
||||
block: name,
|
||||
state,
|
||||
position: p,
|
||||
},
|
||||
));
|
||||
if matches!(name, "minecraft:fire" | "minecraft:soul_fire") {
|
||||
// BaseFireBlock.entityInside (its inFire hurt does nothing to arrows).
|
||||
effects.apply(Effect::ClearFreeze);
|
||||
|
||||
@@ -92,9 +92,7 @@ impl Banners {
|
||||
});
|
||||
}
|
||||
banners.no_item_required = read_tag(root, "no_item_required", 0);
|
||||
if let Some(raw) = fs::read(root.join("items.json")).ok().and_then(|b| {
|
||||
serde_json::from_slice::<std::collections::BTreeMap<String, Value>>(&b).ok()
|
||||
}) {
|
||||
if let Ok(raw) = horizon_world::item_defaults::read(root) {
|
||||
for (item, c) in raw {
|
||||
let id: Arc<str> = Arc::from(item.as_str());
|
||||
if let Some(dye) = c["minecraft:dye"].as_str() {
|
||||
|
||||
@@ -194,7 +194,8 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[flag("world", Ty::Str, "world folder to open or create"), flag("seed", Ty::Str, "seed for a new world"), flag("creative", Ty::Bool, "creative mode for a new world"),
|
||||
flag("version", Ty::Str, "an installed launcher version tag such as v2026.10.13"), flag("game", Ty::Str, "path to a horizon-client binary"),
|
||||
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)"),
|
||||
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions")],
|
||||
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions"),
|
||||
flag("headless", Ty::Bool, "no display, window or GPU: a player that plays through these commands only (no screenshots)")],
|
||||
mcp: true, local: true),
|
||||
cmd!("wait-for", Session, "wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND [--timeout S] [--since SEQ]", "wait until the game reaches a state, then reply",
|
||||
"screen takes the names state shows (playing, inventory, paused, dead, title...); widget an id from ui; loaded means a world is open, the player is in it and no chunk is pending; chat a new line containing TEXT; event a kind from events (death, respawn, item, screen...). chat and event count from --since SEQ (take last from events before acting), else from now. A timeout (default 30 s) is an error with kind timeout.",
|
||||
|
||||
@@ -6,7 +6,19 @@ use crate::{Session, commands};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
const PROTOCOL: &str = "2025-06-18";
|
||||
/// Revisions this server implements. Older ones are not claimed: only the revision actually
|
||||
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
|
||||
const SUPPORTED: &[&str] = &["2025-06-18"];
|
||||
|
||||
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
|
||||
fn negotiate_version(params: &Value) -> &'static str {
|
||||
let wanted = params.get("protocolVersion").and_then(Value::as_str);
|
||||
SUPPORTED
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|v| Some(*v) == wanted)
|
||||
.unwrap_or(SUPPORTED[0])
|
||||
}
|
||||
|
||||
fn tools() -> Vec<Value> {
|
||||
commands::COMMANDS
|
||||
@@ -134,7 +146,7 @@ impl Session {
|
||||
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
|
||||
"protocolVersion": negotiate_version(params),
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
|
||||
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
|
||||
@@ -194,7 +206,17 @@ pub fn serve(session: &mut Session) -> Result<(), String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn version_negotiation() {
|
||||
let v = |p: Value| negotiate_version(&p);
|
||||
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
|
||||
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
|
||||
assert_eq!(v(json!({})), "2025-06-18");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_matches_rfc_4648() {
|
||||
@@ -234,9 +256,9 @@ mod tests {
|
||||
fn protocol_basics() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
|
||||
let init = s
|
||||
.handle("initialize", &json!({ "protocolVersion": "2025-03-26" }))
|
||||
.handle("initialize", &json!({ "protocolVersion": "2025-06-18" }))
|
||||
.unwrap();
|
||||
assert_eq!(init["protocolVersion"], "2025-03-26");
|
||||
assert_eq!(init["protocolVersion"], "2025-06-18");
|
||||
assert!(s.handle("nope", &json!({})).is_err());
|
||||
let r = s
|
||||
.handle(
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Synchronous mod-block contacts from the server's simulation code.
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
cell::Cell,
|
||||
sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Contact<'a> {
|
||||
pub entity: i32,
|
||||
pub kind: &'a str,
|
||||
pub block: &'a str,
|
||||
pub state: BlockStateId,
|
||||
pub position: BlockPos,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Event<'a> {
|
||||
StepOn(Contact<'a>),
|
||||
Inside(Contact<'a>),
|
||||
FallOn(Contact<'a>, f64),
|
||||
}
|
||||
|
||||
pub static HOOK: OnceLock<fn(&str, Event<'_>, &mut f32)> = OnceLock::new();
|
||||
/// Whether the loaded blocks hold one outside `minecraft:` (a content mod's).
|
||||
/// Without one no contact can dispatch, so the item and projectile sweeps
|
||||
/// skip their per-tick work: every item and thrown thing on a server would
|
||||
/// otherwise sweep its blocks each tick for nothing.
|
||||
static MOD_BLOCKS: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Called when the block tables load.
|
||||
pub fn set_mod_blocks(any: bool) {
|
||||
MOD_BLOCKS.store(any, Ordering::Relaxed);
|
||||
}
|
||||
thread_local! {
|
||||
static LEVEL: Cell<Option<&'static str>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Scope authority to this thread and restore it even on an error or panic.
|
||||
/// Nested guest actions may temporarily enter a different dimension.
|
||||
pub fn in_level<R>(dimension: Option<&'static str>, f: impl FnOnce() -> R) -> R {
|
||||
struct Restore(Option<&'static str>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
LEVEL.set(self.0);
|
||||
}
|
||||
}
|
||||
let _restore = Restore(LEVEL.replace(dimension));
|
||||
f()
|
||||
}
|
||||
|
||||
pub fn emit(event: Event<'_>) {
|
||||
dispatch(event, &mut 0.0);
|
||||
}
|
||||
|
||||
/// Damage is the calculated fall damage, before hurt handling and fall sounds.
|
||||
pub fn fall(contact: Contact<'_>, distance: f64, mut damage: f32) -> f32 {
|
||||
dispatch(Event::FallOn(contact, distance), &mut damage);
|
||||
damage
|
||||
}
|
||||
|
||||
fn dispatch(event: Event<'_>, damage: &mut f32) {
|
||||
let contact = match event {
|
||||
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
|
||||
};
|
||||
if contact.block.starts_with("minecraft:") {
|
||||
return;
|
||||
}
|
||||
if let Some(dimension) = LEVEL.get()
|
||||
&& let Some(hook) = HOOK.get()
|
||||
{
|
||||
let original = *damage;
|
||||
hook(dimension, event, damage);
|
||||
if !damage.is_finite() {
|
||||
*damage = original;
|
||||
}
|
||||
*damage = damage.max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reuse the entity block sweep for item and projectile ticks without an effect collector.
|
||||
pub fn swept_inside<'a>(
|
||||
entity: i32,
|
||||
kind: &str,
|
||||
from: horizon_core::geometry::Vec3,
|
||||
to: horizon_core::geometry::Vec3,
|
||||
bounds: horizon_core::geometry::Aabb,
|
||||
mut block: impl FnMut(BlockPos) -> (BlockStateId, &'a str),
|
||||
) {
|
||||
if !MOD_BLOCKS.load(Ordering::Relaxed) || LEVEL.get().is_none() || HOOK.get().is_none() {
|
||||
return;
|
||||
}
|
||||
let bounds = bounds.deflate(horizon_core::geometry::Vec3::new(1e-5, 1e-5, 1e-5));
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let _ =
|
||||
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, step| {
|
||||
if step >= 16 {
|
||||
return false;
|
||||
}
|
||||
if visited.insert(p) {
|
||||
let (state, name) = block(p);
|
||||
emit(Event::Inside(Contact {
|
||||
entity,
|
||||
kind,
|
||||
block: name,
|
||||
state,
|
||||
position: p,
|
||||
}));
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
thread_local! {
|
||||
static SEEN: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
fn hook(dimension: &str, event: Event<'_>, damage: &mut f32) {
|
||||
assert_eq!(dimension, "minecraft:the_nether");
|
||||
let c = match event {
|
||||
Event::FallOn(c, distance) => {
|
||||
assert_eq!(distance, 12.0);
|
||||
*damage = match *damage {
|
||||
99.0 => f32::NAN,
|
||||
98.0 => -1.0,
|
||||
_ => *damage * 0.2,
|
||||
};
|
||||
c
|
||||
}
|
||||
Event::Inside(c) | Event::StepOn(c) => c,
|
||||
};
|
||||
assert_eq!(
|
||||
(c.entity, c.kind, c.state, c.position),
|
||||
(
|
||||
17,
|
||||
"minecraft:zombie",
|
||||
BlockStateId(9),
|
||||
BlockPos::new(1, 2, 3)
|
||||
)
|
||||
);
|
||||
SEEN.set(SEEN.get() + 1);
|
||||
}
|
||||
#[test]
|
||||
fn contacts_require_server_scope_and_mod_blocks_and_restore_nested_levels() {
|
||||
HOOK.set(hook).unwrap();
|
||||
let c = Contact {
|
||||
entity: 17,
|
||||
kind: "minecraft:zombie",
|
||||
block: "test:hay",
|
||||
state: BlockStateId(9),
|
||||
position: BlockPos::new(1, 2, 3),
|
||||
};
|
||||
assert_eq!(fall(c, 12.0, 10.0), 10.0);
|
||||
in_level(Some("minecraft:the_nether"), || {
|
||||
emit(Event::StepOn(c));
|
||||
emit(Event::Inside(c));
|
||||
assert_eq!(fall(c, 12.0, 10.0), 2.0);
|
||||
in_level(None, || assert_eq!(fall(c, 12.0, 10.0), 10.0));
|
||||
assert_eq!(fall(c, 12.0, 0.0), 0.0);
|
||||
emit(Event::Inside(Contact {
|
||||
block: "minecraft:hay_block",
|
||||
..c
|
||||
}));
|
||||
});
|
||||
assert_eq!(fall(c, 12.0, 10.0), 10.0);
|
||||
assert_eq!(SEEN.get(), 4);
|
||||
in_level(Some("minecraft:the_nether"), || {
|
||||
assert_eq!(fall(c, 12.0, 99.0), 99.0);
|
||||
assert_eq!(fall(c, 12.0, 98.0), 0.0);
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
let to = Vec3::new(2.5, 2.0, 3.5);
|
||||
let bounds = Aabb::new(Vec3::new(2.25, 2.0, 3.25), Vec3::new(2.75, 2.5, 3.75));
|
||||
let block = |p| {
|
||||
(
|
||||
BlockStateId(9),
|
||||
if p == c.position {
|
||||
"test:hay"
|
||||
} else {
|
||||
"minecraft:air"
|
||||
},
|
||||
)
|
||||
};
|
||||
set_mod_blocks(true);
|
||||
swept_inside(17, c.kind, Vec3::new(0.5, 2.0, 3.5), to, bounds, block);
|
||||
assert_eq!(SEEN.get(), 7, "a block crossed mid-move is notified once");
|
||||
let at = Vec3::new(1.5, 2.0, 3.5);
|
||||
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
|
||||
swept_inside(17, c.kind, at, at, bounds, block);
|
||||
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
|
||||
});
|
||||
let _ =
|
||||
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
|
||||
assert_eq!(fall(c, 12.0, 10.0), 10.0);
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,25 @@ fn custom_only(saved: &Tag) -> Compound {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockEntity.collectComponents`: explicit components plus the type's implicit components.
|
||||
/// Unlike Ctrl + Pick Block, this does not carry the remaining block entity NBT.
|
||||
pub fn collected_components(saved: &Tag) -> Compound {
|
||||
let id = block_entity_nbt::id(saved).unwrap_or_default();
|
||||
let mut collected = match saved.get("components") {
|
||||
Some(Tag::Compound(c)) => c.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
for part in parts(&id) {
|
||||
if let Some((component, value)) = collect(*part, saved) {
|
||||
match value {
|
||||
Some(v) => collected.insert(component.into(), v),
|
||||
None => collected.remove(&JavaString::from(component)),
|
||||
};
|
||||
}
|
||||
}
|
||||
collected
|
||||
}
|
||||
|
||||
/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone
|
||||
/// item of the block entity saved as `saved` (a full, canonical compound).
|
||||
pub fn pick(saved: &Tag) -> Compound {
|
||||
@@ -310,19 +329,7 @@ pub fn pick(saved: &Tag) -> Compound {
|
||||
// ItemStack.applyComponents(collectComponents()): the block entity's own
|
||||
// components, then the implicit ones; a value equal to the item's own is
|
||||
// no change.
|
||||
let mut collected = match saved.get("components") {
|
||||
Some(Tag::Compound(c)) => c.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
for part in parts(&id) {
|
||||
if let Some((component, value)) = collect(*part, saved) {
|
||||
match value {
|
||||
Some(v) => collected.insert(component.into(), v),
|
||||
None => collected.remove(&JavaString::from(component)),
|
||||
};
|
||||
}
|
||||
}
|
||||
for (component, value) in collected {
|
||||
for (component, value) in collected_components(saved) {
|
||||
if item_default(&component.to_string_lossy()).as_ref() == Some(&value) {
|
||||
patch.remove(&component);
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Identity of the ordered block-state registry used by chunk packets, and of
|
||||
//! the item and mob effect registries once content mods add to them (item
|
||||
//! stacks and effect packets carry their network ids).
|
||||
//! the item, mob effect, entity type and menu registries once content mods
|
||||
//! add to them (item stacks, effect, entity and open screen packets carry
|
||||
//! their network ids).
|
||||
use horizon_protocol::registry::Builtin;
|
||||
use serde::{Deserialize, Serialize, de::IgnoredAny};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -77,14 +78,13 @@ pub fn identity(root: &Path) -> Result<[u8; 32], IdentityError> {
|
||||
if !components.is_empty() {
|
||||
hash = hash_entries(hash, "horizon:nbt_components/v1", &components);
|
||||
// Defaults are not sent in a stack patch. Both peers must therefore
|
||||
// agree on their values as well as the registered wire IDs.
|
||||
let bytes = std::fs::read(root.join("items.json")).map_err(IdentityError::Read)?;
|
||||
let items: BTreeMap<String, BTreeMap<String, serde_json::Value>> =
|
||||
serde_json::from_slice(&bytes).map_err(IdentityError::Json)?;
|
||||
// agree on their values as well as the registered wire IDs, native
|
||||
// mods' changes included.
|
||||
let items = horizon_world::item_defaults::read(root).map_err(IdentityError::Read)?;
|
||||
let mut defaults = Vec::new();
|
||||
for (item, components) in items {
|
||||
for (name, value) in components {
|
||||
if horizon_protocol::item::custom::id(&name).is_some() {
|
||||
for (name, value) in components.as_object().into_iter().flatten() {
|
||||
if horizon_protocol::item::custom::id(name).is_some() {
|
||||
defaults.push((defaults.len() as u64, format!("{item}\0{name}\0{value}")));
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,12 @@ fn hash_facts(bytes: &[u8]) -> Result<[u8; 32], IdentityError> {
|
||||
}
|
||||
|
||||
/// The built-in registries whose network ids content mods extend besides blocks.
|
||||
const REGISTRIES: [&str; 2] = ["minecraft:item", "minecraft:mob_effect"];
|
||||
const REGISTRIES: [&str; 4] = [
|
||||
"minecraft:item",
|
||||
"minecraft:mob_effect",
|
||||
"minecraft:entity_type",
|
||||
"minecraft:menu",
|
||||
];
|
||||
|
||||
fn parse_registries(bytes: &[u8]) -> Result<Builtin, IdentityError> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| IdentityError::Registries(e.to_string()))?;
|
||||
@@ -177,6 +182,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// a mod menu type in the report changes the identity, so a client
|
||||
/// without it is refused before it could be sent an id it lacks.
|
||||
#[test]
|
||||
fn a_mod_menu_type_joins_the_identity() {
|
||||
let root = |name: &str, extra: &str| {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-content-identity-menu-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let facts = br#"{"states":[["minecraft:stone",false,false,"Block{minecraft:stone}",{"id":"minecraft:stone"}]]}"#;
|
||||
std::fs::write(root.join("facts.json"), facts).unwrap();
|
||||
let report = format!(
|
||||
r#"{{"minecraft:menu":{{"entries":{{"minecraft:generic_9x1":{{"protocol_id":0}}{extra}}}}}}}"#
|
||||
);
|
||||
std::fs::write(root.join("registries.json"), report).unwrap();
|
||||
let hash = identity(&root).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
hash
|
||||
};
|
||||
let vanilla = root("vanilla", "");
|
||||
let press = root("press", r#","ruby:press":{"protocol_id":25}"#);
|
||||
assert_ne!(press, vanilla);
|
||||
assert_ne!(press, root("mill", r#","ruby:mill":{"protocol_id":25}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_without_mod_entries_keeps_its_block_identity() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -44,10 +44,73 @@ impl Ingredient {
|
||||
match v {
|
||||
Value::String(s) => add(s),
|
||||
Value::Array(a) => a.iter().filter_map(Value::as_str).for_each(&mut add),
|
||||
Value::Object(o) => return Self::parse_object(o, items),
|
||||
_ => return None,
|
||||
}
|
||||
(!list.is_empty()).then(|| Self::of(list))
|
||||
}
|
||||
/// NeoForge's ingredient types (`NeoForgeRegistries.Keys.INGREDIENT_TYPES`),
|
||||
/// the standard shapes mods' recipes carry, each resolved to the flat item
|
||||
/// list it matches. A kind outside these still fails the file: a mod's
|
||||
/// own ingredient types come with their own hook, not the loader.
|
||||
fn parse_object(o: &serde_json::Map<String, Value>, items: &Items) -> Option<Self> {
|
||||
// Current NeoForge marks a custom ingredient with
|
||||
// `neoforge:ingredient_type`; `type` is the older packs' key.
|
||||
let kind = o
|
||||
.get("neoforge:ingredient_type")
|
||||
.or_else(|| o.get("type"))?
|
||||
.as_str()?;
|
||||
let children = || {
|
||||
o.get("children")
|
||||
.or_else(|| o.get("ingredients"))
|
||||
.and_then(Value::as_array)
|
||||
};
|
||||
let kept = match kind {
|
||||
// `CompoundIngredient`: any of its children matching the stack
|
||||
// is enough — the union of their items.
|
||||
"neoforge:compound" => {
|
||||
let subs: Vec<Self> = children()?
|
||||
.iter()
|
||||
.map(|s| Self::parse(s, items))
|
||||
.collect::<Option<_>>()?;
|
||||
let mut seen = Vec::new();
|
||||
for sub in &subs {
|
||||
for item in &sub.1 {
|
||||
if !seen.contains(item) {
|
||||
seen.push(item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
// `IntersectionIngredient`: every child must match the same
|
||||
// stack — the items all of them name.
|
||||
"neoforge:intersection" => {
|
||||
let subs: Vec<Self> = children()?
|
||||
.iter()
|
||||
.map(|s| Self::parse(s, items))
|
||||
.collect::<Option<_>>()?;
|
||||
subs.first()?
|
||||
.1
|
||||
.iter()
|
||||
.filter(|i| subs[1..].iter().all(|s| s.0.contains(*i)))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
// The base's items without the subtracted's.
|
||||
"neoforge:difference" => {
|
||||
let base = Self::parse(o.get("base")?, items)?;
|
||||
let gone = Self::parse(o.get("subtracted")?, items)?;
|
||||
base.1
|
||||
.iter()
|
||||
.filter(|i| !gone.0.contains(*i))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
(!kept.is_empty()).then(|| Self::of(kept))
|
||||
}
|
||||
pub fn test(&self, stack: &ItemStack) -> bool {
|
||||
self.0.contains(&*stack.item)
|
||||
}
|
||||
@@ -1252,6 +1315,18 @@ impl Recipes {
|
||||
let mut meta = HashMap::new();
|
||||
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
|
||||
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
// NeoForge's load conditions: `mod_loaded` asks which mods' data is
|
||||
// in this root (`minecraft`, always, and the content mods').
|
||||
// `mod_loaded` asks which mods are loaded, not which namespaces
|
||||
// they own: a mod that also owns a namespace doesn't make that
|
||||
// namespace another mod.
|
||||
let mods: Vec<String> = {
|
||||
let mut mods = horizon_items::content_namespaces::mod_ids(root);
|
||||
if !mods.iter().any(|m| m == "minecraft") {
|
||||
mods.push("minecraft".to_owned());
|
||||
}
|
||||
mods
|
||||
};
|
||||
for (name, p) in recipe_files(&root.join("data/minecraft/recipe"))? {
|
||||
sources.push((name, fs::read(&p)?));
|
||||
}
|
||||
@@ -1291,6 +1366,13 @@ impl Recipes {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// A file whose load conditions don't hold is silently absent,
|
||||
// as NeoForge's ICondition leaves it out of the manager.
|
||||
if !horizon_world::conditions::conditions_hold(&v, &|id: &str| {
|
||||
mods.iter().any(|m| m == id)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if let Some(kind) = custom_type(&v) {
|
||||
let id = if name.contains(':') {
|
||||
name
|
||||
@@ -1759,6 +1841,99 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod's recipe whose ingredient is one of NeoForge's standard shapes
|
||||
/// loads and carries the resolved items, where the plain string parser
|
||||
/// used to drop the file whole.
|
||||
#[test]
|
||||
fn a_neoforge_ingredient_recipe_loads() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-neo-ingredient-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
fs::create_dir_all(root.join("data/mymod/recipe")).unwrap();
|
||||
fs::write(
|
||||
root.join(horizon_items::content_namespaces::MARKER),
|
||||
br#"["mymod"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let recipe = r##"{
|
||||
"type": "minecraft:crafting_shapeless",
|
||||
"ingredients": [
|
||||
{"neoforge:ingredient_type": "neoforge:compound",
|
||||
"children": ["minecraft:oak_planks", "minecraft:birch_planks"]},
|
||||
{"type": "neoforge:difference",
|
||||
"base": ["minecraft:oak_planks", "minecraft:birch_planks"],
|
||||
"subtracted": "minecraft:birch_planks"}
|
||||
],
|
||||
"result": {"id": "minecraft:stick", "count": 4}
|
||||
}"##;
|
||||
fs::write(root.join("data/mymod/recipe/treated.json"), recipe).unwrap();
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
assert_eq!(recipes.crafting.len(), 1);
|
||||
assert_eq!(recipes.crafting[0].0, "mymod:treated");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// NeoForge's standard ingredient shapes resolve to their flat item
|
||||
/// lists; a kind the loader doesn't know still fails the file.
|
||||
#[test]
|
||||
fn neoforge_ingredient_shapes_parse() {
|
||||
let mut tags = HashMap::new();
|
||||
tags.insert(
|
||||
"minecraft:planks".to_owned(),
|
||||
["minecraft:oak_planks", "minecraft:birch_planks"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<HashSet<_>>(),
|
||||
);
|
||||
let items = Items::for_tests(tags);
|
||||
let json = |s: &str| serde_json::from_str::<Value>(s).unwrap();
|
||||
// Compound (`CompoundIngredient`): any child matching is enough —
|
||||
// the union of their items, a tag beside an item gives both.
|
||||
let union = Ingredient::parse(
|
||||
&json(r##"{"neoforge:ingredient_type":"neoforge:compound","children":["#minecraft:planks","minecraft:stone"]}"##),
|
||||
&items,
|
||||
)
|
||||
.unwrap();
|
||||
// The tag's items come from a HashSet: their order is not fixed.
|
||||
let mut all = union.1.clone();
|
||||
all.sort();
|
||||
assert_eq!(
|
||||
all,
|
||||
[
|
||||
"minecraft:birch_planks",
|
||||
"minecraft:oak_planks",
|
||||
"minecraft:stone"
|
||||
]
|
||||
);
|
||||
// Intersection: every child must match — only the items all name.
|
||||
let both = Ingredient::parse(
|
||||
&json(r##"{"type":"neoforge:intersection","children":["#minecraft:planks","minecraft:oak_planks"]}"##),
|
||||
&items,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(both.1, ["minecraft:oak_planks"]);
|
||||
// Difference: the base without the subtracted.
|
||||
let no_oak = Ingredient::parse(
|
||||
&json(r##"{"type":"neoforge:difference","base":"#minecraft:planks","subtracted":"minecraft:oak_planks"}"##),
|
||||
&items,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(no_oak.1, ["minecraft:birch_planks"]);
|
||||
// An empty intersection and an unknown kind fail the file, as today.
|
||||
let empty = Ingredient::parse(
|
||||
&json(
|
||||
r#"{"type":"neoforge:intersection","children":["minecraft:oak_planks","minecraft:birch_planks"]}"#,
|
||||
),
|
||||
&items,
|
||||
);
|
||||
assert!(empty.is_none());
|
||||
let unknown =
|
||||
Ingredient::parse(&json(r#"{"type":"mymod:aspect","aspect":"fire"}"#), &items);
|
||||
assert!(unknown.is_none());
|
||||
}
|
||||
|
||||
fn ingredient(items: &[&str]) -> Ingredient {
|
||||
Ingredient::of(items.iter().map(|s| (*s).to_owned()).collect())
|
||||
}
|
||||
@@ -1928,6 +2103,66 @@ mod tests {
|
||||
assert_eq!((&*made.item, made.count), ("minecraft:stick", 1));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A recipe whose `neoforge:conditions` hold loads, one whose don't is
|
||||
/// silently absent, from the root's own files and a pack's alike.
|
||||
#[test]
|
||||
fn load_conditions_gate_recipes() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-conditions-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
fs::write(
|
||||
root.join(horizon_items::content_namespaces::MARKER),
|
||||
br#"["mymod"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join(horizon_items::content_namespaces::MOD_IDS_MARKER),
|
||||
br#"["mymod"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let file = |conditions: &str, tag: &str| {
|
||||
format!(
|
||||
r#"{{"type":"mymod:crusher","tag":"{tag}","neoforge:conditions":{conditions}}}"#
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
// `mymod` is in the root, `ic2` is not.
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/yes.json"),
|
||||
file(r#"[{"type":"neoforge:mod_loaded","modid":"mymod"}]"#, "yes"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/no.json"),
|
||||
file(r#"[{"type":"neoforge:mod_loaded","modid":"ic2"}]"#, "no"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/inverted.json"),
|
||||
file(
|
||||
r#"[{"type":"neoforge:not","value":{"type":"neoforge:mod_loaded","modid":"ic2"}}]"#,
|
||||
"inverted",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
let ids = &recipes.custom["mymod:crusher"];
|
||||
assert_eq!(
|
||||
ids.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
|
||||
["minecraft:inverted", "minecraft:yes"]
|
||||
);
|
||||
// A pack's gated file answers the same gate.
|
||||
let packs = [(
|
||||
"recipe/minecraft:pack_yes".to_owned(),
|
||||
vec![file(r#"[{"type":"neoforge:true"}]"#, "pack_yes")],
|
||||
)];
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay { files: &packs }).unwrap();
|
||||
let ids = &recipes.custom["mymod:crusher"];
|
||||
assert!(ids.iter().any(|r| r.id == "minecraft:pack_yes"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
#[test]
|
||||
fn cook_finds_the_first_recipe_the_scan_finds() {
|
||||
let recipes = cooking_book();
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! `DebugLevelSource`: the `minecraft:debug` generator of the Debug world preset,
|
||||
//! a grid of every block state floating over a layer of barriers.
|
||||
use crate::{
|
||||
chunk::{Chunk, LevelShape},
|
||||
streaming::ChunkGenerator,
|
||||
};
|
||||
use horizon_world::{nbt::Tag, registry::BlockRegistry};
|
||||
|
||||
/// `DebugLevelSource.HEIGHT`: the layer the block states stand in.
|
||||
pub const HEIGHT: i32 = 70;
|
||||
/// `DebugLevelSource.BARRIER_HEIGHT`.
|
||||
pub const BARRIER_HEIGHT: i32 = 60;
|
||||
|
||||
/// The debug world's generator: every block state in registry order
|
||||
/// (`ALL_BLOCKS`) on a square grid, one every other block, at `HEIGHT`.
|
||||
pub struct DebugTerrain {
|
||||
shape: LevelShape,
|
||||
biome: u16,
|
||||
barrier: u32,
|
||||
/// `ALL_BLOCKS.size()`: block states are numbered in that order, so the
|
||||
/// state at an index is the state with that id.
|
||||
states: u32,
|
||||
}
|
||||
impl DebugTerrain {
|
||||
/// The generator of a world whose overworld is `minecraft:debug`.
|
||||
pub fn from_settings(
|
||||
settings: &Tag,
|
||||
registry: &BlockRegistry,
|
||||
biomes: &crate::biome::Biomes,
|
||||
shape: LevelShape,
|
||||
) -> Option<Self> {
|
||||
let generator = settings
|
||||
.get("dimensions")?
|
||||
.get("minecraft:overworld")?
|
||||
.get("generator")?;
|
||||
if crate::save::text(generator.get("type"))?.trim_start_matches("minecraft:") != "debug" {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
shape,
|
||||
// `FixedBiomeSource(plains)`.
|
||||
biome: biomes.id("minecraft:plains"),
|
||||
barrier: registry.default_state("minecraft:barrier")?.0,
|
||||
states: u32::try_from(registry.len()).ok()?,
|
||||
})
|
||||
}
|
||||
/// `DebugLevelSource.getBlockStateFor`: the state id at a block column
|
||||
/// (air, id 0, where the grid has none).
|
||||
pub fn state_for(&self, x: i32, z: i32) -> u32 {
|
||||
grid_state(self.states, x, z)
|
||||
}
|
||||
}
|
||||
/// `DebugLevelSource.GRID_WIDTH` and `GRID_HEIGHT` for `count` block states:
|
||||
/// `Mth.ceil(Mth.sqrt(size))` and `ceil(size / width)`.
|
||||
fn grid(count: u32) -> (i32, i32) {
|
||||
let width = (count as f32).sqrt().ceil() as i32;
|
||||
let height = (count as f32 / width as f32).ceil() as i32;
|
||||
(width, height)
|
||||
}
|
||||
/// `getBlockStateFor(worldX, worldZ)` as a state id.
|
||||
fn grid_state(count: u32, mut x: i32, mut z: i32) -> u32 {
|
||||
let (width, height) = grid(count);
|
||||
if x > 0 && z > 0 && x % 2 != 0 && z % 2 != 0 {
|
||||
x /= 2;
|
||||
z /= 2;
|
||||
if x <= width && z <= height {
|
||||
let index = (x * width + z).unsigned_abs();
|
||||
if index < count {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
impl ChunkGenerator for DebugTerrain {
|
||||
type Worker = ();
|
||||
fn worker(&self) {}
|
||||
/// `DebugLevelSource.buildTerrain` builds nothing; `applyBiomeDecoration`
|
||||
/// writes the barriers and the grid.
|
||||
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
|
||||
let mut chunk = Chunk::with_layout(cx, cz, self.shape.column());
|
||||
for s in &mut chunk.sections {
|
||||
std::sync::Arc::make_mut(s).biomes = horizon_core::paletted::PalettedContainer::new(
|
||||
crate::chunk::BIOME_STRATEGY,
|
||||
u32::from(self.biome),
|
||||
);
|
||||
}
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
chunk.set(x, BARRIER_HEIGHT, z, self.barrier);
|
||||
let state = self.state_for(cx * 16 + x, cz * 16 + z);
|
||||
if state != 0 {
|
||||
chunk.set(x, HEIGHT, z, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
for s in &mut chunk.sections {
|
||||
std::sync::Arc::make_mut(s).compact();
|
||||
}
|
||||
Box::new(chunk)
|
||||
}
|
||||
/// `spawnOriginalMobs` is empty.
|
||||
fn finish(&self, proto: crate::streaming::Proto) -> Chunk {
|
||||
*proto.downcast::<Chunk>().expect("debug terrain proto")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `getBlockStateFor`: only odd, positive columns hold a state, in the order
|
||||
/// `x / 2 * width + z / 2`, and nothing past the last state.
|
||||
#[test]
|
||||
fn the_grid_holds_a_state_every_other_block() {
|
||||
// 1000 states: a 32 wide grid, 32 high (ceil(1000 / 32) = 32).
|
||||
assert_eq!(grid(1000), (32, 32));
|
||||
assert_eq!(grid(1), (1, 1));
|
||||
assert_eq!(grid(26_000), (162, 161));
|
||||
// Even, zero and negative columns are air.
|
||||
for (x, z) in [(2, 1), (1, 2), (0, 1), (1, 0), (-1, 1), (1, -1)] {
|
||||
assert_eq!(grid_state(1000, x, z), 0, "{x} {z}");
|
||||
}
|
||||
// (1, 1) is index 0 (air), (3, 5) is 1 * 32 + 2 = 34.
|
||||
assert_eq!(grid_state(1000, 1, 1), 0);
|
||||
assert_eq!(grid_state(1000, 3, 5), 34);
|
||||
// x = 2 * 32 + 1 maps to x / 2 = 32: index 32 * 32 + z / 2, past the 1000 states.
|
||||
assert_eq!(grid_state(1000, 65, 1), 0);
|
||||
assert_eq!(grid_state(1000, 61, 3), 30 * 32 + 1);
|
||||
}
|
||||
}
|
||||
@@ -182,47 +182,14 @@ impl ItemEntity {
|
||||
/// up) without a full collision cube, at `speed` (`nextFloat() * 0.2 +
|
||||
/// 0.1`).
|
||||
pub fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
|
||||
let bounds = self.bounds();
|
||||
let at = Vec3::new(
|
||||
self.position.x,
|
||||
(bounds.min_position().y + bounds.max_position().y) / 2.0,
|
||||
self.position.z,
|
||||
self.velocity = towards_closest_space(
|
||||
self.position,
|
||||
self.bounds(),
|
||||
self.velocity,
|
||||
world,
|
||||
facts,
|
||||
speed,
|
||||
);
|
||||
let pos = [
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
];
|
||||
let delta = [
|
||||
at.x - f64::from(pos[0]),
|
||||
at.y - f64::from(pos[1]),
|
||||
at.z - f64::from(pos[2]),
|
||||
];
|
||||
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
|
||||
let mut closest = (1, 1);
|
||||
let mut nearest = f64::MAX;
|
||||
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
|
||||
let mut n = pos;
|
||||
n[axis] += step;
|
||||
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
|
||||
let d = if step > 0 {
|
||||
1.0 - delta[axis]
|
||||
} else {
|
||||
delta[axis]
|
||||
};
|
||||
if d < nearest {
|
||||
nearest = d;
|
||||
closest = (axis, step);
|
||||
}
|
||||
}
|
||||
}
|
||||
let push = f64::from(closest.1 as f32 * speed);
|
||||
let v = self.velocity.scale(0.75);
|
||||
self.velocity = match closest.0 {
|
||||
0 => Vec3::new(push, v.y, v.z),
|
||||
1 => Vec3::new(v.x, push, v.z),
|
||||
_ => Vec3::new(v.x, v.y, push),
|
||||
};
|
||||
}
|
||||
/// Returns false when the item should be removed. `no_physics`: the item
|
||||
/// moves through blocks this tick ([`ItemEntity::inside_block`]).
|
||||
@@ -339,6 +306,37 @@ impl ItemEntity {
|
||||
&mut self.velocity,
|
||||
);
|
||||
}
|
||||
if !no_physics {
|
||||
// Entity.applyEffectsFromBlocks: stepOn, then checkInsideBlocks.
|
||||
if self.on_ground {
|
||||
let p = BlockPos::containing(self.position.add_vector(Vec3::new(
|
||||
0.0,
|
||||
-f64::from(0.2_f32),
|
||||
0.0,
|
||||
)));
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
crate::block_contact::emit(crate::block_contact::Event::StepOn(
|
||||
crate::block_contact::Contact {
|
||||
entity: self.id as i32,
|
||||
kind: "minecraft:item",
|
||||
block: &facts.get(state).name,
|
||||
state,
|
||||
position: p,
|
||||
},
|
||||
));
|
||||
}
|
||||
crate::block_contact::swept_inside(
|
||||
self.id as i32,
|
||||
"minecraft:item",
|
||||
self.previous,
|
||||
self.position,
|
||||
self.bounds(),
|
||||
|p| {
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
(state, &facts.get(state).name)
|
||||
},
|
||||
);
|
||||
}
|
||||
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
|
||||
self.needs_sync = true;
|
||||
}
|
||||
@@ -638,6 +636,7 @@ impl ExperienceOrb {
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
player: Option<[f64; 3]>,
|
||||
server: bool,
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
@@ -647,13 +646,22 @@ impl ExperienceOrb {
|
||||
self.position.y.floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
let bounds = self.bounds();
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
// `boolean colliding = !level.noCollision(getBoundingBox())`: an orb
|
||||
// inside blocks takes no gravity until it is pushed out below.
|
||||
let colliding = !crate::collisions::no_collision_empty(&mut scene, bounds);
|
||||
if facts.get(block).fluid == Fluid::Water {
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.99,
|
||||
(self.velocity.y + 5.0e-4).min(0.06),
|
||||
self.velocity.z * 0.99,
|
||||
);
|
||||
} else {
|
||||
} else if !colliding {
|
||||
self.velocity.y -= 0.03;
|
||||
}
|
||||
if facts.get(block).fluid == Fluid::Lava {
|
||||
@@ -681,12 +689,18 @@ impl ExperienceOrb {
|
||||
);
|
||||
}
|
||||
}
|
||||
let bounds = self.bounds();
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
if player.is_none() && server && colliding {
|
||||
// `followingPlayer == null && !level().isClientSide() && colliding`: an orb that would still
|
||||
// be stuck at its next position gets
|
||||
// `Entity.moveTowardsClosestSpace`, a nudge towards the nearest
|
||||
// free side of its block.
|
||||
if !crate::collisions::no_collision_empty(&mut scene, bounds.move_by(self.velocity)) {
|
||||
let speed = next_float() * 0.2 + 0.1;
|
||||
self.move_towards_closest_space(world, facts, speed);
|
||||
}
|
||||
}
|
||||
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
|
||||
// push and before the move.
|
||||
let wanted = self.velocity;
|
||||
let moved = scene
|
||||
.resolve(bounds, wanted, 0.0, self.on_ground)
|
||||
@@ -697,26 +711,114 @@ impl ExperienceOrb {
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
let mut friction = 0.98;
|
||||
// Entity.move's restituteMovementAfterCollisions, an orb's bounciness
|
||||
// 0: each collided axis's velocity is zeroed — x and z by Mth.equal's
|
||||
// 1.0E-5, y exactly.
|
||||
if (wanted.x - moved.x).abs() >= 1.0e-5 {
|
||||
self.velocity.x = 0.0;
|
||||
}
|
||||
if (wanted.z - moved.z).abs() >= 1.0e-5 {
|
||||
self.velocity.z = 0.0;
|
||||
}
|
||||
if moved.y != wanted.y {
|
||||
self.velocity.y = 0.0;
|
||||
}
|
||||
// `float friction = getAirDrag()` (0.98F), the ground's block
|
||||
// friction multiplied in as a float, widened once for the scale.
|
||||
let mut friction = 0.98_f32;
|
||||
if self.on_ground {
|
||||
let below = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y - 1.0).floor() as i32,
|
||||
(self.position.y - 0.999_999).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
friction = f64::from(facts.get(below).friction) * 0.98;
|
||||
friction *= facts.get(below).friction;
|
||||
}
|
||||
let friction = f64::from(friction);
|
||||
// The drag scales every axis by the same friction, the ground's
|
||||
// block friction included.
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * friction,
|
||||
self.velocity.y * 0.98,
|
||||
self.velocity.y * friction,
|
||||
self.velocity.z * friction,
|
||||
);
|
||||
if self.on_ground {
|
||||
self.velocity.y *= -0.9;
|
||||
// `if (verticalCollisionBelow && fallSpeed < -getGravity())`: the
|
||||
// landing bounce is 0.4 of the pre-move speed, not a fixed
|
||||
// restitution.
|
||||
if self.on_ground && wanted.y < -0.03 {
|
||||
self.velocity.y = -wanted.y * 0.4;
|
||||
}
|
||||
self.age += 1;
|
||||
self.age < 6000
|
||||
}
|
||||
/// `Entity.moveTowardsClosestSpace` from the middle of the orb's box:
|
||||
/// towards the nearest side of its block (north, south, west, east or
|
||||
/// up) without a full collision cube, at `speed` (the orb's own
|
||||
/// random, `nextFloat() * 0.2 + 0.1`).
|
||||
fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
|
||||
self.velocity = towards_closest_space(
|
||||
self.position,
|
||||
self.bounds(),
|
||||
self.velocity,
|
||||
world,
|
||||
facts,
|
||||
speed,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `Entity.moveTowardsClosestSpace` from the middle of an entity's box
|
||||
/// (`bounds`, its feet at `position`): the velocity pushed towards the
|
||||
/// nearest side of its block (north, south, west, east or up) without a full
|
||||
/// collision cube, at `speed` (`nextFloat() * 0.2 + 0.1`). Items and
|
||||
/// experience orbs share it.
|
||||
fn towards_closest_space(
|
||||
position: Vec3,
|
||||
bounds: Aabb,
|
||||
velocity: Vec3,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
speed: f32,
|
||||
) -> Vec3 {
|
||||
let at = Vec3::new(
|
||||
position.x,
|
||||
(bounds.min_position().y + bounds.max_position().y) / 2.0,
|
||||
position.z,
|
||||
);
|
||||
let pos = [
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
];
|
||||
let delta = [
|
||||
at.x - f64::from(pos[0]),
|
||||
at.y - f64::from(pos[1]),
|
||||
at.z - f64::from(pos[2]),
|
||||
];
|
||||
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
|
||||
let mut closest = (1, 1);
|
||||
let mut nearest = f64::MAX;
|
||||
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
|
||||
let mut n = pos;
|
||||
n[axis] += step;
|
||||
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
|
||||
let d = if step > 0 {
|
||||
1.0 - delta[axis]
|
||||
} else {
|
||||
delta[axis]
|
||||
};
|
||||
if d < nearest {
|
||||
nearest = d;
|
||||
closest = (axis, step);
|
||||
}
|
||||
}
|
||||
}
|
||||
let push = f64::from(closest.1 as f32 * speed);
|
||||
let v = velocity.scale(0.75);
|
||||
match closest.0 {
|
||||
0 => Vec3::new(push, v.y, v.z),
|
||||
1 => Vec3::new(v.x, push, v.z),
|
||||
_ => Vec3::new(v.x, v.y, push),
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
|
||||
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
|
||||
@@ -947,9 +1049,15 @@ pub struct ArmorStand {
|
||||
pub health: f32,
|
||||
pub id: u32,
|
||||
pub position: Vec3,
|
||||
/// `Entity.xo`, `yo`, `zo`: the position at the start of the tick, which
|
||||
/// the renderer blends toward `position` by the partial tick.
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
pub on_ground: bool,
|
||||
/// `yRot`, which the body follows (`ArmorStand.tickHeadTurn`: `yBodyRot`).
|
||||
pub yaw: f32,
|
||||
/// `yBodyRotO`: the body's yaw at the start of the tick.
|
||||
pub previous_yaw: f32,
|
||||
/// mainhand, offhand, feet, legs, chest, head (EquipmentSlot order).
|
||||
pub equipment: [Option<crate::items::ItemStack>; 6],
|
||||
pub small: bool,
|
||||
@@ -970,6 +1078,9 @@ pub struct ArmorStand {
|
||||
/// rides, which seats it every tick (`positionRider`), as NPC plugins hang
|
||||
/// a second name over a player's head.
|
||||
pub vehicle: Option<u32>,
|
||||
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
|
||||
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
|
||||
pub disabled_slots: i32,
|
||||
}
|
||||
impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
@@ -985,15 +1096,50 @@ impl ArmorStand {
|
||||
[-1.0, 0.0, -1.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
];
|
||||
/// Where the renderer draws the stand `alpha` of the way through the tick
|
||||
/// (`EntityRenderer`'s `Mth.lerp(partialTick, xo, x)` and so on).
|
||||
pub fn render_position(&self, alpha: f64) -> Vec3 {
|
||||
self.previous.lerp(self.position, alpha)
|
||||
}
|
||||
/// The body's yaw `alpha` of the way through the tick
|
||||
/// (`LivingEntityRenderer.solveBodyRot`: `Mth.rotLerp(partialTick,
|
||||
/// yBodyRotO, yBodyRot)`).
|
||||
pub fn render_yaw(&self, alpha: f32) -> f32 {
|
||||
self.previous_yaw + alpha * crate::camera::wrap_degrees(self.yaw - self.previous_yaw)
|
||||
}
|
||||
/// A server's stand on its client, each tick:
|
||||
/// `setOldPosAndRot` (`ClientLevel.tickNonPassenger`), then the step of
|
||||
/// its `SteppedInterpolationHandler` (`step`: the position and yaw it is
|
||||
/// put at, when one is due). The yaw
|
||||
/// turns the short way and stays continuous, so a frame between ticks
|
||||
/// never spins it.
|
||||
pub fn interpolate(&mut self, step: Option<(Vec3, f32)>) {
|
||||
self.previous = self.position;
|
||||
self.previous_yaw = self.yaw;
|
||||
if let Some((at, yaw)) = step {
|
||||
self.position = at;
|
||||
self.yaw += crate::camera::wrap_degrees(yaw - self.yaw);
|
||||
}
|
||||
}
|
||||
/// Put there at once, with nothing to blend from
|
||||
/// (`Entity.snapTo`).
|
||||
pub fn snap(&mut self, at: Vec3, yaw: f32) {
|
||||
self.position = at;
|
||||
self.previous = at;
|
||||
self.yaw = yaw;
|
||||
self.previous_yaw = yaw;
|
||||
}
|
||||
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
fire_ticks: 0,
|
||||
health: 20.0,
|
||||
id,
|
||||
position,
|
||||
previous: position,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: true,
|
||||
yaw,
|
||||
previous_yaw: yaw,
|
||||
equipment: Default::default(),
|
||||
small: false,
|
||||
show_arms: false,
|
||||
@@ -1005,8 +1151,29 @@ impl ArmorStand {
|
||||
last_hit: i64::MIN / 2,
|
||||
pose: Self::DEFAULT_POSE,
|
||||
vehicle: None,
|
||||
disabled_slots: 0,
|
||||
}
|
||||
}
|
||||
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
|
||||
/// hand, feet, legs, chest, head).
|
||||
fn filter_flag(slot: usize) -> u32 {
|
||||
[0, 5, 1, 2, 3, 4][slot]
|
||||
}
|
||||
/// `ArmorStand.isDisabled`: the slot is locked, or it is a hand and the
|
||||
/// stand has no arms.
|
||||
pub fn is_disabled(&self, slot: usize) -> bool {
|
||||
self.disabled_slots & (1 << Self::filter_flag(slot)) != 0 || (slot < 2 && !self.show_arms)
|
||||
}
|
||||
/// The `swapItem` locks: taking from a filled slot or putting into an
|
||||
/// empty one can each be disabled.
|
||||
pub fn swap_locked(&self, slot: usize) -> bool {
|
||||
let offset = if self.equipment[slot].is_some() {
|
||||
8
|
||||
} else {
|
||||
16
|
||||
};
|
||||
self.disabled_slots & (1 << (Self::filter_flag(slot) + offset)) != 0
|
||||
}
|
||||
pub fn environment_tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
@@ -1296,6 +1463,53 @@ mod fire_environment_tests {
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod armor_stand_render_tests {
|
||||
use super::*;
|
||||
|
||||
/// A falling stand is drawn between last tick's position and this one's,
|
||||
/// so it slides down every frame instead of jumping once a tick (#4416).
|
||||
#[test]
|
||||
fn a_falling_stand_is_drawn_between_its_ticks() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
|
||||
// A new stand has no move behind it.
|
||||
assert_eq!(stand.render_position(0.5), stand.position);
|
||||
let fall = |s: &ArmorStand| Some((Vec3::new(0.5, s.position.y - 1.0, 0.5), 0.0));
|
||||
stand.interpolate(fall(&stand));
|
||||
assert_eq!(stand.render_position(0.0).y, 70.0);
|
||||
assert_eq!(stand.render_position(0.25).y, 69.75);
|
||||
assert_eq!(stand.render_position(1.0).y, 69.0);
|
||||
stand.interpolate(fall(&stand));
|
||||
assert_eq!(stand.render_position(0.5).y, 68.5);
|
||||
// Landed: no step is due, so it stays put, and so does every frame.
|
||||
stand.interpolate(None);
|
||||
assert_eq!(stand.render_position(0.5).y, 68.0);
|
||||
}
|
||||
|
||||
/// A turning stand's body turns between ticks too, the short way round.
|
||||
#[test]
|
||||
fn a_turning_stand_is_drawn_between_its_ticks() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::ZERO, 170.0);
|
||||
assert_eq!(stand.render_yaw(0.5), 170.0);
|
||||
// To -170 is 20 degrees on through 180, not 340 back through 0.
|
||||
stand.interpolate(Some((Vec3::ZERO, -170.0)));
|
||||
assert_eq!(stand.render_yaw(0.0), 170.0);
|
||||
assert_eq!(stand.render_yaw(0.5), 180.0);
|
||||
assert_eq!(stand.render_yaw(1.0), 190.0);
|
||||
stand.interpolate(None);
|
||||
assert_eq!(stand.render_yaw(0.5), 190.0);
|
||||
}
|
||||
|
||||
/// A snap (a move too far to interpolate) leaves nothing to blend from.
|
||||
#[test]
|
||||
fn a_snapped_stand_is_drawn_where_it_landed() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
|
||||
stand.interpolate(Some((Vec3::new(0.5, 69.0, 0.5), 0.0)));
|
||||
stand.snap(Vec3::new(200.5, 64.0, 0.5), 90.0);
|
||||
assert_eq!(stand.render_position(0.5), Vec3::new(200.5, 64.0, 0.5));
|
||||
assert_eq!(stand.render_yaw(0.5), 90.0);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod merge_tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -511,6 +511,10 @@ pub fn mob_to_nbt(
|
||||
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
|
||||
if is_animal(id) {
|
||||
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
|
||||
// Animal.addAdditionalSaveData: the player who fed it.
|
||||
if let Some(cause) = m.extra.love_cause {
|
||||
map.insert("LoveCause".into(), Tag::IntArray(cause.to_vec()));
|
||||
}
|
||||
}
|
||||
let species = m.species();
|
||||
// MushroomCow saves its variant under `Type` (below), not `variant`.
|
||||
@@ -538,7 +542,15 @@ pub fn mob_to_nbt(
|
||||
"minecraft:fox" => {
|
||||
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
map.entry("Trusted".into()).or_insert(Tag::List(Vec::new()));
|
||||
// The trusted UUIDs (`Fox.TRUSTED_LIST_CODEC`: a list of int arrays).
|
||||
map.insert(
|
||||
"Trusted".into(),
|
||||
Tag::List(
|
||||
crate::mobs::fox::trusted_entities(m)
|
||||
.map(|uuid| Tag::IntArray(uuid.to_vec()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
for (key, bit) in [
|
||||
("Sleeping", crate::mobs::fox::SLEEPING),
|
||||
("Sitting", crate::mobs::fox::SITTING),
|
||||
@@ -575,7 +587,10 @@ pub fn mob_to_nbt(
|
||||
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
|
||||
map.insert(
|
||||
"MoreCarrotTicks".into(),
|
||||
Tag::Int(m.extra.more_carrot_ticks),
|
||||
);
|
||||
}
|
||||
"minecraft:goat" => {
|
||||
map.insert(
|
||||
@@ -1141,6 +1156,25 @@ pub fn mob_to_nbt(
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
// A worn piece or held item that kept its damage, name or
|
||||
// components is saved with them.
|
||||
if slot != "mainhand"
|
||||
&& item.is_some()
|
||||
&& let Some(stack) = crate::mobs::finalize::stored_stack(
|
||||
m,
|
||||
[
|
||||
crate::mobs::finalize::MAINHAND,
|
||||
crate::mobs::finalize::OFFHAND,
|
||||
crate::mobs::finalize::HEAD,
|
||||
crate::mobs::finalize::CHEST,
|
||||
crate::mobs::finalize::LEGS,
|
||||
crate::mobs::finalize::FEET,
|
||||
][index],
|
||||
)
|
||||
{
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
// Enchanted or trimmed gear is saved with its components.
|
||||
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
|
||||
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
|
||||
@@ -1405,6 +1439,10 @@ pub fn mob_from_nbt(
|
||||
crate::mobs::gear::set(&mut m, index, stack.enchantments().to_vec());
|
||||
let trim = stack.data().and_then(|d| d.trim.clone());
|
||||
crate::mobs::gear::set_trim(&mut m, index, trim);
|
||||
// The damage, name and components stay with the slot.
|
||||
if index != crate::mobs::gear::MAIN {
|
||||
crate::mobs::gear::set_stack(&mut m, index, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1463,6 +1501,8 @@ pub fn mob_from_nbt(
|
||||
}
|
||||
if is_animal(&m.kind.id) {
|
||||
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
|
||||
// Animal.readAdditionalSaveData: the player who fed it into love.
|
||||
m.extra.love_cause = crate::save::uuid_from_tag(tag.get("LoveCause"));
|
||||
}
|
||||
m.name_visible = flag(tag, "CustomNameVisible");
|
||||
m.no_ai = flag(tag, "NoAI");
|
||||
@@ -1592,6 +1632,17 @@ pub fn mob_from_nbt(
|
||||
match m.species() {
|
||||
crate::mobs::Species::Fox => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
|
||||
// Fox.readAdditionalSaveData: clearTrusted, then addTrustedEntity for
|
||||
// each of "Trusted".
|
||||
crate::mobs::fox::clear_trusted(&mut m);
|
||||
if let Some(Tag::List(trusted)) = tag.get("Trusted") {
|
||||
for uuid in trusted
|
||||
.iter()
|
||||
.filter_map(|t| crate::save::uuid_from_tag(Some(t)))
|
||||
{
|
||||
crate::mobs::fox::add_trusted_entity(&mut m, uuid);
|
||||
}
|
||||
}
|
||||
// Fox.readAdditionalSaveData: the sleeping, sitting and crouching flags.
|
||||
for (key, bit) in [
|
||||
("Sleeping", crate::mobs::fox::SLEEPING),
|
||||
@@ -1633,6 +1684,10 @@ pub fn mob_from_nbt(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// `Rabbit.readAdditionalSaveData`.
|
||||
if m.species() == crate::mobs::Species::Rabbit {
|
||||
m.extra.more_carrot_ticks = number(tag.get("MoreCarrotTicks")).unwrap_or(0) as i32;
|
||||
}
|
||||
// `Ocelot.readAdditionalSaveData`.
|
||||
if m.species() == crate::mobs::Species::Ocelot {
|
||||
m.extra.trusting = flag(tag, "Trusting");
|
||||
@@ -2155,6 +2210,7 @@ pub fn arrow_from_nbt(
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.owner_uuid = owner;
|
||||
let a = p.arrow.as_mut()?;
|
||||
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
|
||||
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
|
||||
@@ -2198,26 +2254,121 @@ pub fn fireball_to_nbt(
|
||||
"acceleration_power",
|
||||
Tag::Double(p.acceleration.to_bits()),
|
||||
);
|
||||
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
|
||||
// (a fireball saved before its first tick keeps what it was loaded with).
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"LeftOwner".into());
|
||||
}
|
||||
if p.left_owner {
|
||||
put(&mut tag, "LeftOwner", Tag::Byte(1));
|
||||
}
|
||||
if p.age > 0 || tag.get("HasBeenShot").is_none() {
|
||||
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
|
||||
}
|
||||
put_projectile(&mut tag, p, owner);
|
||||
if let Some(power) = power {
|
||||
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
/// `Projectile.addAdditionalSaveData`: Owner, LeftOwner only when set, and
|
||||
/// HasBeenShot once it has ticked (one saved before its first tick keeps
|
||||
/// what it was loaded with).
|
||||
fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
|
||||
if let Tag::Compound(map) = tag {
|
||||
map.remove(&"LeftOwner".into());
|
||||
}
|
||||
if p.left_owner {
|
||||
put(tag, "LeftOwner", Tag::Byte(1));
|
||||
}
|
||||
if p.age > 0 || tag.get("HasBeenShot").is_none() {
|
||||
put(tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
|
||||
}
|
||||
if let Some(owner) = owner {
|
||||
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
|
||||
put(tag, "Owner", Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The thrown item projectiles Horizon saves: each entity type's id is also
|
||||
/// the item it saves when none was loaded (`getDefaultItem`).
|
||||
const THROWABLES: [(ProjectileKind, &str); 3] = [
|
||||
(ProjectileKind::Snowball, "minecraft:snowball"),
|
||||
(ProjectileKind::Egg, "minecraft:egg"),
|
||||
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
|
||||
];
|
||||
fn throwable_item(kind: &ProjectileKind) -> Option<&'static str> {
|
||||
if let ProjectileKind::Thrown(t) = kind {
|
||||
return Some(t.item);
|
||||
}
|
||||
THROWABLES
|
||||
.iter()
|
||||
.find(|(k, _)| k == kind)
|
||||
.map(|&(_, id)| id)
|
||||
}
|
||||
|
||||
/// The thrown kind an entity id names: vanilla's, or a content mod's type.
|
||||
pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
|
||||
match THROWABLES.iter().find(|(_, i)| *i == id) {
|
||||
Some((kind, _)) => Some(kind.clone()),
|
||||
None => crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether its chunk saves it: the kinds `arrow_to_nbt`, `fireball_to_nbt`
|
||||
/// and [`throwable_to_nbt`] write.
|
||||
pub fn saved_with_chunk(p: &Projectile) -> bool {
|
||||
matches!(
|
||||
p.kind,
|
||||
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
|
||||
) || (p.arrow.is_some() && matches!(p.kind, ProjectileKind::Arrow { .. }))
|
||||
|| throwable_item(&p.kind).is_some()
|
||||
}
|
||||
|
||||
/// `ThrowableItemProjectile.addAdditionalSaveData`: `Projectile`'s keys and
|
||||
/// the `Item` it flies as.
|
||||
pub fn throwable_to_nbt(
|
||||
p: &Projectile,
|
||||
uuid: [i32; 4],
|
||||
owner: Option<[i32; 4]>,
|
||||
raw: Option<&Tag>,
|
||||
) -> Option<Tag> {
|
||||
let item = throwable_item(&p.kind)?;
|
||||
let base = Base {
|
||||
id: p.kind.id(),
|
||||
uuid,
|
||||
position: p.position,
|
||||
motion: p.velocity,
|
||||
rotation: [p.yaw, p.pitch],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put_projectile(&mut tag, p, owner);
|
||||
// Entity.saveWithoutId writes NoGravity only when set.
|
||||
if p.no_gravity {
|
||||
put(&mut tag, "NoGravity", Tag::Byte(1));
|
||||
}
|
||||
// the loaded item stays (a blue egg's, a renamed one's).
|
||||
if tag.get("Item").is_none() {
|
||||
put(
|
||||
&mut tag,
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new(item, 1)),
|
||||
);
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
/// `ThrowableItemProjectile.readAdditionalSaveData` for the kinds
|
||||
/// [`throwable_to_nbt`] writes; the owner is the player when it is theirs.
|
||||
pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = throwable_kind(&kind(tag)?)?;
|
||||
let owner = owner_uuid(tag);
|
||||
let from_player = player.is_some() && owner == player;
|
||||
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.owner_uuid = owner;
|
||||
p.no_gravity = flag(tag, "NoGravity");
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// `Projectile`'s `Owner`.
|
||||
pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
|
||||
uuid_from_tag(tag.get("Owner"))
|
||||
}
|
||||
|
||||
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = match kind(tag)?.as_str() {
|
||||
"minecraft:fireball" => ProjectileKind::LargeFireball {
|
||||
@@ -2234,6 +2385,7 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.owner_uuid = owner;
|
||||
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
|
||||
Some(p)
|
||||
}
|
||||
@@ -2498,6 +2650,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
} else {
|
||||
put(&mut tag, "equipment", Tag::compound(equipment));
|
||||
}
|
||||
put(&mut tag, "DisabledSlots", Tag::Int(a.disabled_slots));
|
||||
for (key, value) in [
|
||||
("Small", a.small),
|
||||
("ShowArms", a.show_arms),
|
||||
@@ -2572,6 +2725,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
}
|
||||
a.small = flag(tag, "Small");
|
||||
a.show_arms = flag(tag, "ShowArms");
|
||||
a.disabled_slots = number(tag.get("DisabledSlots")).unwrap_or(0) as i32;
|
||||
a.no_base_plate = flag(tag, "NoBasePlate");
|
||||
a.invisible = flag(tag, "Invisible");
|
||||
a.marker = flag(tag, "Marker");
|
||||
@@ -3519,6 +3673,34 @@ mod sheep_nbt_tests {
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
|
||||
/// `Rabbit.addAdditionalSaveData` and `readAdditionalSaveData`: `MoreCarrotTicks`.
|
||||
#[test]
|
||||
fn a_rabbit_keeps_how_full_of_carrots_it_is() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(data.types["minecraft:rabbit"].clone()),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
m.extra.more_carrot_ticks = 33;
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(number(tag.get("MoreCarrotTicks")), Some(33));
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.more_carrot_ticks, 33);
|
||||
}
|
||||
|
||||
/// `Ocelot.addAdditionalSaveData` and `readAdditionalSaveData`: `Trusting`.
|
||||
#[test]
|
||||
fn an_ocelot_keeps_its_trust() {
|
||||
@@ -4084,6 +4266,85 @@ mod fireball_nbt_tests {
|
||||
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
|
||||
let player = Some([5, 6, 7, 8]);
|
||||
for (kind, id) in [
|
||||
(ProjectileKind::Snowball, "minecraft:snowball"),
|
||||
(ProjectileKind::Egg, "minecraft:egg"),
|
||||
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
|
||||
] {
|
||||
let mut p =
|
||||
Projectile::new(kind.clone(), Vec3::new(1.5, 70.0, -2.5), Some(Who::Player));
|
||||
(p.velocity, p.left_owner, p.age) = (Vec3::new(0.3, -0.1, 1.2), true, 4);
|
||||
[p.yaw, p.pitch] = [30.0, -10.0];
|
||||
let tag = throwable_to_nbt(&p, [1, 2, 3, 4], player, None).unwrap();
|
||||
assert_eq!(super::kind(&tag).as_deref(), Some(id));
|
||||
// ThrowableItemProjectile: the item it flies as, by its default.
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!((&*item.item, item.count), (id, 1));
|
||||
assert_eq!(number(tag.get("HasBeenShot")), Some(1));
|
||||
assert_eq!(uuid_from_tag(tag.get("Owner")), player);
|
||||
let back = throwable_from_nbt(&tag, player).unwrap();
|
||||
assert_eq!((back.kind, back.owner), (kind, Some(Who::Player)));
|
||||
assert_eq!((back.position, back.velocity), (p.position, p.velocity));
|
||||
assert_eq!(
|
||||
([back.yaw, back.pitch], back.left_owner),
|
||||
([30.0, -10.0], true)
|
||||
);
|
||||
// an owner who is not here keeps their UUID through a moved resave.
|
||||
let mut away = throwable_from_nbt(&tag, Some([9; 4])).unwrap();
|
||||
assert_eq!((away.owner, away.owner_uuid), (None, player));
|
||||
away.position.y += 3.0;
|
||||
let resaved = throwable_to_nbt(&away, [1; 4], away.owner_uuid, None).unwrap();
|
||||
assert_eq!(uuid_from_tag(resaved.get("Owner")), player);
|
||||
}
|
||||
// the chunk saves arrows, fireballs and these, and so takes them with it.
|
||||
let kinds = [
|
||||
(ProjectileKind::SmallFireball, true),
|
||||
(ProjectileKind::Snowball, true),
|
||||
(ProjectileKind::WindCharge, false),
|
||||
(ProjectileKind::Trident, false),
|
||||
];
|
||||
for (kind, saved) in kinds {
|
||||
let p = Projectile::new(kind, Vec3::ZERO, None);
|
||||
assert_eq!(saved_with_chunk(&p), saved, "{:?}", p.kind);
|
||||
}
|
||||
// a loaded item stays, and a new projectile leaves LeftOwner out.
|
||||
let raw = Tag::compound([
|
||||
("id", Tag::String("minecraft:egg".into())),
|
||||
("Pos", doubles(&[0.0, 64.0, 0.0])),
|
||||
(
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new("minecraft:blue_egg", 1)),
|
||||
),
|
||||
]);
|
||||
let egg = throwable_from_nbt(&raw, None).unwrap();
|
||||
let tag = throwable_to_nbt(&egg, [1; 4], None, Some(&raw)).unwrap();
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!(&*item.item, "minecraft:blue_egg");
|
||||
assert!(tag.get("LeftOwner").is_none() && tag.get("Owner").is_none());
|
||||
assert_eq!(number(tag.get("HasBeenShot")), Some(0));
|
||||
let wind = Projectile::new(ProjectileKind::WindCharge, Vec3::ZERO, None);
|
||||
assert!(throwable_to_nbt(&wind, [1; 4], None, None).is_none());
|
||||
// NoGravity is loaded and saved again (a summoned pearl that floats).
|
||||
let floating = Tag::compound([
|
||||
("id", Tag::String("minecraft:ender_pearl".into())),
|
||||
("Pos", doubles(&[0.0, 64.0, 0.0])),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
]);
|
||||
let pearl = throwable_from_nbt(&floating, None).unwrap();
|
||||
assert!(pearl.no_gravity);
|
||||
let tag = throwable_to_nbt(&pearl, [1; 4], None, None).unwrap();
|
||||
assert_eq!(number(tag.get("NoGravity")), Some(1));
|
||||
assert!(
|
||||
throwable_to_nbt(&egg, [1; 4], None, None)
|
||||
.unwrap()
|
||||
.get("NoGravity")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -4136,6 +4397,43 @@ mod neutral_anger_tests {
|
||||
mod held_item_name_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_worn_piece_keeps_its_damage_and_name_through_the_save() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<_, _> = data
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(id, kind)| (id, Arc::new(kind)))
|
||||
.collect();
|
||||
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
|
||||
let mut helmet = crate::items::ItemStack::new("minecraft:iron_helmet", 1);
|
||||
helmet.damage = 40;
|
||||
helmet.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Hat".into()));
|
||||
crate::mobs::finalize::set_item_stack(&mut mob, crate::mobs::finalize::HEAD, helmet);
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
|
||||
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
|
||||
let worn = crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
|
||||
assert_eq!(worn.damage, 40);
|
||||
assert_eq!(worn.custom_name().as_deref(), Some("Hat"));
|
||||
// Replacing the item forgets the old stack.
|
||||
let mut loaded = loaded;
|
||||
crate::mobs::finalize::set_item(
|
||||
&mut loaded,
|
||||
crate::mobs::finalize::HEAD,
|
||||
"minecraft:iron_helmet",
|
||||
);
|
||||
let fresh =
|
||||
crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
|
||||
assert_eq!(fresh.damage, 0);
|
||||
assert!(fresh.custom_name().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -4184,3 +4482,33 @@ mod held_item_name_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mod_thrown_nbt_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_mod_thrown_type_saves_its_id_and_item_and_loads_back() {
|
||||
crate::projectiles::register_test_types();
|
||||
let bolt = crate::projectiles::thrown_type("ruby:bolt").unwrap();
|
||||
let mut p = Projectile::new(
|
||||
ProjectileKind::Thrown(bolt),
|
||||
Vec3::new(1.0, 80.0, 2.0),
|
||||
None,
|
||||
);
|
||||
p.velocity = Vec3::new(0.0, 0.5, 1.0);
|
||||
assert!(saved_with_chunk(&p));
|
||||
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
|
||||
assert_eq!(super::kind(&tag).as_deref(), Some("ruby:bolt"));
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!(&*item.item, "ruby:gem");
|
||||
let back = throwable_from_nbt(&tag, None).unwrap();
|
||||
assert_eq!((&back.kind, back.velocity), (&p.kind, p.velocity));
|
||||
// NoGravity comes back too, as for vanilla's (a summoned bolt that floats).
|
||||
p.no_gravity = true;
|
||||
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
|
||||
assert!(throwable_from_nbt(&tag, None).unwrap().no_gravity);
|
||||
// a type no mod declares now is not a thrown kind.
|
||||
assert!(throwable_kind("gone:bolt").is_none());
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,52 @@ impl FlatTerrain {
|
||||
void_platform,
|
||||
})
|
||||
}
|
||||
/// The settings of a superflat overworld that lets structure sets generate
|
||||
/// (`FlatLevelSource.createState`): its layers, biome and `structure_overrides`
|
||||
/// (no key means every set; `[]` none). `None` for anything else, a world with no
|
||||
/// structure set to generate, the Void preset's biome (its platform is a feature
|
||||
/// this generator places) or one that is not superflat.
|
||||
pub fn structure_settings(
|
||||
settings: &Tag,
|
||||
) -> Option<horizon_world::worldgen::generator::FlatSettings> {
|
||||
let generator = settings
|
||||
.get("dimensions")?
|
||||
.get("minecraft:overworld")?
|
||||
.get("generator")?;
|
||||
if crate::save::text(generator.get("type"))?.trim_start_matches("minecraft:") != "flat" {
|
||||
return None;
|
||||
}
|
||||
let s = generator.get("settings")?;
|
||||
let biome = crate::save::text(s.get("biome")).unwrap_or_else(|| "minecraft:plains".into());
|
||||
if biome == "minecraft:the_void" {
|
||||
return None;
|
||||
}
|
||||
let mut layers = Vec::new();
|
||||
if let Some(Tag::List(list)) = s.get("layers") {
|
||||
for layer in list {
|
||||
let block = crate::save::text(layer.get("block"))?;
|
||||
let height = crate::save::number(layer.get("height")).unwrap_or(0).max(0) as i32;
|
||||
layers.push((block, height));
|
||||
}
|
||||
}
|
||||
let structure_overrides = match s.get("structure_overrides") {
|
||||
Some(Tag::List(sets)) => Some(
|
||||
sets.iter()
|
||||
.filter_map(|set| crate::save::text(Some(set)))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
Some(Tag::String(set)) => Some(vec![set.to_string_lossy()]),
|
||||
_ => None,
|
||||
};
|
||||
if structure_overrides.as_ref().is_some_and(Vec::is_empty) {
|
||||
return None;
|
||||
}
|
||||
Some(horizon_world::worldgen::generator::FlatSettings {
|
||||
layers,
|
||||
biome,
|
||||
structure_overrides,
|
||||
})
|
||||
}
|
||||
/// FlatLevelSource.getSpawnHeight: the top of the layers.
|
||||
pub fn spawn_height(&self) -> i32 {
|
||||
self.shape.min_y + self.layers.len().min(self.shape.height as usize) as i32
|
||||
@@ -479,4 +525,67 @@ mod tests {
|
||||
(None, vec!["Sum of layer heights is > 4064".into()])
|
||||
);
|
||||
}
|
||||
|
||||
/// `FlatLevelSource.createState`: the settings a flat world's structure sets run with.
|
||||
#[test]
|
||||
fn a_flat_world_names_the_structure_sets_that_may_generate() {
|
||||
let world = |generator: Tag| {
|
||||
Tag::compound([(
|
||||
"dimensions",
|
||||
Tag::compound([(
|
||||
"minecraft:overworld",
|
||||
Tag::compound([("generator", generator)]),
|
||||
)]),
|
||||
)])
|
||||
};
|
||||
let flat = |extra: Vec<(&str, Tag)>, biome: &str| {
|
||||
let mut settings = vec![
|
||||
("biome", Tag::String(biome.into())),
|
||||
(
|
||||
"layers",
|
||||
Tag::List(vec![Tag::compound([
|
||||
("block", Tag::String("minecraft:stone".into())),
|
||||
("height", Tag::Int(3)),
|
||||
])]),
|
||||
),
|
||||
];
|
||||
settings.extend(extra);
|
||||
world(Tag::compound([
|
||||
("type", Tag::String("minecraft:flat".into())),
|
||||
("settings", Tag::compound(settings)),
|
||||
]))
|
||||
};
|
||||
// No key: every set. A list: those. An empty list: none. The void and a noise world: none.
|
||||
let all = FlatTerrain::structure_settings(&flat(vec![], "minecraft:plains")).unwrap();
|
||||
assert_eq!(all.structure_overrides, None);
|
||||
assert_eq!(all.layers, [("minecraft:stone".to_owned(), 3)]);
|
||||
let some = FlatTerrain::structure_settings(&flat(
|
||||
vec![(
|
||||
"structure_overrides",
|
||||
Tag::List(vec![Tag::String("minecraft:villages".into())]),
|
||||
)],
|
||||
"minecraft:desert",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
some.structure_overrides,
|
||||
Some(vec!["minecraft:villages".to_owned()])
|
||||
);
|
||||
assert_eq!(some.biome, "minecraft:desert");
|
||||
assert!(
|
||||
FlatTerrain::structure_settings(&flat(
|
||||
vec![("structure_overrides", Tag::List(vec![]))],
|
||||
"minecraft:plains"
|
||||
))
|
||||
.is_none()
|
||||
);
|
||||
assert!(FlatTerrain::structure_settings(&flat(vec![], "minecraft:the_void")).is_none());
|
||||
assert!(
|
||||
FlatTerrain::structure_settings(&world(Tag::compound([(
|
||||
"type",
|
||||
Tag::String("minecraft:noise".into())
|
||||
)])))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
+947
-189
File diff suppressed because it is too large.
Load diff
@@ -326,13 +326,19 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let data = self.options.resources.join("data");
|
||||
// The world's packs' advancements go over the game's own.
|
||||
let files = super::functions::content_overlay(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
&self.level.enabled_features,
|
||||
);
|
||||
// The world's packs' advancements go over the game's own, and a
|
||||
// native mod's contributed files are already among them: the tables
|
||||
// were built for this open (`build_world_tables`), so the hook is
|
||||
// asked once, not again here.
|
||||
let files = match &self.world_tables {
|
||||
Some(tables) => tables.files.clone(),
|
||||
None => super::functions::content_overlay(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
&self.level.enabled_features,
|
||||
),
|
||||
};
|
||||
self.adv.pending_tree = std::thread::Builder::new()
|
||||
.name("horizon-advancements".into())
|
||||
.spawn(move || Tree::shared(&data, &horizon_world::loot::PackOverlay { files: &files }))
|
||||
@@ -457,6 +463,8 @@ impl Game<'_> {
|
||||
EntityView {
|
||||
kind: "minecraft:player".into(),
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
// Entity.isOnFire on a server: remainingFireTicks (players aren't fire immune).
|
||||
on_fire: p.fire_ticks > 0,
|
||||
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
// Input.shift: the held key, as `BlockItem.place`'s sneaking reads it.
|
||||
input_sneak: self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft),
|
||||
|
||||
@@ -498,7 +498,7 @@ impl Game<'_> {
|
||||
json!({
|
||||
"dialog": self.dialog_status(),
|
||||
"screen": self.screen_label(),
|
||||
"menu": self.title.as_ref().map(|_| self.title_value()),
|
||||
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
|
||||
"world": (self.title.is_none()).then(|| match &self.integrated {
|
||||
Some(server) => server.world_name.clone(),
|
||||
None => self.level.name.clone(),
|
||||
@@ -528,8 +528,16 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Splitscreen's profile page, a joining player's (`split_profiles`).
|
||||
fn split_profiles_open(&self) -> bool {
|
||||
self.screen == Screen::Options
|
||||
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
|
||||
}
|
||||
fn title_value(&self) -> Value {
|
||||
use horizon_client::title::Mode;
|
||||
if self.split_profiles_open() {
|
||||
return self.ui.options.split_profiles.state();
|
||||
}
|
||||
let Some(title) = &self.title else {
|
||||
return Value::Null;
|
||||
};
|
||||
@@ -727,7 +735,7 @@ impl Game<'_> {
|
||||
v["chat_input"] = json!(chat.input.value);
|
||||
v["chat_suggestions"] = json!(chat.suggestions_visible());
|
||||
}
|
||||
if self.title.is_some() {
|
||||
if self.title.is_some() || self.split_profiles_open() {
|
||||
v["menu"] = self.title_value();
|
||||
}
|
||||
if self.screen == Screen::Inventory && self.title.is_none() {
|
||||
@@ -1591,6 +1599,22 @@ impl Game<'_> {
|
||||
self.synth_click(event_loop, x, y, button)?;
|
||||
ok(json!({ "ok": true, "screen": self.screen_label() }))
|
||||
}
|
||||
// Splitscreen's profile page, over a joining player's title screen.
|
||||
"type" if self.split_profiles_open() => {
|
||||
let text = line
|
||||
.trim_start()
|
||||
.strip_prefix("type")
|
||||
.unwrap_or("")
|
||||
.strip_prefix(' ')
|
||||
.unwrap_or("");
|
||||
let page = &mut self.ui.options.split_profiles;
|
||||
if text == "\\b" {
|
||||
page.key(winit::keyboard::KeyCode::Backspace, None);
|
||||
} else {
|
||||
page.type_text(text);
|
||||
}
|
||||
ok(json!({ "ok": true, "menu": self.title_value() }))
|
||||
}
|
||||
"type" if self.title.is_some() => {
|
||||
// type [--field name|seed] [--replace] TEXT on the title screens.
|
||||
let mut text = line
|
||||
|
||||
@@ -522,7 +522,9 @@ impl Game<'_> {
|
||||
let slot = match &held {
|
||||
Some(h) => {
|
||||
let slot = self.equipment_slot(h);
|
||||
if slot < 2 && !a.show_arms {
|
||||
// ArmorStand.interactAt: a disabled slot, or a hand without
|
||||
// arms, is FAIL.
|
||||
if a.is_disabled(slot) {
|
||||
return true;
|
||||
}
|
||||
slot
|
||||
@@ -550,6 +552,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
};
|
||||
// An empty hand takes from the main hand when the clicked slot is
|
||||
// disabled (`isDisabled(clickedSlot) ? slot : clickedSlot`).
|
||||
let slot = if held.is_none() && a.is_disabled(slot) {
|
||||
0
|
||||
} else {
|
||||
slot
|
||||
};
|
||||
// ArmorStand.swapItem: taking from a filled slot or putting into an
|
||||
// empty one can be disabled; the click then passes.
|
||||
if a.swap_locked(slot) {
|
||||
return false;
|
||||
}
|
||||
let current = self.armor_stands[i].equipment[slot].clone();
|
||||
if held.is_none() && current.is_none() {
|
||||
return false;
|
||||
@@ -722,6 +736,8 @@ impl Game<'_> {
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
for (i, a) in self.armor_stands.iter_mut().enumerate() {
|
||||
a.previous = a.position;
|
||||
a.previous_yaw = a.yaw;
|
||||
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
@@ -785,11 +801,10 @@ impl Game<'_> {
|
||||
let dragon = a.equipment[5]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:dragon_head");
|
||||
let position = super::entity_culling::at(a.position);
|
||||
if !self.standing_entity_in_view(
|
||||
camera,
|
||||
position,
|
||||
position,
|
||||
super::entity_culling::at(a.previous),
|
||||
super::entity_culling::at(a.position),
|
||||
alpha,
|
||||
width,
|
||||
height,
|
||||
@@ -798,7 +813,9 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let p = glam::DVec3::new(a.position.x, a.position.y, a.position.z) - camera;
|
||||
// EntityRenderer: drawn between last tick's position and this one's.
|
||||
let at = a.render_position(alpha);
|
||||
let p = super::entity_culling::at(at) - camera;
|
||||
// setupRotations: 180 - body yaw, plus the wobble for 5 ticks after a hit.
|
||||
let wiggle = (self.world.game_time - a.last_hit) as f32 + alpha as f32;
|
||||
let wobble = if wiggle < 5.0 {
|
||||
@@ -807,13 +824,15 @@ impl Game<'_> {
|
||||
0.0
|
||||
};
|
||||
let base = glam::Mat4::from_translation(p.as_vec3())
|
||||
* glam::Mat4::from_rotation_y((180.0 - a.yaw + wobble).to_radians())
|
||||
* glam::Mat4::from_rotation_y(
|
||||
(180.0 - a.render_yaw(alpha as f32) + wobble).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
|
||||
let light = self.world.light(
|
||||
a.position.x.floor() as i32,
|
||||
(a.position.y + 0.5).floor() as i32,
|
||||
a.position.z.floor() as i32,
|
||||
at.x.floor() as i32,
|
||||
(at.y + 0.5).floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
self.armor_stand_vertices(a, base, packed, out);
|
||||
@@ -1322,6 +1341,26 @@ mod swap_tests {
|
||||
ItemStack::new(item, count)
|
||||
}
|
||||
#[test]
|
||||
fn disabled_slots_lock_by_filter_flag() {
|
||||
let mut a = ArmorStand::new(1, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
// Hands need arms.
|
||||
assert!(a.is_disabled(0) && a.is_disabled(1));
|
||||
a.show_arms = true;
|
||||
assert!(!a.is_disabled(0) && !a.is_disabled(1));
|
||||
// Bit 4 locks the head (equipment index 5), bit 5 the off hand (1).
|
||||
a.disabled_slots = (1 << 4) | (1 << 5);
|
||||
assert!(a.is_disabled(5) && a.is_disabled(1));
|
||||
assert!(!a.is_disabled(4) && !a.is_disabled(0));
|
||||
// Taking (+8) from a filled slot and putting (+16) into an empty one.
|
||||
a.disabled_slots = (1 << (3 + 8)) | (1 << (1 + 16));
|
||||
assert!(!a.swap_locked(4), "an empty chest slot is not take-locked");
|
||||
a.equipment[4] = Some(stack("minecraft:iron_chestplate", 1));
|
||||
assert!(a.swap_locked(4));
|
||||
assert!(a.swap_locked(2), "an empty feet slot is put-locked");
|
||||
a.equipment[2] = Some(stack("minecraft:iron_boots", 1));
|
||||
assert!(!a.swap_locked(2));
|
||||
}
|
||||
#[test]
|
||||
fn creative_swap_gives_the_worn_piece_to_the_hand() {
|
||||
let swap = swap_with_equipment_slot(
|
||||
&stack("minecraft:diamond_chestplate", 1),
|
||||
|
||||
@@ -778,6 +778,15 @@ impl Game<'_> {
|
||||
return None;
|
||||
};
|
||||
let skin = skull.skin.as_ref()?;
|
||||
// PlayerSkin.Patch: a `texture` override replaces the profile's skin.
|
||||
if let Some(texture) = &skin.texture
|
||||
&& let Some(sprite) =
|
||||
skins::patch_skin(texture, &mut self.skin_downloads.borrow_mut(), |location| {
|
||||
self.gui.atlas.sprites.contains_key(location)
|
||||
})
|
||||
{
|
||||
return Some(sprite);
|
||||
}
|
||||
// The texture a packed or resolved `textures` property fetches: the
|
||||
// download of a static profile, or the one the services answer for a
|
||||
// `Dynamic` (name- or id-only) profile.
|
||||
|
||||
@@ -152,6 +152,31 @@ impl Game<'_> {
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `ShulkerBoxBlock.playerWillDestroy`: a Creative player's filled box is
|
||||
/// one item at the block center, with collectComponents and the default pickup delay.
|
||||
/// This direct spawn intentionally does not use the block_drops gamerule.
|
||||
pub(super) fn drop_creative_shulker(&mut self, pos: [i32; 3]) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let filled = matches!(self.world.block_entities.get(&key), Some(BlockEntity::Chest(items))
|
||||
if items.iter().flatten().any(|s| s.count > 0));
|
||||
if !filled {
|
||||
return;
|
||||
}
|
||||
let Some(saved) = self.saved_block_entity(key) else {
|
||||
return;
|
||||
};
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let mut stack = ItemStack::new(&self.res.facts().get(state).item, 1);
|
||||
let patch = block_entity_item::collected_components(&saved);
|
||||
(stack.damage, stack.extra) = item_data::decode_components(Some(&Tag::Compound(patch)));
|
||||
self.spawn_item(
|
||||
stack,
|
||||
pos.map(|v| f64::from(v) + 0.5),
|
||||
super::dispenser::new_item_motion(),
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
/// `BlockItem.updateBlockStateFromTag`: the properties of the item's
|
||||
/// `block_state` component (a hive's honey level, a statue's pose) set on
|
||||
/// the block just placed at `pos`. Returns the state it has now.
|
||||
|
||||
@@ -28,7 +28,10 @@ fn parse_state(text: &str) -> Result<BlockState> {
|
||||
let (k, v) = pair.split_once('=').ok_or("properties are key=value")?;
|
||||
properties.insert(k.to_owned(), v.to_owned());
|
||||
}
|
||||
Ok(BlockState { name, properties })
|
||||
Ok(BlockState {
|
||||
name,
|
||||
properties: properties.into(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
leash::{Holder, Pending},
|
||||
riders,
|
||||
},
|
||||
motion::RiddenBoat,
|
||||
motion::{BubbleColumn, RiddenBoat, bubble_column_at},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
|
||||
@@ -48,6 +48,25 @@ pub(super) struct Boat {
|
||||
water_level: f64,
|
||||
land_friction: f32,
|
||||
out_of_control: f32,
|
||||
/// `AbstractBoat.DATA_ID_BUBBLE_TIME` (synched): the ticks of `BUBBLE_TIME`
|
||||
/// left before a bubble column lifts or drags the boat; 0 when it is not
|
||||
/// over one.
|
||||
pub bubble_time: i32,
|
||||
/// `AbstractBoat.isAboveBubbleColumn`, set by `onAboveBubbleColumn` and
|
||||
/// cleared by `tickBubbleColumn` (the server's).
|
||||
above_bubble_column: bool,
|
||||
/// `AbstractBoat.bubbleColumnDirectionIsDown`.
|
||||
bubble_down: bool,
|
||||
/// A bubble column's drag asked a rider the server can't move itself (a
|
||||
/// player) to get out: `guest_vehicle_tick` takes it (`ejectPassengers`).
|
||||
pub eject_riders: bool,
|
||||
/// `Entity.tickCount`, counted where the wobble is (a client's tick).
|
||||
tick_count: i32,
|
||||
/// `AbstractBoat.bubbleMultiplier`, `bubbleAngle` and `bubbleAngleO`
|
||||
/// (a client's).
|
||||
bubble_multiplier: f32,
|
||||
bubble_angle: f32,
|
||||
bubble_angle_old: f32,
|
||||
pub hurt_time: i32,
|
||||
pub hurt_dir: i32,
|
||||
pub damage: f32,
|
||||
@@ -72,6 +91,13 @@ pub(super) struct Boat {
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
/// `AbstractBoat.BUBBLE_TIME`: the ticks over a bubble column before it
|
||||
/// lifts or drags the boat.
|
||||
const BUBBLE_TIME: i32 = 60;
|
||||
/// `EntityEvent` 71 and 72, which `AbstractBoat.tickBubbleColumn` broadcasts
|
||||
/// for a down and an up column.
|
||||
pub(super) const BUBBLE_DOWN_EVENT: i8 = 71;
|
||||
pub(super) const BUBBLE_UP_EVENT: i8 = 72;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
pub(super) const HEIGHT: f64 = 0.5625;
|
||||
impl Boat {
|
||||
@@ -90,6 +116,14 @@ impl Boat {
|
||||
water_level: 0.0,
|
||||
land_friction: 0.0,
|
||||
out_of_control: 0.0,
|
||||
bubble_time: 0,
|
||||
above_bubble_column: false,
|
||||
bubble_down: false,
|
||||
eject_riders: false,
|
||||
tick_count: 0,
|
||||
bubble_multiplier: 0.0,
|
||||
bubble_angle: 0.0,
|
||||
bubble_angle_old: 0.0,
|
||||
hurt_time: 0,
|
||||
hurt_dir: 1,
|
||||
damage: 0.0,
|
||||
@@ -174,6 +208,42 @@ impl Boat {
|
||||
yaw,
|
||||
)
|
||||
}
|
||||
/// `AbstractBoat.getBubbleAngle`: the wobble, in degrees, `a` of the way
|
||||
/// from the last tick's to this one's.
|
||||
pub fn bubble_angle(&self, a: f32) -> f32 {
|
||||
self.bubble_angle_old + a * (self.bubble_angle - self.bubble_angle_old)
|
||||
}
|
||||
/// `AbstractBoat.tickBubbleColumn`: on a client, the wobble grows by 0.05 a
|
||||
/// tick while the synched bubble time runs and fades by 0.1 otherwise.
|
||||
pub(super) fn tick_bubble_wobble(&mut self) {
|
||||
self.tick_count += 1;
|
||||
if self.bubble_time > 0 {
|
||||
self.bubble_multiplier += 0.05;
|
||||
} else {
|
||||
self.bubble_multiplier -= 0.1;
|
||||
}
|
||||
self.bubble_multiplier = self.bubble_multiplier.clamp(0.0, 1.0);
|
||||
self.bubble_angle_old = self.bubble_angle;
|
||||
self.bubble_angle =
|
||||
10.0 * (0.5 * f64::from(self.tick_count)).sin() as f32 * self.bubble_multiplier;
|
||||
}
|
||||
/// `AbstractBoat.tickBubbleColumn`'s countdown on the server: the bubble
|
||||
/// time drops while the boat stays over a column and resets when it leaves.
|
||||
/// `Some(down)` on the tick it runs out: the boat is thrown up (`false`) or
|
||||
/// pulled down (`true`).
|
||||
pub(super) fn tick_bubble_countdown(&mut self) -> Option<bool> {
|
||||
if !self.above_bubble_column {
|
||||
self.bubble_time = 0;
|
||||
}
|
||||
if self.bubble_time <= 0 {
|
||||
return None;
|
||||
}
|
||||
self.bubble_time -= 1;
|
||||
let diff = BUBBLE_TIME - self.bubble_time - 1;
|
||||
let fired = (diff > 0 && self.bubble_time == 0).then_some(self.bubble_down);
|
||||
self.above_bubble_column = false;
|
||||
fired
|
||||
}
|
||||
/// `getRowingTime`.
|
||||
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
|
||||
if self.paddling[side] {
|
||||
@@ -684,9 +754,194 @@ impl Game<'_> {
|
||||
if self.boats[i].out_of_control >= 60.0 {
|
||||
self.eject_boat_mobs(i, false);
|
||||
}
|
||||
self.boat_bubble_tick(i);
|
||||
self.boat_contacts(i);
|
||||
}
|
||||
}
|
||||
/// `Entity.canSimulateMovement`: for a boat (`AUTHORITATIVE_SIDE`) a
|
||||
/// server leaves the boat its player drives to that player's client, and a
|
||||
/// client moves only the boat its player drives.
|
||||
fn boat_can_simulate_movement(&self, id: u32) -> bool {
|
||||
if self.remote.is_some() {
|
||||
self.riding == Some(id)
|
||||
} else {
|
||||
!self.guest_vehicles().contains(&id)
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.tick`: `status = getStatus()` for a boat this window
|
||||
/// does not tick (`tick_boat` sets it for the others): a client's boats
|
||||
/// other than the one it drives, and a server's boats its players drive.
|
||||
pub(super) fn refresh_boat_status(&mut self, i: usize) {
|
||||
let b = &self.boats[i];
|
||||
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
|
||||
let status = self.boat_status(&mut probe);
|
||||
self.boats[i].status = status;
|
||||
}
|
||||
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
|
||||
/// (its box deflated by 1e-5 as `Entity.checkInsideBlocks` does), in x, y,
|
||||
/// z order.
|
||||
pub(super) fn boat_bubble_columns(&self, i: usize) -> Vec<BubbleColumn> {
|
||||
let b = self.boats[i].bounds().deflate(Vec3::new(
|
||||
f64::from(1.0e-5f32),
|
||||
f64::from(1.0e-5f32),
|
||||
f64::from(1.0e-5f32),
|
||||
));
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let mut columns = Vec::new();
|
||||
for x in lo.x.floor() as i32..=(hi.x.ceil() as i32 - 1) {
|
||||
for y in lo.y.floor() as i32..=(hi.y.ceil() as i32 - 1) {
|
||||
for z in lo.z.floor() as i32..=(hi.z.ceil() as i32 - 1) {
|
||||
if let Some(column) = bubble_column_at(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
BlockPos::new(x, y, z),
|
||||
) {
|
||||
columns.push(column);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
columns
|
||||
}
|
||||
/// `Entity.handleOnInsideBubbleColumn`: a column under more water lifts
|
||||
/// boat `i` up to 0.7 or drags it down to -0.3 (`AbstractBoat` does not
|
||||
/// override `onInsideBubbleColumn`), applied by the column's own push below
|
||||
/// the surface.
|
||||
pub(super) fn boat_inside_bubble_column(&mut self, i: usize, column: BubbleColumn) {
|
||||
if !self.boat_can_simulate_movement(self.boats[i].id) {
|
||||
return;
|
||||
}
|
||||
column.push(&mut self.boats[i].velocity);
|
||||
}
|
||||
/// `AbstractBoat.onAboveBubbleColumn`: whether the splash comes up, one
|
||||
/// time in a hundred for a boat that is not under water.
|
||||
pub(super) fn boat_splash_roll(&mut self, i: usize) -> bool {
|
||||
!self.boats[i].under_water() && self.world.random.next_int_bound(100).unwrap_or(1) == 0
|
||||
}
|
||||
/// `AbstractBoat.onAboveBubbleColumn`: the client's half after the roll,
|
||||
/// the splash sound and particle at the boat.
|
||||
pub(super) fn boat_bubble_splash(&mut self, i: usize) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let p = self.boats[i].position;
|
||||
let pitch = 0.8 + 0.4 * self.world.random.next_float();
|
||||
self.audio.play(
|
||||
"minecraft:entity.generic.splash",
|
||||
Some([p.x, p.y, p.z]),
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
let x = p.x + f64::from(self.world.random.next_float());
|
||||
let z = p.z + f64::from(self.world.random.next_float());
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::Splash),
|
||||
[x, p.y + 0.7, z],
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
/// `AbstractBoat.onAboveBubbleColumn`: on the server (this window
|
||||
/// simulates the boat) the column is remembered, the countdown starts and
|
||||
/// the splash's game event goes to the listeners (its sound and particle
|
||||
/// are the client's).
|
||||
fn boat_above_bubble_column(&mut self, i: usize, drag_down: bool) {
|
||||
let b = &mut self.boats[i];
|
||||
b.above_bubble_column = true;
|
||||
b.bubble_down = drag_down;
|
||||
if b.bubble_time == 0 {
|
||||
b.bubble_time = BUBBLE_TIME;
|
||||
}
|
||||
if !self.boat_splash_roll(i) {
|
||||
return;
|
||||
}
|
||||
// A window that is its own client hears and sees the splash too.
|
||||
if self.options.execution_role.is_client() {
|
||||
self.boat_bubble_splash(i);
|
||||
}
|
||||
let (id, p) = (self.boats[i].id, self.boats[i].position);
|
||||
// Entity.gameEvent(SPLASH, getControllingPassenger()): the window's
|
||||
// own player when it drives the boat.
|
||||
let ctx = if self.riding == Some(id) && self.player.is_some() {
|
||||
self.player_context(None)
|
||||
} else {
|
||||
self.who_context(None, None)
|
||||
};
|
||||
self.post_game_event("minecraft:splash", [p.x, p.y, p.z], ctx);
|
||||
}
|
||||
/// `AbstractBoat.handleBubbleColumnEffect`: an up column throws the boat
|
||||
/// up at 0.6, a down column pulls it down by 0.7.
|
||||
pub(super) fn handle_bubble_column_effect(&mut self, i: usize, drag_down: bool) {
|
||||
if !self.boat_can_simulate_movement(self.boats[i].id) {
|
||||
return;
|
||||
}
|
||||
let v = &mut self.boats[i].velocity;
|
||||
if drag_down {
|
||||
// movement.add(0.0, -0.7, 0.0)
|
||||
v.y += -0.7;
|
||||
} else {
|
||||
v.y = 0.6;
|
||||
}
|
||||
}
|
||||
/// `Entity.ejectPassengers`: for a down column the mobs get out here, this
|
||||
/// window's player gets out, and a server's player is asked to
|
||||
/// (`guest_vehicle_tick` lets it out through its client).
|
||||
fn eject_boat_riders(&mut self, i: usize) {
|
||||
self.eject_boat_mobs(i, false);
|
||||
let id = self.boats[i].id;
|
||||
if self.riding == Some(id) && self.player.is_some() {
|
||||
self.dismount(i);
|
||||
} else if self.guest_rides(id) {
|
||||
self.boats[i].eject_riders = true;
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.tickBubbleColumn`: on the server, a boat that stays over
|
||||
/// a column for `BUBBLE_TIME` ticks is thrown up or pulled down (a down
|
||||
/// column lets the riders out first), and every client is told.
|
||||
fn tick_boat_bubble_column(&mut self, i: usize) {
|
||||
if let Some(down) = self.boats[i].tick_bubble_countdown() {
|
||||
let (id, p) = (self.boats[i].id, self.boats[i].position);
|
||||
let event = if down {
|
||||
BUBBLE_DOWN_EVENT
|
||||
} else {
|
||||
BUBBLE_UP_EVENT
|
||||
};
|
||||
if down {
|
||||
self.eject_boat_riders(i);
|
||||
}
|
||||
self.lan_entity_event(id as i32, event, [p.x, p.y, p.z]);
|
||||
self.handle_bubble_column_effect(i, down);
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.tick`: the end of it on the server for boat `i`, the two
|
||||
/// `applyEffectsFromBlocks` that reach bubble columns (the lava is
|
||||
/// `boat_lava_tick`'s), then `tickBubbleColumn`; and the wobble when this
|
||||
/// window is its own client.
|
||||
fn boat_bubble_tick(&mut self, i: usize) {
|
||||
let id = self.boats[i].id;
|
||||
// The boat a server's player drives is not ticked here: its status is.
|
||||
if self.guest_vehicles().contains(&id) {
|
||||
self.refresh_boat_status(i);
|
||||
}
|
||||
// An ejection nobody took (its player left the boat first) is void.
|
||||
if !self.guest_rides(id) {
|
||||
self.boats[i].eject_riders = false;
|
||||
}
|
||||
let touches = self.boat_bubble_columns(i);
|
||||
// AbstractBoat.tick calls applyEffectsFromBlocks twice.
|
||||
for _ in 0..2 {
|
||||
for &column in &touches {
|
||||
if column.surface {
|
||||
self.boat_above_bubble_column(i, column.drag);
|
||||
} else {
|
||||
self.boat_inside_bubble_column(i, column);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tick_boat_bubble_column(i);
|
||||
if self.options.execution_role.is_client() {
|
||||
self.boats[i].tick_bubble_wobble();
|
||||
}
|
||||
}
|
||||
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
|
||||
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
|
||||
/// damage, so lava breaks a boat on its first tick inside, and the fire
|
||||
@@ -1053,6 +1308,8 @@ impl Game<'_> {
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
self.eject_boat_mobs(i, true);
|
||||
let id = self.boats[i].id;
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
// AbstractBoat.remove: a destroyed boat drops its lead.
|
||||
@@ -1132,6 +1389,16 @@ impl Game<'_> {
|
||||
(hurt.sin() * hurt * damage / 10.0 * b.hurt_dir as f32).to_radians(),
|
||||
);
|
||||
}
|
||||
// AbstractBoatRenderer.extractRenderState: bubbleAngle.
|
||||
let wobble = b.bubble_angle(a);
|
||||
// AbstractBoatRenderer.submit: a boat above water wobbles in a
|
||||
// bubble column, about the axis (1, 0, 1).
|
||||
if !b.under_water() && wobble.abs() >= 1.0e-5 {
|
||||
base *= glam::Mat4::from_axis_angle(
|
||||
glam::Vec3::new(1.0, 0.0, 1.0).normalize(),
|
||||
wobble * (std::f64::consts::PI / 180.0) as f32,
|
||||
);
|
||||
}
|
||||
base *= glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_rotation_y(90f32.to_radians());
|
||||
let mut poses = Poses::new(model);
|
||||
@@ -1186,3 +1453,95 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn boat() -> Boat {
|
||||
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// `AbstractBoat.tickBubbleColumn` on a client: the wobble grows while the
|
||||
/// bubble time runs and fades after.
|
||||
#[test]
|
||||
fn the_wobble_grows_while_the_bubble_time_runs_and_fades_after() {
|
||||
let mut b = boat();
|
||||
b.bubble_time = BUBBLE_TIME;
|
||||
for _ in 0..30 {
|
||||
b.tick_bubble_wobble();
|
||||
}
|
||||
// 0.05 a tick, capped at 1.
|
||||
assert_eq!(b.bubble_multiplier, 1.0);
|
||||
// 10 * sin(0.5 * tickCount) * multiplier.
|
||||
let expected = 10.0 * (0.5 * 30.0f64).sin() as f32;
|
||||
assert!((b.bubble_angle - expected).abs() < 1.0e-4);
|
||||
// The render angle lerps between the last two ticks.
|
||||
let mid = b.bubble_angle(0.5);
|
||||
assert!((mid - (b.bubble_angle_old + b.bubble_angle) / 2.0).abs() < 1.0e-5);
|
||||
// Once the bubble time is 0 the multiplier fades by 0.1 a tick.
|
||||
b.bubble_time = 0;
|
||||
for _ in 0..4 {
|
||||
b.tick_bubble_wobble();
|
||||
}
|
||||
assert!((b.bubble_multiplier - 0.6).abs() < 1.0e-5);
|
||||
for _ in 0..10 {
|
||||
b.tick_bubble_wobble();
|
||||
}
|
||||
assert_eq!(b.bubble_multiplier, 0.0);
|
||||
assert_eq!(b.bubble_angle, 0.0);
|
||||
}
|
||||
|
||||
/// `AbstractBoat.tickBubbleColumn` on the server: a boat held over a
|
||||
/// column fires on the 60th tick, with the column's direction, then the
|
||||
/// time restarts.
|
||||
#[test]
|
||||
fn the_bubble_countdown_fires_every_sixty_ticks_over_a_column() {
|
||||
for down in [false, true] {
|
||||
let mut b = boat();
|
||||
let mut fired = Vec::new();
|
||||
for tick in 1..=130 {
|
||||
// What boat_above_bubble_column does each tick.
|
||||
b.above_bubble_column = true;
|
||||
b.bubble_down = down;
|
||||
if b.bubble_time == 0 {
|
||||
b.bubble_time = BUBBLE_TIME;
|
||||
}
|
||||
if let Some(d) = b.tick_bubble_countdown() {
|
||||
fired.push((tick, d));
|
||||
}
|
||||
assert!(!b.above_bubble_column);
|
||||
}
|
||||
assert_eq!(fired, vec![(60, down), (120, down)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaving the column resets the time: nothing fires, and a boat with no
|
||||
/// time left does nothing over a column until the column sets it.
|
||||
#[test]
|
||||
fn leaving_the_column_resets_the_bubble_countdown() {
|
||||
let mut b = boat();
|
||||
b.bubble_time = BUBBLE_TIME;
|
||||
for _ in 0..30 {
|
||||
b.above_bubble_column = true;
|
||||
assert_eq!(b.tick_bubble_countdown(), None);
|
||||
}
|
||||
assert_eq!(b.bubble_time, 30);
|
||||
// A tick away from the column.
|
||||
assert_eq!(b.tick_bubble_countdown(), None);
|
||||
assert_eq!(b.bubble_time, 0);
|
||||
b.above_bubble_column = true;
|
||||
assert_eq!(b.tick_bubble_countdown(), None);
|
||||
assert!(b.above_bubble_column);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boat_that_never_saw_a_column_does_not_wobble() {
|
||||
let mut b = boat();
|
||||
for _ in 0..10 {
|
||||
b.tick_bubble_wobble();
|
||||
}
|
||||
assert_eq!(b.bubble_angle, 0.0);
|
||||
assert_eq!(b.bubble_angle(0.5), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ impl Game<'_> {
|
||||
}
|
||||
let at = hit.pos.map(|v| f64::from(v) + 0.5);
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
if brushable && self.brush_block(hit.pos, hit.face) {
|
||||
if brushable && self.brush_block(hit.pos, hit.face, slot) {
|
||||
self.damage_held(1);
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.brush`: whether the block changed (the brush wears).
|
||||
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3]) -> bool {
|
||||
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3], slot: usize) -> bool {
|
||||
let time = self.world.game_time;
|
||||
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
|
||||
brush.hit_face.get_or_insert(face);
|
||||
@@ -141,7 +141,7 @@ impl Game<'_> {
|
||||
let before = completion_state(brush.count);
|
||||
brush.count += 1;
|
||||
if brush.count >= 10 {
|
||||
self.brushing_completed(pos, brush.hit_face);
|
||||
self.brushing_completed(pos, brush.hit_face, slot);
|
||||
return true;
|
||||
}
|
||||
self.schedule_brushable_tick(pos);
|
||||
@@ -191,7 +191,7 @@ impl Game<'_> {
|
||||
|
||||
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
|
||||
/// brushed face and the block turns into its sand or gravel.
|
||||
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>) {
|
||||
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>, slot: usize) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let turns_into = match &*self.res.facts().get(state).name {
|
||||
"minecraft:suspicious_sand" => "minecraft:sand",
|
||||
@@ -212,7 +212,20 @@ impl Game<'_> {
|
||||
f64::from(pos[1] + face[1]) + 0.625,
|
||||
f64::from(pos[2] + face[2]) + 0.5,
|
||||
];
|
||||
self.spawn_item(item, at, [0.0; 3], 0);
|
||||
let mut drops = vec![item];
|
||||
let tool = self.stack_in(slot).cloned();
|
||||
let origin = pos.map(|c| f64::from(c) + 0.5);
|
||||
self.native_use_drops(
|
||||
"",
|
||||
origin,
|
||||
Some((pos, state)),
|
||||
None,
|
||||
tool.as_ref(),
|
||||
&mut drops,
|
||||
);
|
||||
for item in drops {
|
||||
self.spawn_item(item, at, [0.0; 3], 0);
|
||||
}
|
||||
}
|
||||
// Level event 3008 for every player, then the block change.
|
||||
self.level_event_at(3008, pos, state.0 as i32);
|
||||
|
||||
@@ -435,6 +435,9 @@ impl Game<'_> {
|
||||
]
|
||||
.map(String::from),
|
||||
);
|
||||
// content mods' thrown types.
|
||||
let thrown = horizon_client::projectiles::thrown_types().iter();
|
||||
summonable.extend(thrown.map(|t| t.id.to_owned()));
|
||||
summonable.sort();
|
||||
summonable.dedup();
|
||||
reg.summonable = summonable;
|
||||
@@ -1432,6 +1435,7 @@ impl Game<'_> {
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: true,
|
||||
});
|
||||
self.ui.screenshot_message = Some(path);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ pub(super) struct CombatTracker {
|
||||
pending: Vec<(String, (u32, Text), Option<Text>)>,
|
||||
/// `pending` for a direct entity with no causing entity: only its name.
|
||||
pending_direct: Vec<(String, Text)>,
|
||||
/// The lengths of `pending` and `pending_direct` before the first
|
||||
/// attacker noted for the hurt being dealt now (`forget_noted`).
|
||||
noted_since: Option<(usize, usize)>,
|
||||
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
|
||||
last_hurt_by: Option<((u32, Text), i64)>,
|
||||
/// The mob the player last hurt (`getLastHurtMob`) and when.
|
||||
@@ -91,6 +94,12 @@ pub(super) struct CombatTracker {
|
||||
/// known (`AbstractArrow.onHitEntity` makes an ownerless arrow its own
|
||||
/// cause).
|
||||
pub(super) projectile_direct: Option<Text>,
|
||||
/// The player whose primed TNT is going off now (`PrimedTnt.getOwner`):
|
||||
/// its entity id and display name, the blast's causing entity.
|
||||
pub(super) blast_owner: Option<(u32, Text)>,
|
||||
/// The guest behind `blast_owner`, when that player is a LAN guest; the
|
||||
/// host's own blast leaves it None.
|
||||
pub(super) blast_guest: Option<u32>,
|
||||
pub(super) projectile_attacker: Option<(u32, Text)>,
|
||||
/// The projectile hitting the player now is another player's that
|
||||
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
|
||||
@@ -145,6 +154,23 @@ pub(super) struct CombatTracker {
|
||||
/// end of the last one).
|
||||
fall_before: f64,
|
||||
}
|
||||
impl CombatTracker {
|
||||
fn mark(&mut self) {
|
||||
let marks = (self.pending.len(), self.pending_direct.len());
|
||||
self.noted_since.get_or_insert(marks);
|
||||
}
|
||||
/// The player's hurt is over: when a native mod canceled it, the
|
||||
/// attackers noted for it go, as no combat entry takes them and the next
|
||||
/// hit of the same damage type must not be credited to them.
|
||||
pub(super) fn forget_noted(&mut self, canceled: bool) {
|
||||
if let Some((attackers, directs)) = self.noted_since.take()
|
||||
&& canceled
|
||||
{
|
||||
self.pending.truncate(attackers);
|
||||
self.pending_direct.truncate(directs);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
|
||||
/// moving with it.
|
||||
@@ -197,18 +223,6 @@ impl Game<'_> {
|
||||
self.note_named_attacker(damage_type, mob, name);
|
||||
}
|
||||
}
|
||||
/// How many attackers wait for the damage they caused, for
|
||||
/// [`Game::drop_pending_since`].
|
||||
pub(super) fn pending_marks(&self) -> (usize, usize) {
|
||||
(self.combat.pending.len(), self.combat.pending_direct.len())
|
||||
}
|
||||
/// Forgets the attackers noted since `marks`: their hit didn't land (a
|
||||
/// native mod canceled it), so no combat entry takes them, and the next
|
||||
/// hit of the same damage type must not be credited to them.
|
||||
pub(super) fn drop_pending_since(&mut self, marks: (usize, usize)) {
|
||||
self.combat.pending.truncate(marks.0);
|
||||
self.combat.pending_direct.truncate(marks.1);
|
||||
}
|
||||
/// `note_attacker` for an entity that is not one of `self.mobs` (the
|
||||
/// ender dragon).
|
||||
pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) {
|
||||
@@ -223,6 +237,7 @@ impl Game<'_> {
|
||||
name: Text,
|
||||
item: Option<Text>,
|
||||
) {
|
||||
self.combat.mark();
|
||||
self.combat
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (id, name), item));
|
||||
@@ -231,6 +246,7 @@ impl Game<'_> {
|
||||
/// nothing caused it (a splash potion nobody threw): the death message
|
||||
/// names it instead (`getLocalizedDeathMessage`). It is no kill credit.
|
||||
pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) {
|
||||
self.combat.mark();
|
||||
self.combat
|
||||
.pending_direct
|
||||
.push((damage_type.to_owned(), name));
|
||||
@@ -314,6 +330,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.combat.pending.clear();
|
||||
self.combat.pending_direct.clear();
|
||||
self.combat.noted_since = None;
|
||||
self.combat.projectile_direct = None;
|
||||
self.combat.projectile_attacker = None;
|
||||
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
|
||||
@@ -741,7 +758,43 @@ mod held_item_tests {
|
||||
assert!(!has_custom_name(&gone));
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod noted_tests {
|
||||
use super::CombatTracker;
|
||||
use horizon_client::text::Text;
|
||||
|
||||
/// A canceled hit forgets what was noted for it, also before its hurt
|
||||
/// started (a PvP blow, `/damage`), and nothing a landed hit noted.
|
||||
#[test]
|
||||
fn a_canceled_hit_forgets_only_its_own_attackers() {
|
||||
let mut c = CombatTracker::default();
|
||||
let arrow = |c: &mut CombatTracker, id| {
|
||||
c.mark();
|
||||
c.pending.push((
|
||||
"minecraft:arrow".into(),
|
||||
(id, Text::literal("skeleton")),
|
||||
None,
|
||||
));
|
||||
};
|
||||
arrow(&mut c, 1);
|
||||
c.forget_noted(false);
|
||||
arrow(&mut c, 2);
|
||||
c.mark();
|
||||
c.pending_direct
|
||||
.push(("minecraft:arrow".into(), Text::literal("arrow")));
|
||||
c.forget_noted(true);
|
||||
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1]);
|
||||
assert!(c.pending_direct.is_empty());
|
||||
// A hit noted and landed, then one noted and canceled: the landed
|
||||
// one keeps its attacker.
|
||||
c.forget_noted(false);
|
||||
arrow(&mut c, 3);
|
||||
c.forget_noted(false);
|
||||
arrow(&mut c, 4);
|
||||
c.forget_noted(true);
|
||||
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1, 3]);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod stuck_tests {
|
||||
use super::Stuck;
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! system message it produced, as styled spans, with the results reported to
|
||||
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
|
||||
//! waits until the terrain and entities around the player are loaded;
|
||||
//! `portal` sends the player through the nether portal it stands in.
|
||||
//! `break` destroys a block as the server player; `portal` sends the player
|
||||
//! through the nether portal it stands in; `falling` counts physical falling
|
||||
//! blocks by dimension, block and gravity.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, text::Text};
|
||||
use serde_json::{Value, json};
|
||||
@@ -244,7 +246,36 @@ impl Game<'_> {
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline))?;
|
||||
}
|
||||
"break" => {
|
||||
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
|
||||
self.harness_owner(|g| g.break_block(pos))?;
|
||||
}
|
||||
"portal" => self.harness_portal()?,
|
||||
"falling" => {
|
||||
// Physical falling blocks are not command-selector targets
|
||||
// yet. Inspect the actual level rather than that unrelated gap.
|
||||
let dimension = op["dimension"].as_str().unwrap_or("minecraft:overworld");
|
||||
let block = op["block"].as_str().unwrap_or("minecraft:sand");
|
||||
let no_gravity = op["no_gravity"].as_bool().unwrap_or(false);
|
||||
let back = self.dim;
|
||||
self.enter_level(None, super::Dim::from_id(dimension))?;
|
||||
let facts = self.res.facts();
|
||||
let matches = |f: &super::FallingBlock| {
|
||||
&*facts.get(f.state).name == block && f.no_gravity == no_gravity
|
||||
};
|
||||
let count = self.falling.iter().filter(|f| matches(f)).count();
|
||||
if op["discard"].as_bool() == Some(true) {
|
||||
self.falling.retain(|f| !matches(f));
|
||||
}
|
||||
self.enter_level(None, back)?;
|
||||
let record = json!({
|
||||
"cmd": format!("#falling {dimension} {block} {no_gravity}"),
|
||||
"messages": [],
|
||||
"results": [[true, count]],
|
||||
});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
other => return Err(format!("command harness: unknown op {other}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod admin;
|
||||
pub(super) mod compute;
|
||||
mod data;
|
||||
pub(super) use data::place_summoned;
|
||||
mod effects;
|
||||
pub(super) mod execute;
|
||||
pub(super) mod extra;
|
||||
@@ -76,6 +77,11 @@ pub(in crate::game) struct OtherEntity {
|
||||
pub(super) fn err(key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
|
||||
CommandError::tr(key, args)
|
||||
}
|
||||
/// `EntityType.getDescriptionId`: `entity.<namespace>.<path>`.
|
||||
fn entity_name_key(id: &str) -> String {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
format!("entity.{namespace}.{}", path.replace('/', "."))
|
||||
}
|
||||
fn texts(args: &[Text]) -> Vec<Text> {
|
||||
args.to_vec()
|
||||
}
|
||||
@@ -1227,21 +1233,11 @@ impl Game<'_> {
|
||||
static RARITY: std::sync::OnceLock<std::collections::HashMap<String, String>> =
|
||||
std::sync::OnceLock::new();
|
||||
let map = RARITY.get_or_init(|| {
|
||||
let v: serde_json::Value = serde_json::from_slice(
|
||||
&std::fs::read(self.options.resources.join("items.json")).unwrap_or_default(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
v.as_object()
|
||||
.map(|o| {
|
||||
o.iter()
|
||||
.filter_map(|(k, c)| {
|
||||
c["minecraft:rarity"]
|
||||
.as_str()
|
||||
.map(|r| (k.clone(), r.to_owned()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
horizon_world::item_defaults::read(&self.options.resources)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|(k, c)| c["minecraft:rarity"].as_str().map(|r| (k, r.to_owned())))
|
||||
.collect()
|
||||
});
|
||||
map.get(item).cloned().unwrap_or_else(|| "common".into())
|
||||
}
|
||||
@@ -1951,11 +1947,15 @@ impl Game<'_> {
|
||||
Target::Boat(id) => {
|
||||
// AbstractBoat.remove: a killed boat drops its lead.
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
self.boat_removed(&b);
|
||||
}
|
||||
}
|
||||
Target::Cart(id) => self.carts.retain(|c| c.id != id),
|
||||
Target::Cart(id) => {
|
||||
self.release_vehicle_container(id);
|
||||
self.carts.retain(|c| c.id != id);
|
||||
}
|
||||
Target::Other(id) => self.others.retain(|o| o.id != id),
|
||||
Target::Crystal(id) => self.kill_crystal(id),
|
||||
Target::Dragon(id) => self.kill_dragon(id),
|
||||
@@ -2019,18 +2019,18 @@ impl Game<'_> {
|
||||
&& self
|
||||
.res
|
||||
.mob_types()
|
||||
.get(&format!("minecraft:{short}"))
|
||||
.get(&kind)
|
||||
.is_some_and(|t| t.category == "monster")
|
||||
{
|
||||
return Err(err("commands.summon.failed.peaceful", &[]));
|
||||
}
|
||||
let before = self.spawned_mark();
|
||||
let name = match self.summon_with_nbt(&format!("minecraft:{short}"), pos, &nbt)? {
|
||||
let name = match self.summon_with_nbt(&kind, pos, &nbt)? {
|
||||
Some(target) => self.target_name(target),
|
||||
// Spawned as a kind no command target stands for (primed
|
||||
// TNT, falling blocks, projectiles): its type's name.
|
||||
None if self.spawned_mark() != before => {
|
||||
Text::translate(&format!("entity.minecraft.{short}"), &[])
|
||||
Text::translate(&entity_name_key(&kind), &[])
|
||||
}
|
||||
None => return Err(err("commands.summon.failed", &[])),
|
||||
};
|
||||
@@ -2038,17 +2038,12 @@ impl Game<'_> {
|
||||
return Ok(1);
|
||||
}
|
||||
let name;
|
||||
if let Some(t) = self
|
||||
.res
|
||||
.mob_types()
|
||||
.get(&format!("minecraft:{short}"))
|
||||
.cloned()
|
||||
{
|
||||
if let Some(t) = self.res.mob_types().get(&kind).cloned() {
|
||||
if self.ui.difficulty == 0 && t.category == "monster" {
|
||||
return Err(err("commands.summon.failed.peaceful", &[]));
|
||||
}
|
||||
self.spawn_mob_spawned(&short, pos);
|
||||
name = Text::translate(&format!("entity.minecraft.{short}"), &[]);
|
||||
name = Text::translate(&entity_name_key(&kind), &[]);
|
||||
} else {
|
||||
match short.as_str() {
|
||||
"item" => {
|
||||
@@ -2106,11 +2101,19 @@ impl Game<'_> {
|
||||
let cart_kind = super::minecarts::CartKind::from_id(&kind)
|
||||
.unwrap_or(super::minecarts::CartKind::Rideable);
|
||||
self.next_entity += 1;
|
||||
let cart = super::minecarts::Minecart::new(
|
||||
let mut cart = super::minecarts::Minecart::new(
|
||||
self.next_entity,
|
||||
cart_kind,
|
||||
Vec3::new(pos[0], pos[1], pos[2]),
|
||||
);
|
||||
// SummonCommand.createEntity loads even an empty compound, so a
|
||||
// spawner minecart's BaseSpawner.load runs: SpawnPotentials becomes
|
||||
// WeightedList.of(new SpawnData()).
|
||||
if cart.spawner.is_some() {
|
||||
use horizon_client::mobs::spawner::{Spawner, SpawnerExt};
|
||||
let empty = horizon_world::nbt::Tag::Compound(Default::default());
|
||||
cart.spawner = Some(Box::new(Spawner::load(&empty)));
|
||||
}
|
||||
self.carts.push(cart);
|
||||
name = Text::translate(&format!("entity.minecraft.{k}"), &[]);
|
||||
}
|
||||
@@ -2122,7 +2125,7 @@ impl Game<'_> {
|
||||
// Spawned as a kind no command target stands for
|
||||
// (primed TNT, falling blocks, projectiles).
|
||||
None if self.spawned_mark() != before => {
|
||||
Text::translate(&format!("entity.minecraft.{short}"), &[])
|
||||
Text::translate(&entity_name_key(&kind), &[])
|
||||
}
|
||||
None => return Err(err("commands.summon.failed", &[])),
|
||||
};
|
||||
@@ -2236,6 +2239,10 @@ impl Game<'_> {
|
||||
return Ok(0);
|
||||
};
|
||||
let state = self.block_state(&b)?;
|
||||
// SetBlockCommand.setBlock: `ServerLevel.isDebug` fails it.
|
||||
if self.world_debug {
|
||||
return Err(err("commands.setblock.failed", &[]));
|
||||
}
|
||||
let current = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if ctx.has("keep") && current != self.world.air {
|
||||
return Err(err("commands.setblock.failed", &[]));
|
||||
@@ -2438,6 +2445,10 @@ impl Game<'_> {
|
||||
if area > i64::from(limit) {
|
||||
return Err(err("commands.fill.toobig", &[&limit, &area]));
|
||||
}
|
||||
// FillCommand.fillBlocks: `ServerLevel.isDebug` fails it.
|
||||
if self.world_debug {
|
||||
return Err(err("commands.fill.failed", &[]));
|
||||
}
|
||||
let state = self.block_state(&block)?;
|
||||
let air = self.world.air;
|
||||
let (hollow, outline, destroy, strict, keep) = (
|
||||
@@ -2448,6 +2459,11 @@ impl Game<'_> {
|
||||
ctx.has("keep"),
|
||||
);
|
||||
let mut count = 0;
|
||||
// Whether the filter takes a state, worked out once per state: a fill visits up to 32768 blocks of a handful
|
||||
// of states, and matching a tag is a name lookup.
|
||||
let mut matched: std::collections::HashMap<BlockStateId, bool> =
|
||||
std::collections::HashMap::new();
|
||||
let plain = !(destroy || hollow || outline);
|
||||
for y in min[1]..=max[1] {
|
||||
for z in min[2]..=max[2] {
|
||||
for x in min[0]..=max[0] {
|
||||
@@ -2455,11 +2471,23 @@ impl Game<'_> {
|
||||
if keep && current != air {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = &filter
|
||||
&& !self.block_matches(f, current)
|
||||
{
|
||||
// Already the block it would become: nothing to place (place_state would do nothing either).
|
||||
if plain && current == state {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = &filter {
|
||||
let ok = match matched.get(¤t) {
|
||||
Some(&ok) => ok,
|
||||
None => {
|
||||
let ok = self.block_matches(f, current);
|
||||
matched.insert(current, ok);
|
||||
ok
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let edge = x == min[0]
|
||||
|| x == max[0]
|
||||
|| y == min[1]
|
||||
@@ -2518,6 +2546,10 @@ impl Game<'_> {
|
||||
return Err(err("argument.pos.unloaded", &[]));
|
||||
}
|
||||
}
|
||||
// CloneCommands.clone: the destination level being a Debug world fails it.
|
||||
if self.world_debug {
|
||||
return Err(err("commands.clone.failed", &[]));
|
||||
}
|
||||
let filter = match ctx.get("filter") {
|
||||
Some(Arg::Block(f)) => Some(f.clone()),
|
||||
_ => None,
|
||||
@@ -3554,6 +3586,20 @@ fn split_top_level(s: &str, sep: char) -> Vec<&str> {
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod summon_tests {
|
||||
#[test]
|
||||
fn a_summoned_entity_is_named_from_its_own_namespace() {
|
||||
use super::entity_name_key;
|
||||
assert_eq!(
|
||||
entity_name_key("minecraft:snowball"),
|
||||
"entity.minecraft.snowball"
|
||||
);
|
||||
assert_eq!(entity_name_key("ruby:bolt"), "entity.ruby.bolt");
|
||||
assert_eq!(entity_name_key("ruby:spell/bolt"), "entity.ruby.spell.bolt");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod look_at_tests {
|
||||
use super::{Target, non_player_entity_id};
|
||||
|
||||
@@ -28,6 +28,36 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
|
||||
c.insert(JavaString::from(key), value);
|
||||
}
|
||||
}
|
||||
/// `EntityType.loadEntityRecursive` snaps every entity of the stack,
|
||||
/// passengers included, to the position (`/summon`, `native_summon`).
|
||||
pub(in crate::game) fn place_summoned(tag: &mut Tag, pos: [f64; 3]) {
|
||||
let p = pos.map(|v| Tag::Double(v.to_bits()));
|
||||
set_key(tag, "Pos", Tag::List(p.to_vec()));
|
||||
// Entity.load: an absent Fire reads as 0 (until the entity moves).
|
||||
if tag.get("Fire").is_none() {
|
||||
set_key(tag, "Fire", Tag::Short(0));
|
||||
}
|
||||
if tag.get("Rotation").is_none() {
|
||||
set_key(
|
||||
tag,
|
||||
"Rotation",
|
||||
Tag::List(vec![
|
||||
Tag::Float(0.0f32.to_bits()),
|
||||
Tag::Float(0.0f32.to_bits()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
if let Tag::Compound(c) = tag
|
||||
&& let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into())
|
||||
{
|
||||
for r in riders {
|
||||
if matches!(r, Tag::Compound(_)) {
|
||||
place_summoned(r, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
// ---- entity data ---------------------------------------------------
|
||||
|
||||
@@ -762,36 +792,7 @@ impl Game<'_> {
|
||||
return Err(err("argument.nbt.expected.compound", &[]));
|
||||
}
|
||||
set_key(&mut tag, "id", Tag::String(kind.into()));
|
||||
// EntityType.loadEntityRecursive snaps every entity of the stack,
|
||||
// passengers included, to the position.
|
||||
fn place(tag: &mut Tag, pos: [f64; 3]) {
|
||||
let p = pos.map(|v| Tag::Double(v.to_bits()));
|
||||
set_key(tag, "Pos", Tag::List(p.to_vec()));
|
||||
// Entity.load: an absent Fire reads as 0 (until the entity moves).
|
||||
if tag.get("Fire").is_none() {
|
||||
set_key(tag, "Fire", Tag::Short(0));
|
||||
}
|
||||
if tag.get("Rotation").is_none() {
|
||||
set_key(
|
||||
tag,
|
||||
"Rotation",
|
||||
Tag::List(vec![
|
||||
Tag::Float(0.0f32.to_bits()),
|
||||
Tag::Float(0.0f32.to_bits()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
if let Tag::Compound(c) = tag
|
||||
&& let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into())
|
||||
{
|
||||
for r in riders {
|
||||
if matches!(r, Tag::Compound(_)) {
|
||||
place(r, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
place(&mut tag, pos);
|
||||
place_summoned(&mut tag, pos);
|
||||
let from = self.mobs.len();
|
||||
let canceled = self.persist.joins_canceled;
|
||||
let target = self.spawn_entity_from_nbt(tag);
|
||||
|
||||
@@ -186,10 +186,13 @@ impl Game<'_> {
|
||||
_ => Some(None),
|
||||
};
|
||||
self.for_player(target, |g| {
|
||||
if g.spectator() {
|
||||
return false;
|
||||
}
|
||||
if let Some(name) = named {
|
||||
g.note_direct_entity(&ty, name);
|
||||
}
|
||||
!g.spectator() && g.hurt_player_from(amount, from, &ty, attacker)
|
||||
g.hurt_player_from(amount, from, &ty, attacker)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -150,6 +150,69 @@ impl Game<'_> {
|
||||
let _ = request.reply.send(reply.to_string());
|
||||
}
|
||||
|
||||
/// Text typed at the open screen's text box by the control socket's
|
||||
/// `type`: its `\b` is a backspace sentinel for the boxes that have always
|
||||
/// taken one, not the characters `\` and `b`. `Err` when no text box of
|
||||
/// the screen takes it.
|
||||
pub(super) fn type_into_screen(&mut self, text: &str) -> Result<()> {
|
||||
if text == "\\b" {
|
||||
match self.screen {
|
||||
Screen::WorldOptions => {
|
||||
self.world_options_text(None);
|
||||
return Ok(());
|
||||
}
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text(None, true);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.type_chars_into_screen(text)
|
||||
}
|
||||
|
||||
/// Characters the open screen's focused text box takes: the control
|
||||
/// socket's `type` and a key press's surviving characters (`charTyped`).
|
||||
/// Nothing here is a key — the caller drops control characters. `Err` when
|
||||
/// no text box of the screen takes them.
|
||||
pub(super) fn type_chars_into_screen(&mut self, text: &str) -> Result<()> {
|
||||
if self.book.is_some() {
|
||||
self.book_type(text);
|
||||
return Ok(());
|
||||
}
|
||||
if self.sign_edit.is_some() {
|
||||
self.sign_input(None, Some(text));
|
||||
return Ok(());
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
Screen::BlockEditor => self.block_editor_type(text),
|
||||
Screen::DebugOptions => self.debug_options_type(text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
|
||||
// The port, or the game rules' search and number boxes.
|
||||
Screen::WorldOptions => self.world_options_text(Some(text)),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text(Some(text), false)
|
||||
}
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(text), false)
|
||||
}
|
||||
// The anvil's rename box, while it has an input to name.
|
||||
Screen::Inventory if self.station_typing() => self.station_text(Some(text), false),
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
// As typed: replaces a selected query, keeps the length limit.
|
||||
self.creative_search_edit(Some(text))
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn control_command(
|
||||
&mut self,
|
||||
line: &str,
|
||||
@@ -371,6 +434,17 @@ impl Game<'_> {
|
||||
}
|
||||
// Native mods see the key as the keyboard hook does.
|
||||
let action = arg(2)?;
|
||||
// ScreenEvent.KeyPressed.Pre: the open screen hears of the
|
||||
// press before it acts on it, as the window's key event hands
|
||||
// it over. A cancelled press goes no further — not to the
|
||||
// screens, not to `native_key` — and its release still comes.
|
||||
if action != "up" && self.screen_key_pressed(physical, action == "repeat") {
|
||||
if action == "tap" {
|
||||
self.physical_keys.remove(&physical);
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
self.native_key_event(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.native_key_event(physical, false, false);
|
||||
@@ -644,6 +718,7 @@ impl Game<'_> {
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.trim().into(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
});
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
@@ -674,48 +749,20 @@ impl Game<'_> {
|
||||
self.require_world()?;
|
||||
// Typed keys: the open screen's narration hears them.
|
||||
self.narration.keyboard_action();
|
||||
if self.book.is_some() {
|
||||
self.book_type(text);
|
||||
return ok();
|
||||
}
|
||||
if self.sign_edit.is_some() {
|
||||
self.sign_input(None, Some(text));
|
||||
return ok();
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
Screen::BlockEditor => self.block_editor_type(text),
|
||||
Screen::DebugOptions => self.debug_options_type(text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
|
||||
// The port, or the game rules' search and number boxes.
|
||||
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
|
||||
Screen::WorldOptions => self.world_options_text(Some(text)),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text((text != "\\b").then_some(text), text == "\\b")
|
||||
}
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(text), false)
|
||||
}
|
||||
// The anvil's rename box.
|
||||
Screen::Inventory
|
||||
if self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == super::stations::Kind::Anvil) =>
|
||||
{
|
||||
self.station_text(Some(text), false)
|
||||
}
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
// As typed: replaces a selected query, keeps the length limit.
|
||||
self.creative_search_edit(Some(text))
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// ScreenEvent.CharacterTyped.Pre: the open screen hears of
|
||||
// each character before a text box takes it, as the window's
|
||||
// key event fires it. A cancelled character is dropped, so
|
||||
// `type` types the survivors only.
|
||||
// `\b` is the socket's backspace sentinel, not two characters.
|
||||
let typed = if text == "\\b" {
|
||||
text.to_owned()
|
||||
} else {
|
||||
let Some(typed) = self.screen_text_typed(text) else {
|
||||
return ok();
|
||||
};
|
||||
typed
|
||||
};
|
||||
self.type_into_screen(&typed)?;
|
||||
ok()
|
||||
}
|
||||
"options" => {
|
||||
@@ -895,6 +942,15 @@ impl Game<'_> {
|
||||
);
|
||||
ok()
|
||||
}
|
||||
// A splitscreen player leaves splitscreen; only P1's game ends the process.
|
||||
"quit" if self.options.split_guest => {
|
||||
if self.remote.is_some() {
|
||||
self.leave_server(None)?;
|
||||
} else if let Some(title) = self.title.as_mut() {
|
||||
title.escape();
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"quit" => {
|
||||
self.begin_closing_save(true)?;
|
||||
ok()
|
||||
|
||||
@@ -89,6 +89,12 @@ pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub targets: Vec<(Who, Aabb)>,
|
||||
}
|
||||
impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
|
||||
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
|
||||
let state = self.w.world.block(p.x, p.y, p.z);
|
||||
(state, &self.w.facts.get(state).name)
|
||||
});
|
||||
}
|
||||
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: self.w.world,
|
||||
@@ -1060,7 +1066,14 @@ impl Game<'_> {
|
||||
bindings.retain(|id, _| live.contains(id));
|
||||
host.mob_players = bindings;
|
||||
}
|
||||
self.mobs.append(&mut mobs);
|
||||
// The mobs added during the tick (into the emptied `self.mobs`) come
|
||||
// first, as `append` put them; with none, the ticked list goes back
|
||||
// as it is instead of being copied into a new one, every mob a tick.
|
||||
if self.mobs.is_empty() {
|
||||
self.mobs = mobs;
|
||||
} else {
|
||||
self.mobs.append(&mut mobs);
|
||||
}
|
||||
self.died = died;
|
||||
// ... and entity event 60 goes out as it is: the poof.
|
||||
for (id, at, width, height) in gone {
|
||||
@@ -1201,7 +1214,16 @@ impl Game<'_> {
|
||||
self.villager_murdered(m, slain_by_player, killer.flatten());
|
||||
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
|
||||
let killer_entity = self.killer_loot_entity(killer.flatten());
|
||||
self.drop_death_loot(&mut dying, by_player, killer_entity, consumed, pos);
|
||||
// LAST_DAMAGE_PLAYER only while the player-hurt memory lasts
|
||||
// (LivingEntity.dropFromLootTable's playerKilled): a mob that dies to
|
||||
// the environment long after a player's hit names no player.
|
||||
let player = killer
|
||||
.filter(|_| m.ai.last_hurt_by_player > 0)
|
||||
.and_then(|conn| self.killer_uuid(conn));
|
||||
if let Some(player) = player.filter(|_| m.extra.leash.is_some()) {
|
||||
self.lead_killers.insert(m.id, (player, self.dim));
|
||||
}
|
||||
self.drop_death_loot(&mut dying, player, by_player, killer_entity, consumed, pos);
|
||||
self.create_wither_rose(m);
|
||||
if m.species() == Species::CopperGolem {
|
||||
// CopperGolem.dropEquipment: the preserved equipment too.
|
||||
@@ -1318,9 +1340,26 @@ impl Game<'_> {
|
||||
}
|
||||
own(self)
|
||||
}
|
||||
/// The UUID of the player `last_hurt_by_player` keeps: the guest on
|
||||
/// connection `conn` (None once they left), else the player in place
|
||||
/// (None on a server, which has none of its own).
|
||||
fn killer_uuid(&mut self, conn: Option<u32>) -> Option<u128> {
|
||||
match conn {
|
||||
// During that guest's own turn, the hub is out of `lan`.
|
||||
Some(conn) if self.lan_acting != Some(conn) => {
|
||||
Some(self.lan.as_ref()?.hub.guest(conn)?.uuid)
|
||||
}
|
||||
Some(_) => Some(self.native_acting_id()),
|
||||
None if self.lan_acting.is_none() && self.player.is_some() => {
|
||||
Some(self.native_acting_id())
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
fn drop_death_loot(
|
||||
&mut self,
|
||||
m: &mut Mob,
|
||||
player_uuid: Option<u128>,
|
||||
by_player: bool,
|
||||
killer: horizon_world::loot::LootEntity,
|
||||
consumed: bool,
|
||||
@@ -1403,22 +1442,49 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let raider = horizon_client::mobs::raider::is_raider(m.species());
|
||||
// The item drops come before the carried block and the orbs, so the
|
||||
// mods get them all at once and they still spawn in that order.
|
||||
let mut stacks = Vec::new();
|
||||
let mut rest = Vec::new();
|
||||
for d in items {
|
||||
match d {
|
||||
drops::Drop::Item(stack) => {
|
||||
let item = if raider && stack.item == horizon_client::mobs::raider::BANNER {
|
||||
drops::Drop::Item(stack) => stacks.push(
|
||||
if raider && stack.item == horizon_client::mobs::raider::BANNER {
|
||||
horizon_client::mobs::raider::ominous_banner()
|
||||
} else {
|
||||
from_loot(&stack)
|
||||
};
|
||||
// Every death drop is its own ItemEntity, and the drop
|
||||
// entity's own random (not the level's) gives it the
|
||||
// constructor's horizontal push (its motion otherwise
|
||||
// stays [0, 0.2, 0] and the drops land stacked at the
|
||||
// mob's centre, without disturbing the level random that
|
||||
// the orbs' merge ids and block ticks read next).
|
||||
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
},
|
||||
),
|
||||
drops::Drop::Stack(stack) => stacks.push(stack),
|
||||
other => rest.push(other),
|
||||
}
|
||||
}
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
let table = drops::death_loot_table(m, death.mob_drops).filter(|t| loot.has_table(t));
|
||||
let context = super::native_hooks::NativeLootDrops {
|
||||
table: table.as_deref().unwrap_or_default(),
|
||||
dimension: self.dim.id(),
|
||||
origin: [m.position.x, m.position.y, m.position.z],
|
||||
block: None,
|
||||
entity: Some((m.id, &kind)),
|
||||
player: player_uuid,
|
||||
tool: None,
|
||||
explosion: None,
|
||||
used: false,
|
||||
};
|
||||
self.native_loot_drops(&context, &mut stacks);
|
||||
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
|
||||
for item in stacks {
|
||||
// Every death drop is its own ItemEntity, and the drop entity's
|
||||
// own random (not the level's) gives it the constructor's
|
||||
// horizontal push (its motion otherwise stays [0, 0.2, 0] and
|
||||
// the drops land stacked at the mob's centre, without disturbing
|
||||
// the level random that the orbs' merge ids and block ticks read
|
||||
// next).
|
||||
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
for d in rest {
|
||||
match d {
|
||||
drops::Drop::CarriedBlock(state) => {
|
||||
// Enderman.dropCustomDeathLoot: spawnAtLocation for each
|
||||
// of the carried block's silk-touch drops.
|
||||
@@ -1434,7 +1500,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
|
||||
drops::Drop::Orb(_) => {}
|
||||
drops::Drop::Item(_) | drops::Drop::Stack(_) | drops::Drop::Orb(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1599,6 +1665,15 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
}
|
||||
// Projectile.setOwner: the acting player's or the mob's UUID, which
|
||||
// its save keeps even once the owner is gone.
|
||||
if p.owner_uuid.is_none() {
|
||||
p.owner_uuid = match p.owner {
|
||||
Some(Who::Player) => Some(self.acting_player_uuid()),
|
||||
Some(Who::Mob(id)) => Some(self.uuid_for(id)),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
@@ -2081,6 +2156,27 @@ impl Game<'_> {
|
||||
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
|
||||
// above trampled farmland breaks and drops.
|
||||
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
|
||||
MobEvent::CropRaided {
|
||||
pos,
|
||||
state,
|
||||
old,
|
||||
mob,
|
||||
} => {
|
||||
self.level_set_with_flags(
|
||||
pos,
|
||||
state,
|
||||
horizon_world::level::flags::UPDATE_CLIENTS,
|
||||
)?;
|
||||
if let Some(old) = old {
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
pos,
|
||||
Some(Who::Mob(mob)),
|
||||
None,
|
||||
);
|
||||
self.level_event_at(2001, pos, old.0 as i32);
|
||||
}
|
||||
}
|
||||
MobEvent::CrackTurtleEgg { pos } => {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let eggs = self
|
||||
@@ -2218,13 +2314,14 @@ impl Game<'_> {
|
||||
self.destroy_particles(pos, old);
|
||||
}
|
||||
if drop {
|
||||
let drops = world_items!(self).block_drops_in(
|
||||
let mut drops = world_items!(self).block_drops_in(
|
||||
self.pack.assets.registry,
|
||||
old,
|
||||
None,
|
||||
&mut self.world.random,
|
||||
Some((&mut self.rand_sequences, self.level.seed)),
|
||||
);
|
||||
self.native_block_drops(pos, old, None, None, None, &mut drops);
|
||||
for d in drops {
|
||||
self.pop_resource(pos, d);
|
||||
}
|
||||
@@ -2587,7 +2684,7 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
feet: [f64; 3],
|
||||
conn: Option<u32>,
|
||||
bindings: &std::collections::HashMap<u32, u32>,
|
||||
bindings: &horizon_client::fast_hash::FxHashMap<u32, u32>,
|
||||
) {
|
||||
// A client of someone else's world has the server's mobs only to show.
|
||||
if self.remote.is_some() || !self.ui.rules.bool("forgive_dead_players") {
|
||||
@@ -2735,6 +2832,15 @@ impl Game<'_> {
|
||||
if p.species() == Species::Fox {
|
||||
child.extra.main_hand = None;
|
||||
}
|
||||
// Fox.FoxBreedGoal.breed: the baby trusts the players who fed its
|
||||
// parents into love.
|
||||
if p.species() == Species::Fox {
|
||||
horizon_client::mobs::fox::trust_breeders(
|
||||
child,
|
||||
p.extra.love_cause,
|
||||
q.as_ref().and_then(|q| q.extra.love_cause),
|
||||
);
|
||||
}
|
||||
// Goat.getBreedOffspring: GoatAi.initMemories, then the baby
|
||||
// screams if the parent a coin toss picks does, or on a 2% roll.
|
||||
if p.species() == Species::Goat {
|
||||
@@ -2966,6 +3072,7 @@ impl Game<'_> {
|
||||
let rocket = std::mem::take(&mut self.combat.rocket_hit);
|
||||
// ServerPlayer.canHarmPlayer refused another player's projectile.
|
||||
if self.creative || self.combat.refuse_player_hit {
|
||||
self.combat.forget_noted(false);
|
||||
return false;
|
||||
}
|
||||
// A projectile entity dealt it: `#is_projectile`, or a firework
|
||||
@@ -2999,7 +3106,6 @@ impl Game<'_> {
|
||||
}),
|
||||
};
|
||||
// The causing entity for the death message.
|
||||
let marks = self.pending_marks();
|
||||
let owner = self.combat.projectile_owner.take();
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
self.note_attacker(damage_type, a);
|
||||
@@ -3067,9 +3173,7 @@ impl Game<'_> {
|
||||
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
|
||||
// early (invulnerable, or inside the hurt cooldown).
|
||||
// A hit a native mod canceled records no damage (hurtServer never ran).
|
||||
if outcome.canceled {
|
||||
self.drop_pending_since(marks);
|
||||
}
|
||||
self.combat.forget_noted(outcome.canceled);
|
||||
if outcome.hurt || outcome.sound {
|
||||
let source = attacker
|
||||
.or(owner)
|
||||
@@ -3096,7 +3200,7 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
if raised {
|
||||
self.after_block(outcome, amount, attacker, projectile);
|
||||
self.after_block(outcome, attacker, projectile);
|
||||
}
|
||||
outcome.hurt
|
||||
}
|
||||
@@ -3152,14 +3256,21 @@ impl Game<'_> {
|
||||
self.give_player_effect("minecraft:wither", 40, 0);
|
||||
}
|
||||
pub(super) fn hurt_player_by(&mut self, damage_type: &str, amount: f32) {
|
||||
if self.creative {
|
||||
return;
|
||||
}
|
||||
if let Some(player) = self.player.as_mut()
|
||||
let mut canceled = false;
|
||||
if !self.creative
|
||||
&& let Some(player) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(damage_type)
|
||||
&& let Ok(o) = player.hurt_blocking(
|
||||
&self.motion.health,
|
||||
self.world.health_rules,
|
||||
id,
|
||||
amount,
|
||||
None,
|
||||
)
|
||||
{
|
||||
let _ = player.hurt(&self.motion.health, self.world.health_rules, id, amount);
|
||||
canceled = o.canceled;
|
||||
}
|
||||
self.combat.forget_noted(canceled);
|
||||
}
|
||||
/// `Player.aiStep` touching arrows in its box grown by (1, 0.5, 1):
|
||||
/// `AbstractArrow.playerTouch`, then `Player.take`'s pickup sound at the
|
||||
@@ -3411,9 +3522,10 @@ impl Game<'_> {
|
||||
} = hit
|
||||
&& let Some(j) = wind_charge_hit(id)
|
||||
{
|
||||
let (owner, guest) = (p.owner, p.guest);
|
||||
let (owner, owner_uuid, guest) = (p.owner, p.owner_uuid, p.guest);
|
||||
let look = self.owner_look(owner, guest);
|
||||
super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest);
|
||||
let wind = &mut projectiles[j];
|
||||
super::deflect::aim_deflect(wind, look, owner, owner_uuid, guest);
|
||||
}
|
||||
}
|
||||
let mut idx = 0;
|
||||
@@ -3564,6 +3676,7 @@ impl Game<'_> {
|
||||
ProjectileKind::Arrow { .. }
|
||||
| ProjectileKind::Trident
|
||||
| ProjectileKind::Snowball
|
||||
| ProjectileKind::Thrown(_)
|
||||
| ProjectileKind::Egg
|
||||
| ProjectileKind::SmallFireball
|
||||
| ProjectileKind::LargeFireball { .. }
|
||||
@@ -3675,7 +3788,7 @@ impl Game<'_> {
|
||||
}
|
||||
ArrowEvent::Block { pos, reaction } => match reaction {
|
||||
BlockReaction::PrimeTnt => {
|
||||
self.spawn_primed_tnt(pos, false);
|
||||
self.spawn_primed_tnt(pos, false, None, None);
|
||||
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
@@ -3860,6 +3973,7 @@ impl Game<'_> {
|
||||
let amount = match p.kind {
|
||||
ProjectileKind::SmallFireball => 5.0,
|
||||
ProjectileKind::LargeFireball { .. } => 6.0,
|
||||
ProjectileKind::Thrown(t) => t.damage,
|
||||
_ => 0.0,
|
||||
};
|
||||
let kind = match p.kind {
|
||||
@@ -3880,6 +3994,7 @@ impl Game<'_> {
|
||||
ProjectileKind::EnderPearl
|
||||
| ProjectileKind::Egg
|
||||
| ProjectileKind::Snowball
|
||||
| ProjectileKind::Thrown(_)
|
||||
| ProjectileKind::LargeFireball { .. }
|
||||
) {
|
||||
return Ok(());
|
||||
@@ -4133,6 +4248,47 @@ impl Game<'_> {
|
||||
self.thrown_break_event(p.id, atp);
|
||||
self.item_burst(atp, "minecraft:snowball");
|
||||
}
|
||||
// a mod's copy of Snowball: its damage to anything it hits.
|
||||
(ProjectileKind::Thrown(t), hit) => {
|
||||
match hit {
|
||||
Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
} => {
|
||||
let push = [p.velocity.x, p.velocity.z];
|
||||
let hurt = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id)
|
||||
.is_some_and(|m| m.damage_projectile(t.damage, source, aim, push));
|
||||
if hurt {
|
||||
self.player_hurt_mob(p, id);
|
||||
}
|
||||
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
|
||||
if hurt
|
||||
&& p.owner == Some(Who::Player)
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
{
|
||||
let victim = self.mob_entity(m);
|
||||
let direct = horizon_client::advancements::EntityView {
|
||||
kind: t.id.into(),
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
self.player_hit(&victim, "minecraft:thrown", Some(direct), t.damage);
|
||||
}
|
||||
}
|
||||
// Player.hurtServer takes no 0 damage.
|
||||
Hit::Entity {
|
||||
who: Who::Player, ..
|
||||
} if t.damage > 0.0 => {
|
||||
let from = [p.previous.x, p.previous.y, p.previous.z];
|
||||
self.mob_hits_player(t.damage, from, "minecraft:thrown");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.thrown_break_event(p.id, atp);
|
||||
self.item_burst(atp, t.item);
|
||||
}
|
||||
(ProjectileKind::Egg, hit) => {
|
||||
if let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
@@ -4450,7 +4606,7 @@ impl Game<'_> {
|
||||
/// client entity's random (the particle engine's here) for an egg.
|
||||
pub(super) fn item_burst(&mut self, at: [f64; 3], item: &str) {
|
||||
let options = self.item_particle(&horizon_client::items::ItemStack::new(item, 1));
|
||||
let egg = item != "minecraft:snowball";
|
||||
let egg = item == "minecraft:egg";
|
||||
for _ in 0..8 {
|
||||
let v = if egg {
|
||||
let r = self.particles.random();
|
||||
@@ -4739,6 +4895,9 @@ impl Game<'_> {
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
let mut handled = true;
|
||||
let mut drops: Vec<(String, u32)> = Vec::new();
|
||||
// The shearing table rolled, for the mods (none for a mooshroom).
|
||||
let mut drops_table = String::new();
|
||||
let mob = (m.id, super::native_hooks::mob_kind(m));
|
||||
// `Sheep.shear`'s drops add a spread on top of the ItemEntity's own
|
||||
// motion; `MushroomCow.shear`'s do not.
|
||||
let mut wool = false;
|
||||
@@ -4828,8 +4987,9 @@ impl Game<'_> {
|
||||
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
|
||||
m.extra.sheared = true;
|
||||
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
|
||||
drops_table = format!("minecraft:shearing/sheep/{color}");
|
||||
let stacks = world_items!(self).loot_table(
|
||||
&format!("minecraft:shearing/sheep/{color}"),
|
||||
&drops_table,
|
||||
false,
|
||||
None,
|
||||
&mut self.world.random,
|
||||
@@ -5214,7 +5374,7 @@ impl Game<'_> {
|
||||
{
|
||||
// Animal.mobInteract: breed adults, speed up babies, then
|
||||
// playEatingSound (silent unless the species overrides it).
|
||||
match feed_animal(m, &mut self.world.random) {
|
||||
match feed_animal(m, &mut self.world.random, Some(acting)) {
|
||||
Some(sound) => {
|
||||
consume = true;
|
||||
eat = sound;
|
||||
@@ -5268,6 +5428,7 @@ impl Game<'_> {
|
||||
spread,
|
||||
speed: 0.2,
|
||||
count: count as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
}
|
||||
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
|
||||
@@ -5329,8 +5490,22 @@ impl Game<'_> {
|
||||
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
|
||||
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
|
||||
}
|
||||
for (item, count) in drops {
|
||||
for _ in 0..count {
|
||||
let mut drops: Vec<ItemStack> = drops
|
||||
.into_iter()
|
||||
.map(|(item, count)| ItemStack::new(&item, count))
|
||||
.collect();
|
||||
if !drops.is_empty() {
|
||||
self.native_use_drops(
|
||||
&drops_table,
|
||||
feet,
|
||||
None,
|
||||
Some((mob.0, &mob.1)),
|
||||
held.as_ref(),
|
||||
&mut drops,
|
||||
);
|
||||
}
|
||||
for stack in drops {
|
||||
for _ in 0..stack.count {
|
||||
// `spawnAtLocation(stack, 1.0)`: an ItemEntity a block above
|
||||
// the feet that starts with the constructor's own motion, and
|
||||
// `setDefaultPickUpDelay`. Sheep.shear adds its spread on top;
|
||||
@@ -5348,10 +5523,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
item: item.as_str().into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
..stack.clone()
|
||||
},
|
||||
[feet[0], feet[1] + 1.0, feet[2]],
|
||||
v,
|
||||
@@ -5383,10 +5556,21 @@ impl Game<'_> {
|
||||
_ => return false,
|
||||
};
|
||||
let at = m.position;
|
||||
let mob = (m.id, super::native_hooks::mob_kind(m));
|
||||
let tool = self.held().cloned();
|
||||
self.audio.play(sound, Some([at.x, at.y, at.z]), 1.0, 1.0);
|
||||
self.mobs[index].extra.sheared = true;
|
||||
for stack in self.sequence_loot(table, table, tool.as_ref()) {
|
||||
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
|
||||
let origin = [at.x, at.y, at.z];
|
||||
self.native_use_drops(
|
||||
table,
|
||||
origin,
|
||||
None,
|
||||
Some((mob.0, &mob.1)),
|
||||
tool.as_ref(),
|
||||
&mut stacks,
|
||||
);
|
||||
for stack in stacks {
|
||||
self.spawn_at_location(at, height, stack);
|
||||
}
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
@@ -5409,7 +5593,17 @@ impl Game<'_> {
|
||||
let feet = [at.x, at.y, at.z];
|
||||
let tool = self.held().cloned();
|
||||
let table = "minecraft:brush/armadillo";
|
||||
for d in self.sequence_loot(table, table, tool.as_ref()) {
|
||||
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
|
||||
let kind = super::native_hooks::mob_kind(&self.mobs[index]);
|
||||
self.native_use_drops(
|
||||
table,
|
||||
feet,
|
||||
None,
|
||||
Some((id, &kind)),
|
||||
tool.as_ref(),
|
||||
&mut stacks,
|
||||
);
|
||||
for d in stacks {
|
||||
self.spawn_at_location(at, 0.0, d);
|
||||
}
|
||||
self.audio
|
||||
@@ -5450,7 +5644,10 @@ impl Game<'_> {
|
||||
return Ok(thrown);
|
||||
}
|
||||
_ if item.ends_with("_spawn_egg") => return self.use_spawn_egg(stack),
|
||||
_ => return Ok(false),
|
||||
_ => match horizon_client::projectiles::thrown_by_item(item) {
|
||||
Some(t) => ProjectileKind::Thrown(t),
|
||||
None => return Ok(false),
|
||||
},
|
||||
};
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return Ok(false);
|
||||
@@ -5480,7 +5677,9 @@ impl Game<'_> {
|
||||
// second is the `use_cooldown` component, as for any thrown item.
|
||||
self.apply_use_cooldown(stack);
|
||||
let sound = match kind {
|
||||
ProjectileKind::Snowball => "minecraft:entity.snowball.throw",
|
||||
ProjectileKind::Snowball | ProjectileKind::Thrown(_) => {
|
||||
"minecraft:entity.snowball.throw"
|
||||
}
|
||||
ProjectileKind::Egg => "minecraft:entity.egg.throw",
|
||||
_ => "minecraft:entity.ender_pearl.throw",
|
||||
};
|
||||
@@ -5528,6 +5727,15 @@ impl Game<'_> {
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
|
||||
}
|
||||
// `Mob.checkAndHandleImportantInteractions` then calls
|
||||
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
|
||||
// who used the egg (a fox egg used on a block trusts nobody).
|
||||
if species == Species::Fox {
|
||||
let spawner = self.acting_player_uuid();
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
|
||||
}
|
||||
}
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
|
||||
self.consume_held();
|
||||
true
|
||||
@@ -5693,6 +5901,7 @@ impl Game<'_> {
|
||||
self.rendered_mobs.set(0);
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
let outline = self.mob_outline(mob);
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
|
||||
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
|
||||
@@ -5867,6 +6076,8 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// What follows isn't a tracked mob: close the last one's span.
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.elder_apparition_geometry(alpha, out);
|
||||
self.spawner_geometry(camera, alpha, out);
|
||||
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
|
||||
@@ -6080,8 +6291,8 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(stack) = a.potion_item() {
|
||||
let owner = super::item_draw::owner_at(at, 0.0, "minecraft:splash_potion");
|
||||
if let Some(stack) = a.drawn_item() {
|
||||
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
|
||||
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
|
||||
let pose = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_quat(self.camera_orientation());
|
||||
|
||||
@@ -89,10 +89,8 @@ impl Game<'_> {
|
||||
}
|
||||
if stack.data().is_some_and(|d| !d.charged.is_empty()) {
|
||||
self.shoot_crossbow();
|
||||
} else if (self.projectile_slot(CROSSBOW).is_some() || self.creative)
|
||||
&& self.using.is_none()
|
||||
{
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
} else if self.projectile_slot(CROSSBOW).is_some() || self.creative {
|
||||
self.start_using_item();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ impl Game<'_> {
|
||||
let p = c.position;
|
||||
self.world.modified.insert((c.pos[0] >> 4, c.pos[2] >> 4));
|
||||
if self.riding == Some(c.id) {
|
||||
self.riding = None;
|
||||
self.dismount_removed_vehicle(p.y);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.break",
|
||||
@@ -468,6 +468,49 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
/// `LivingEntity.dismountVehicle` from a removed vehicle (a broken
|
||||
/// cushion) whose position was at `vehicle_y`: up to the higher of the
|
||||
/// rider's and the vehicle's heights, then the free spot nearest there
|
||||
/// (`findFreePosition` within the rider's own box). No get-up sound:
|
||||
/// `Cushion.removePassenger` plays none once the cushion is removed.
|
||||
pub(super) fn dismount_removed_vehicle(&mut self, vehicle_y: f64) {
|
||||
self.riding = None;
|
||||
let border = self.border_bounds();
|
||||
let b = self.res.behaviours();
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = (
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
let mut at = Vec3::new(p.position.x, p.position.y.max(vehicle_y), p.position.z);
|
||||
// `getBbWidth() <= 4 && getBbHeight() <= 4`.
|
||||
if w <= 4.0 && h <= 4.0 {
|
||||
let center = at.add_vector(Vec3::new(0.0, h / 2.0, 0.0));
|
||||
let size = Vec3::new(w, h, w);
|
||||
let free = horizon_world::level::portal_travel::find_free_position(
|
||||
Aabb::of_size(center, size),
|
||||
center,
|
||||
size,
|
||||
&border,
|
||||
|q| {
|
||||
b.collision_shape(self.world.block(q.x, q.y, q.z))
|
||||
.map(|s| s.moved(Vec3::new(f64::from(q.x), f64::from(q.y), f64::from(q.z))))
|
||||
},
|
||||
);
|
||||
if let Some(free) = free {
|
||||
at = free.add_vector(Vec3::new(0.0, -h / 2.0, 0.0));
|
||||
}
|
||||
}
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
|
||||
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
|
||||
let Some(c) = self
|
||||
|
||||
@@ -954,6 +954,20 @@ impl Game<'_> {
|
||||
let p = c.position;
|
||||
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
|
||||
}
|
||||
// Interaction: EntityDimensions.scalable(width, height), so its eyes
|
||||
// are at 0.85 of the height. Its rotation is not kept here.
|
||||
for i in self.interactions.values() {
|
||||
let b = i.bounds();
|
||||
let p = i.position;
|
||||
g.entity_box(
|
||||
[b.min(Axis::X), b.min(Axis::Y), b.min(Axis::Z)],
|
||||
[b.max(Axis::X), b.max(Axis::Y), b.max(Axis::Z)],
|
||||
[p.x, p.y, p.z],
|
||||
i.height.max(0.0) * 0.85,
|
||||
false,
|
||||
(0.0, 0.0),
|
||||
);
|
||||
}
|
||||
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
|
||||
// ArmorStand.getDimensions: a marker has none, a small one is
|
||||
// half scale with its eyes at 0.9875.
|
||||
@@ -962,8 +976,13 @@ impl Game<'_> {
|
||||
(_, true) => ((0.25, 0.9875), 0.9875),
|
||||
_ => ((0.5, 1.975), 1.7775),
|
||||
};
|
||||
let p = a.position;
|
||||
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
|
||||
g.hitbox(
|
||||
at(a.previous, a.position),
|
||||
size,
|
||||
eye,
|
||||
true,
|
||||
(0.0, a.render_yaw(alpha)),
|
||||
);
|
||||
}
|
||||
for b in &self.boats {
|
||||
let h = super::boats::HEIGHT as f32;
|
||||
@@ -1023,9 +1042,12 @@ impl Game<'_> {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
let (size, eye) = match p.kind {
|
||||
K::Arrow { .. } | K::Trident => (0.5, 0.13),
|
||||
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
|
||||
(0.25, 0.2125)
|
||||
}
|
||||
K::Snowball
|
||||
| K::Egg
|
||||
| K::EnderPearl
|
||||
| K::LlamaSpit
|
||||
| K::Potion { .. }
|
||||
| K::Thrown(_) => (0.25, 0.2125),
|
||||
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
|
||||
(0.3125, 0.265_625)
|
||||
}
|
||||
|
||||
@@ -1583,7 +1583,11 @@ impl Game<'_> {
|
||||
_ => {
|
||||
// The game's dispatcher, captured for the console.
|
||||
self.chat_capture = Some(Vec::new());
|
||||
self.as_console(|g| g.run_command(line));
|
||||
// A command a native mod registered goes to the mods first,
|
||||
// looked up by the same first word the match above read.
|
||||
if !self.perform_native_console_command(line.trim()) {
|
||||
self.as_console(|g| g.run_command(line));
|
||||
}
|
||||
for text in self.chat_capture.take().unwrap_or_default() {
|
||||
self.console_reply(text);
|
||||
}
|
||||
|
||||
@@ -34,14 +34,14 @@ pub(super) fn aim_deflect(
|
||||
p: &mut Projectile,
|
||||
look: Option<Vec3>,
|
||||
owner: Option<Who>,
|
||||
owner_uuid: Option<[i32; 4]>,
|
||||
guest: Option<u32>,
|
||||
) {
|
||||
if let Some(look) = look {
|
||||
p.velocity = look;
|
||||
p.needs_sync = true;
|
||||
}
|
||||
p.owner = owner;
|
||||
p.guest = guest;
|
||||
(p.owner, p.owner_uuid, p.guest) = (owner, owner_uuid, guest);
|
||||
p.acceleration = 0.1;
|
||||
}
|
||||
|
||||
@@ -196,14 +196,14 @@ impl Game<'_> {
|
||||
/// `Projectile.deflect(AIM_DEFLECT, player, player, true)`.
|
||||
pub(super) fn deflect(&mut self, i: usize) {
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let guest = self.lan_acting;
|
||||
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
|
||||
let p = &mut self.creatures.projectiles[i];
|
||||
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
|
||||
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
|
||||
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
|
||||
p.owner = Some(Who::Player);
|
||||
// On a server, the guest who struck it (their projectile now).
|
||||
p.guest = guest;
|
||||
(p.guest, p.owner_uuid) = (guest, owner_uuid);
|
||||
// Not into the one who turned it on its way out.
|
||||
p.left_owner = false;
|
||||
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
|
||||
@@ -274,9 +274,9 @@ mod tests {
|
||||
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
|
||||
let mut p = wind_charge();
|
||||
let look = Vec3::new(0.0, -1.0, 0.0);
|
||||
aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3));
|
||||
aim_deflect(&mut p, Some(look), Some(Who::Player), Some([4; 4]), Some(3));
|
||||
assert_eq!(p.velocity, look);
|
||||
assert_eq!(p.owner, Some(Who::Player));
|
||||
assert_eq!((p.owner, p.owner_uuid), (Some(Who::Player), Some([4; 4])));
|
||||
assert_eq!(p.guest, Some(3));
|
||||
assert_eq!(p.acceleration, 0.1);
|
||||
assert!(p.needs_sync);
|
||||
@@ -285,9 +285,10 @@ mod tests {
|
||||
#[test]
|
||||
fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() {
|
||||
let mut p = wind_charge();
|
||||
aim_deflect(&mut p, None, None, None);
|
||||
p.owner_uuid = Some([7; 4]);
|
||||
aim_deflect(&mut p, None, None, None, None);
|
||||
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
|
||||
assert_eq!(p.owner, None);
|
||||
assert_eq!((p.owner, p.owner_uuid), (None, None));
|
||||
assert_eq!(p.acceleration, 0.1);
|
||||
assert!(!p.needs_sync);
|
||||
}
|
||||
|
||||
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user