mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into #5685: uiSoundReserve beside main's splitscreen extra
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
40704247c7
206 files changed
+10283
-1160
No files matched your search
Generated
+1
@@ -1619,6 +1619,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"naga",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -611,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.
|
||||
|
||||
@@ -116,6 +116,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 {
|
||||
@@ -150,6 +152,7 @@ impl Default for Extras {
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +229,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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -233,7 +237,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:{}\nuiSoundReserve:{}\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:{}\nuiSoundReserve:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -269,6 +273,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
|
||||
@@ -400,6 +405,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>> {
|
||||
@@ -436,6 +443,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.
|
||||
@@ -446,7 +454,7 @@ impl Item {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
| AgentControl | DiscordPresence | Splitscreen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -485,6 +493,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."
|
||||
}
|
||||
@@ -497,9 +508,11 @@ impl Item {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::UiSoundReserve => 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,
|
||||
}
|
||||
}
|
||||
@@ -517,6 +530,7 @@ impl Item {
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -545,6 +559,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 => {}
|
||||
}
|
||||
}
|
||||
@@ -597,6 +612,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::*;
|
||||
@@ -88,6 +88,7 @@ mod frame_gen;
|
||||
mod hdr;
|
||||
mod hdr_stats;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod block_light;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod native_material;
|
||||
@@ -412,6 +413,9 @@ pub struct Frame<'a> {
|
||||
pub additive: &'a [Vertex],
|
||||
/// Name tags over everything, without depth (`TEXT_SEE_THROUGH`).
|
||||
pub see_through: &'a [Vertex],
|
||||
/// Opaque entity quads only a native pack's shadow pass draws (the local player in first
|
||||
/// person).
|
||||
pub shadow_only: &'a [Vertex],
|
||||
/// Camera-relative sun and moon, or the animated End flash (additive,
|
||||
/// behind everything and after the resident End sky box).
|
||||
pub celestial: &'a [Vertex],
|
||||
@@ -599,6 +603,8 @@ pub struct Renderer {
|
||||
screen_buffer: wgpu::Buffer,
|
||||
screen_capacity: u64,
|
||||
entity_group: wgpu::BindGroup,
|
||||
/// The layout of `entity_group`, for the native shadow pass.
|
||||
entity_bindings: wgpu::BindGroupLayout,
|
||||
/// The entity resources with the hand's projection (`hand_globals`).
|
||||
hand_group: wgpu::BindGroup,
|
||||
hand_globals: wgpu::Buffer,
|
||||
@@ -607,6 +613,8 @@ pub struct Renderer {
|
||||
entity_batch: EntityBatch,
|
||||
group: wgpu::BindGroup,
|
||||
globals: wgpu::Buffer,
|
||||
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
|
||||
pbr_maps: Option<[wgpu::Texture; 2]>,
|
||||
lightmap: wgpu::Texture,
|
||||
/// The block sprites, `sprite_split` layers per array (the first is
|
||||
/// `sprites` in terrain.wgsl, the others `sprites_1`...).
|
||||
@@ -699,6 +707,9 @@ pub struct Renderer {
|
||||
section_uploaded: crate::fast_hash::FxHashMap<SectionKey, std::time::Instant>,
|
||||
/// Changes whenever `block_entities` does (unique across renderers).
|
||||
block_entity_revision: u64,
|
||||
/// The light sources of each section and their revision, for `block_light`.
|
||||
light_blocks: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
light_revision: u64,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// The column `occlusion`, `sections` and `detail_columns` count in: the
|
||||
@@ -924,6 +935,37 @@ pub struct PreparedTextures {
|
||||
/// The sprite layers per array `sprites` is cut by.
|
||||
split: u32,
|
||||
forcefield: (Vec<u8>, u32),
|
||||
/// The LabPBR maps' mip chains (normals, specular), their levels, layers and size.
|
||||
pbr: Option<(Vec<u8>, Vec<u8>, u32, u32, u32)>,
|
||||
}
|
||||
/// Every level of `layers` square RGBA layers of `size`, each the mean of four texels of the
|
||||
/// one above, layer major: the LabPBR maps' mips (their channels are data, not colour).
|
||||
fn box_mip_chain(pixels: &[u8], size: u32, layers: u32) -> Vec<u8> {
|
||||
let layer_bytes = (size * size * 4) as usize;
|
||||
let mut out = Vec::new();
|
||||
for layer in pixels.chunks_exact(layer_bytes).take(layers as usize) {
|
||||
let mut level = layer.to_vec();
|
||||
let mut side = size as usize;
|
||||
out.extend_from_slice(&level);
|
||||
while side > 1 {
|
||||
let half = side / 2;
|
||||
let mut next = Vec::with_capacity(half * half * 4);
|
||||
for y in 0..half {
|
||||
for x in 0..half {
|
||||
for c in 0..4 {
|
||||
let at = |dx: usize, dy: usize| {
|
||||
u32::from(level[((2 * y + dy) * side + 2 * x + dx) * 4 + c])
|
||||
};
|
||||
next.push(((at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1) + 2) / 4) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&next);
|
||||
level = next;
|
||||
side = half;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
impl PreparedTextures {
|
||||
pub fn new(
|
||||
@@ -956,9 +998,25 @@ impl PreparedTextures {
|
||||
.collect(),
|
||||
split,
|
||||
forcefield: mip_chain(forcefield.0, forcefield.1, &[], 0, 1),
|
||||
pbr: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// With the resource packs' LabPBR `normals` and `specular` maps (`layers` layers of
|
||||
/// `size`, as `RenderAssets` holds them; nothing when empty).
|
||||
pub fn with_pbr(mut self, normals: &[u8], specular: &[u8], size: u32, layers: u32) -> Self {
|
||||
if !normals.is_empty() {
|
||||
self.pbr = Some((
|
||||
box_mip_chain(normals, size, layers),
|
||||
box_mip_chain(specular, size, layers),
|
||||
size.trailing_zeros() + 1,
|
||||
layers,
|
||||
size,
|
||||
));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// The same mip chains cut by `split` layers per array: chains made before
|
||||
/// the adapter was known, for a GPU with fewer layers per array.
|
||||
fn resplit(mut self, layers: u32, split: u32) -> Self {
|
||||
@@ -1923,6 +1981,40 @@ impl Renderer {
|
||||
};
|
||||
let mut mip_levels = 1;
|
||||
let gl = adapter.get_info().backend == wgpu::Backend::Gl;
|
||||
// The resource packs' LabPBR maps, for native shader packs: one array each, cut to
|
||||
// the layers the GPU has (`block_normal_map` gives the default past them).
|
||||
let pbr_maps = prepared
|
||||
.pbr
|
||||
.map(|(normals, specular, levels, layers, size)| {
|
||||
let kept = layers.min(device.limits().max_texture_array_layers);
|
||||
let count = array_layers(kept, gl);
|
||||
[("LabPBR normals", normals), ("LabPBR specular", specular)].map(
|
||||
|(label, mut data)| {
|
||||
// The extra layers are empty.
|
||||
let layer_bytes = data.len() / layers as usize;
|
||||
data.resize(layer_bytes * count as usize, 0);
|
||||
device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: count,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&data,
|
||||
)
|
||||
},
|
||||
)
|
||||
});
|
||||
let sprite_textures: Vec<wgpu::Texture> = prepared
|
||||
.sprites
|
||||
.into_iter()
|
||||
@@ -3160,6 +3252,7 @@ impl Renderer {
|
||||
screen_buffer,
|
||||
screen_capacity,
|
||||
entity_group,
|
||||
entity_bindings,
|
||||
hand_group,
|
||||
hand_globals,
|
||||
entity_buffer,
|
||||
@@ -3167,6 +3260,7 @@ impl Renderer {
|
||||
entity_batch: EntityBatch::default(),
|
||||
group,
|
||||
globals,
|
||||
pbr_maps,
|
||||
lightmap,
|
||||
sprite_textures,
|
||||
sprite_unused,
|
||||
@@ -3224,6 +3318,8 @@ impl Renderer {
|
||||
block_entities: Default::default(),
|
||||
section_uploaded: Default::default(),
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
light_blocks: Default::default(),
|
||||
light_revision: 0,
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
layout: crate::chunk::ColumnLayout::VANILLA,
|
||||
world_post,
|
||||
@@ -3472,6 +3568,11 @@ impl Renderer {
|
||||
pub fn section_count(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Whether one of Horizon's own packs with `[shadow]` draws, whose shadow pass takes the
|
||||
/// frame's `shadow_only` quads.
|
||||
pub fn native_shadow(&self) -> bool {
|
||||
self.native.as_ref().is_some_and(|n| n.shadow.is_some())
|
||||
}
|
||||
/// What a shader pack's shadow pass draws of the entities this frame: (entities or block
|
||||
/// entities other than the player, so those Horizon's culling hides from the camera are
|
||||
/// kept for it; the local player, so its model is built in first person too). (false,
|
||||
@@ -3504,6 +3605,15 @@ impl Renderer {
|
||||
if changed {
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
}
|
||||
let lights_changed = if mesh.lights.is_empty() {
|
||||
self.light_blocks.remove(&key).is_some()
|
||||
} else {
|
||||
let old = self.light_blocks.insert(key, mesh.lights.clone());
|
||||
old.as_deref() != Some(&mesh.lights[..])
|
||||
};
|
||||
if lights_changed {
|
||||
self.light_revision += 1;
|
||||
}
|
||||
if !self.lod_retiring.is_empty() {
|
||||
self.lod_retiring.remove(&key);
|
||||
}
|
||||
@@ -3883,6 +3993,9 @@ impl Renderer {
|
||||
for sy in 0..self.layout.sections() as i32 {
|
||||
let key = (x, sy, z);
|
||||
block_entities |= self.block_entities.remove(&key).is_some();
|
||||
if self.light_blocks.remove(&key).is_some() {
|
||||
self.light_revision += 1;
|
||||
}
|
||||
self.section_uploaded.remove(&key);
|
||||
if retiring {
|
||||
// Distant tiles still leave its column out: drawn until they don't.
|
||||
@@ -4182,9 +4295,11 @@ impl Renderer {
|
||||
horizon_shader_api::Report,
|
||||
Vec<(String, f64)>,
|
||||
Vec<(u32, u32)>,
|
||||
Vec<(u32, u32)>,
|
||||
block_light::LightTable,
|
||||
)>,
|
||||
) -> std::result::Result<Vec<String>, String> {
|
||||
let Some((report, constants, sprite_tags)) = pack else {
|
||||
let Some((report, constants, sprite_tags, sprite_materials, lights)) = pack else {
|
||||
self.native = None;
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
@@ -4194,9 +4309,19 @@ impl Renderer {
|
||||
sprites: self.terrain_sprites,
|
||||
format: self.config.format.remove_srgb_suffix(),
|
||||
sprite_tags: &sprite_tags,
|
||||
sprite_materials: &sprite_materials,
|
||||
entities: &self.entity_bindings,
|
||||
weather_groups: [&self.weather.entity_layout, &self.weather.time_layout],
|
||||
pbr: self.pbr_maps.as_ref(),
|
||||
};
|
||||
let (pack, labels) =
|
||||
native_pack::NativePack::new(&self.device, &terrain, report, &constants)?;
|
||||
let (pack, labels) = native_pack::NativePack::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&terrain,
|
||||
report,
|
||||
&constants,
|
||||
lights,
|
||||
)?;
|
||||
self.native = pack.draws().then_some(pack);
|
||||
if self.native.is_some() {
|
||||
self.pack_post = None;
|
||||
@@ -5465,6 +5590,7 @@ impl Renderer {
|
||||
frame.see_through,
|
||||
oit_on,
|
||||
);
|
||||
self.entity_batch.append_shadow_only(frame.shadow_only);
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads = entity_quads
|
||||
+ text_quads
|
||||
@@ -5479,7 +5605,8 @@ impl Renderer {
|
||||
// Where the tail of materials the classic and the improved passes
|
||||
// both draw ends: the shadows, particles and items count back from it.
|
||||
let base_quads = world_quads - oit_entity_quads;
|
||||
let cloud_quads = if let Some(input) = &frame.clouds {
|
||||
let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds);
|
||||
let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) {
|
||||
let (rebuilt, uniform) =
|
||||
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
|
||||
let vertices = self.cloud_mesh.vertices.len() as u64;
|
||||
@@ -5521,6 +5648,7 @@ impl Renderer {
|
||||
let hand_quads = (frame.hand.len() / 4) as u32;
|
||||
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
|
||||
let see_through_quads = (frame.see_through.len() / 4) as u32;
|
||||
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
|
||||
let entity_vertices = all_entities.len() as u64;
|
||||
if entity_vertices > self.entity_capacity {
|
||||
self.entity_capacity = entity_vertices.next_power_of_two();
|
||||
@@ -5566,15 +5694,16 @@ impl Renderer {
|
||||
bytemuck::cast_slice(&frame.celestial[..celestial_vertices]),
|
||||
);
|
||||
}
|
||||
let max_quads = (world_quads + additive_quads + hand_quads + see_through_quads)
|
||||
.max(cloud_quads)
|
||||
.max(if frame.lightning.bolts.is_empty() {
|
||||
0
|
||||
} else {
|
||||
lightning::QUADS
|
||||
})
|
||||
.max(weather_quads)
|
||||
.max(frame.glowing.len() as u32 / 4);
|
||||
let max_quads =
|
||||
(world_quads + additive_quads + hand_quads + see_through_quads + shadow_only_quads)
|
||||
.max(cloud_quads)
|
||||
.max(if frame.lightning.bolts.is_empty() {
|
||||
0
|
||||
} else {
|
||||
lightning::QUADS
|
||||
})
|
||||
.max(weather_quads)
|
||||
.max(frame.glowing.len() as u32 / 4);
|
||||
if max_quads > self.index_quads {
|
||||
self.index_quads = max_quads.next_power_of_two();
|
||||
self.indices = Self::quad_indices(&self.device, self.index_quads);
|
||||
@@ -5859,6 +5988,12 @@ impl Renderer {
|
||||
.filter(|_| scaled.is_some())
|
||||
.unwrap_or([self.config.width, self.config.height]);
|
||||
native.frame.write(&self.queue, frame, size);
|
||||
native.frame.block_light.update(
|
||||
&self.queue,
|
||||
frame.camera,
|
||||
&self.light_blocks,
|
||||
self.light_revision,
|
||||
);
|
||||
if let Some(t) = &mut native.timing {
|
||||
t.begin(&self.device, &self.queue);
|
||||
native_sky_pass =
|
||||
@@ -5927,6 +6062,22 @@ impl Renderer {
|
||||
shadow_translucent,
|
||||
);
|
||||
}
|
||||
drop(pass);
|
||||
if entity_quads > 0 || shadow_only_quads > 0 {
|
||||
let mut pass = native
|
||||
.frame
|
||||
.shadow_map
|
||||
.begin_entities(&mut encoder, "Native shader pack entity shadow");
|
||||
pass.set_pipeline(&shadow.entity);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
// At the end of the buffer (`EntityBatch::append_shadow_only`).
|
||||
let first = entity_vertices as u32 - shadow_only_quads * 4;
|
||||
pass.draw_indexed(0..shadow_only_quads * 6, first as i32, 0..1);
|
||||
}
|
||||
}
|
||||
if native_sky_pass
|
||||
&& let Some(native) = &mut self.native
|
||||
@@ -6160,12 +6311,24 @@ impl Renderer {
|
||||
self.end_sky.draw(&mut pass);
|
||||
}
|
||||
if celestial_vertices > 0 && !pack_draws_celestial {
|
||||
// The Overworld's bodies start with the sun's quad
|
||||
// (`Game::celestial_geometry`): a pack sky drawing its own skips it.
|
||||
let own_sun = !frame.end_sky
|
||||
&& self
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.sky.is_some() && !n.vanilla_sun);
|
||||
let first = if own_sun { 6 } else { 0 };
|
||||
pass.set_vertex_buffer(0, self.celestial_buffer.slice(..));
|
||||
pass.draw_indexed(0..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
|
||||
pass.draw_indexed(first..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !frame.title && frame.stars.is_some() && !pack_draws_sky {
|
||||
let own_stars = self
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.sky.is_some() && !n.vanilla_stars);
|
||||
if !frame.title && frame.stars.is_some() && !pack_draws_sky && !own_stars {
|
||||
self.stars.draw(&mut pass);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
@@ -6649,7 +6812,13 @@ impl Renderer {
|
||||
// terrain and clouds, so the water behind the rain is drawn first.
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if !pack_draws_weather && !oit_on {
|
||||
self.weather.draw(&mut pass, &self.entity_group);
|
||||
let native_weather = self
|
||||
.native
|
||||
.as_ref()
|
||||
.filter(|_| !frame.title)
|
||||
.and_then(|n| Some((n.weather.as_ref()?, &n.frame.group)));
|
||||
self.weather
|
||||
.draw(&mut pass, &self.entity_group, native_weather);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// LevelRenderer: the world border after translucent terrain and weather.
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,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],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -14,6 +14,11 @@ 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`).
|
||||
@@ -27,20 +32,23 @@ 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 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());
|
||||
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
|
||||
let shadow = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.map(|_| NativeShadow::new(device, terrain, &frame))
|
||||
.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());
|
||||
@@ -55,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()
|
||||
@@ -67,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(
|
||||
@@ -79,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));
|
||||
}
|
||||
@@ -87,6 +124,10 @@ impl NativePack {
|
||||
sky,
|
||||
material,
|
||||
post,
|
||||
vanilla_clouds,
|
||||
vanilla_sun,
|
||||
vanilla_stars,
|
||||
weather,
|
||||
shadow,
|
||||
timing: None,
|
||||
};
|
||||
@@ -95,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,9 +16,59 @@ 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,
|
||||
@@ -40,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,
|
||||
}
|
||||
@@ -50,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.
|
||||
@@ -57,6 +117,9 @@ 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,
|
||||
@@ -151,10 +214,14 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
|
||||
}
|
||||
|
||||
impl NativeFrame {
|
||||
/// The frame of a pack with the `[shadow]` settings `shadow`, if any.
|
||||
/// 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"),
|
||||
@@ -162,19 +229,46 @@ impl NativeFrame {
|
||||
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(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 }),
|
||||
],
|
||||
entries: &entries,
|
||||
});
|
||||
let opaque_depth = depth_copy(device, [1, 1]);
|
||||
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
|
||||
@@ -192,16 +286,49 @@ impl NativeFrame {
|
||||
..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),
|
||||
&shadow_map,
|
||||
&opaque_depth,
|
||||
&opaque_color,
|
||||
(&opaque_depth, &opaque_color),
|
||||
&textures,
|
||||
&block_light,
|
||||
&pbr,
|
||||
);
|
||||
Self {
|
||||
textures,
|
||||
block_light,
|
||||
pbr,
|
||||
buffer,
|
||||
layout,
|
||||
group,
|
||||
@@ -241,54 +368,74 @@ impl NativeFrame {
|
||||
buffer: &wgpu::Buffer,
|
||||
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
|
||||
shadow: &super::native_shadow::ShadowMap,
|
||||
opaque_depth: &wgpu::Texture,
|
||||
opaque_color: &wgpu::Texture,
|
||||
(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: 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.create_view(&Default::default()),
|
||||
),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
&opaque_color.create_view(&Default::default()),
|
||||
),
|
||||
},
|
||||
],
|
||||
entries: &entries,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -338,8 +485,10 @@ impl NativeFrame {
|
||||
&self.buffer,
|
||||
(&self.reflection, &self.reflection_sampler),
|
||||
&self.shadow_map,
|
||||
&self.opaque_depth,
|
||||
&self.opaque_color,
|
||||
(&self.opaque_depth, &self.opaque_color),
|
||||
&self.textures,
|
||||
&self.block_light,
|
||||
&self.pbr,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -437,6 +586,20 @@ 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,
|
||||
@@ -461,26 +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(6, wgpu::TextureSampleType::Depth),
|
||||
comparison_entry(7),
|
||||
texture_entry(8, float),
|
||||
texture_entry(9, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(10, float),
|
||||
],
|
||||
entries: &entries,
|
||||
});
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
groups[GROUP as usize] = Some(&layout);
|
||||
@@ -519,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,
|
||||
})],
|
||||
@@ -543,6 +727,7 @@ impl NativePost {
|
||||
Ok(Self {
|
||||
passes,
|
||||
layout,
|
||||
textures: frame.textures.clone(),
|
||||
frame: frame.buffer.clone(),
|
||||
shadow: (
|
||||
frame.shadow_map.view.clone(),
|
||||
@@ -557,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,
|
||||
})
|
||||
}
|
||||
@@ -573,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 {
|
||||
@@ -624,62 +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: 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: &entries,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -706,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 {
|
||||
@@ -729,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(),
|
||||
);
|
||||
@@ -871,8 +1112,125 @@ pub fn frame_inputs(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -934,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, None);
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
@@ -943,6 +1301,7 @@ mod tests {
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
report.stages,
|
||||
&report.manifest,
|
||||
&[("gain".into(), 1.0)],
|
||||
format,
|
||||
&frame,
|
||||
@@ -1053,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, None);
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
|
||||
//! solid terrain the light (the sun by day, the moon by night) sees is drawn into a square depth
|
||||
//! 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
|
||||
@@ -56,7 +56,7 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
|
||||
@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 { discard; }
|
||||
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.
|
||||
@@ -67,6 +67,40 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
|
||||
}
|
||||
";
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -191,6 +225,25 @@ impl ShadowMap {
|
||||
&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),
|
||||
@@ -198,7 +251,7 @@ impl ShadowMap {
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
@@ -261,14 +314,106 @@ pub struct NativeShadow {
|
||||
/// 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 shadow pass's shader: the frame, Horizon's terrain, and the entries.
|
||||
fn shader_source() -> String {
|
||||
/// 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{}{ENTRIES}",
|
||||
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
prefixed(
|
||||
@@ -283,8 +428,9 @@ impl NativeShadow {
|
||||
device: &wgpu::Device,
|
||||
shape: &TerrainShape<'_>,
|
||||
frame: &super::native_post::NativeFrame,
|
||||
solid_materials: &[u32],
|
||||
) -> Result<Self, String> {
|
||||
let text = shader_source();
|
||||
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 = [
|
||||
@@ -420,6 +566,7 @@ impl NativeShadow {
|
||||
};
|
||||
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}"));
|
||||
}
|
||||
@@ -428,6 +575,7 @@ impl NativeShadow {
|
||||
flat,
|
||||
translucent,
|
||||
flat_translucent,
|
||||
entity,
|
||||
group,
|
||||
})
|
||||
}
|
||||
@@ -439,7 +587,29 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_shadow_shader_validates() {
|
||||
let text = shader_source();
|
||||
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(),
|
||||
@@ -454,6 +624,7 @@ mod tests {
|
||||
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(
|
||||
@@ -474,6 +645,7 @@ mod tests {
|
||||
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] {
|
||||
|
||||
@@ -252,7 +252,7 @@ mod tests {
|
||||
resource: globals_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let frame = NativeFrame::new(&device, None);
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
|
||||
@@ -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,
|
||||
@@ -84,6 +88,8 @@ impl Weather {
|
||||
});
|
||||
Self {
|
||||
pipeline,
|
||||
entity_layout: entities.clone(),
|
||||
time_layout: bindings,
|
||||
group,
|
||||
uniform,
|
||||
vertices: Self::buffer(device, 4),
|
||||
@@ -138,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(..));
|
||||
@@ -163,8 +182,156 @@ fn depth_stencil() -> wgpu::DepthStencilState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
|
||||
@@ -364,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.
|
||||
@@ -424,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,
|
||||
@@ -490,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" {
|
||||
@@ -548,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:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\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(),
|
||||
@@ -556,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",
|
||||
@@ -709,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,
|
||||
|
||||
@@ -33,6 +33,7 @@ pub enum H {
|
||||
Fog,
|
||||
Weather,
|
||||
AnimatedTextures,
|
||||
DitherCutoutMips,
|
||||
PresentMode,
|
||||
Pacing,
|
||||
Workers,
|
||||
@@ -261,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,
|
||||
@@ -525,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",
|
||||
@@ -646,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 => {
|
||||
@@ -733,6 +741,7 @@ 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,
|
||||
|
||||
@@ -544,6 +544,17 @@ impl HudAtlas {
|
||||
}
|
||||
}
|
||||
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() {
|
||||
@@ -567,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()?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,8 +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.
|
||||
NativePackOptions(usize),
|
||||
/// 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`).
|
||||
@@ -1263,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 {
|
||||
@@ -1525,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 {
|
||||
@@ -1570,6 +1575,7 @@ impl Default for OptionsUi {
|
||||
framebuffer: [1280, 720],
|
||||
split_guest: false,
|
||||
row_bands: Vec::new(),
|
||||
split_joined: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1633,30 +1639,64 @@ 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).
|
||||
/// The options of the Horizon pack `s.shader_packs[i]`, two a row.
|
||||
fn native_pack_option_rows(s: &Settings, i: usize) -> Vec<Row> {
|
||||
/// 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,
|
||||
h: 20.0,
|
||||
kind: WKind::Text {
|
||||
text: Text::literal(""),
|
||||
color: 0,
|
||||
centered: false,
|
||||
},
|
||||
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 mut widgets = pack
|
||||
.options
|
||||
.iter()
|
||||
.zip(&pack.values)
|
||||
.enumerate()
|
||||
.map(|(o, (option, v))| {
|
||||
let value = crate::shader_pack_list::show_value(&option.kind, *v);
|
||||
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, 150.0, label, Act::NativePackOption(i, o))
|
||||
});
|
||||
let mut rows = vec![Row::Note(pack.title.clone())];
|
||||
while let Some(a) = widgets.next() {
|
||||
rows.push(Row::Buttons(a, widgets.next()));
|
||||
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> {
|
||||
@@ -1667,19 +1707,7 @@ fn shader_option_rows(s: &Settings) -> Vec<Row> {
|
||||
.screens
|
||||
.screen(options.screen())
|
||||
.map_or(2, |(_, c)| c);
|
||||
let blank = || Widget {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: 150.0,
|
||||
h: 20.0,
|
||||
kind: WKind::Text {
|
||||
text: Text::literal(""),
|
||||
color: 0,
|
||||
centered: false,
|
||||
},
|
||||
list: false,
|
||||
row: 0,
|
||||
};
|
||||
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;
|
||||
@@ -1744,13 +1772,25 @@ 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> {
|
||||
if let WKind::Button {
|
||||
act: Act::NativePackOption(pack, option),
|
||||
..
|
||||
} = &wd.kind
|
||||
{
|
||||
let option = s.shader_packs.get(*pack)?.options.get(*option)?;
|
||||
return (!option.description.is_empty()).then(|| Text::literal(&option.description));
|
||||
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 {
|
||||
@@ -2095,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",
|
||||
@@ -2113,7 +2161,7 @@ impl OptionsUi {
|
||||
Page::Mods => "horizon.pages.mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::ShaderOptions | Page::NativePackOptions(_) => "horizon.pages.shader_options",
|
||||
Page::ShaderOptions | Page::NativePackOptions(..) => "horizon.pages.shader_options",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
|
||||
"options.videoTitle"
|
||||
}
|
||||
@@ -2124,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 {
|
||||
@@ -2213,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.
|
||||
@@ -2279,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),
|
||||
@@ -2572,7 +2640,7 @@ impl OptionsUi {
|
||||
.collect()
|
||||
}
|
||||
Page::ShaderOptions => shader_option_rows(s),
|
||||
Page::NativePackOptions(i) => native_pack_option_rows(s, i),
|
||||
Page::NativePackOptions(i, at) => native_pack_option_rows(s, i, at),
|
||||
_ => {
|
||||
let _ = s;
|
||||
Vec::new()
|
||||
@@ -3024,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,
|
||||
},
|
||||
@@ -3109,7 +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))),
|
||||
Page::NativePackOptions(i, _) => {
|
||||
Some(("controls.reset", Act::NativePackReset(i)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((label, act)) = folder {
|
||||
@@ -3906,7 +3977,7 @@ impl OptionsUi {
|
||||
}
|
||||
}
|
||||
Act::NativePackOptions(i) => {
|
||||
self.push(Page::NativePackOptions(i));
|
||||
self.push(Page::NativePackOptions(i, 0));
|
||||
}
|
||||
Act::NativePackOption(pack, option) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack)
|
||||
@@ -4829,7 +4900,7 @@ impl OptionsUi {
|
||||
}
|
||||
}
|
||||
Act::NativePackOptions(i) => {
|
||||
self.push(Page::NativePackOptions(i));
|
||||
self.push(Page::NativePackOptions(i, 0));
|
||||
}
|
||||
Act::NativePackOption(pack, option) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack)
|
||||
@@ -5153,6 +5224,22 @@ mod tests {
|
||||
},
|
||||
}],
|
||||
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);
|
||||
@@ -5166,7 +5253,11 @@ mod tests {
|
||||
};
|
||||
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));
|
||||
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)));
|
||||
@@ -5174,9 +5265,17 @@ mod tests {
|
||||
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]
|
||||
@@ -6715,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)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use horizon_shader_api::{OptionKind, PackOption};
|
||||
use horizon_shader_api::{OptionKind, OptionPage, PackOption};
|
||||
|
||||
pub const DIR: &str = "shaderpacks";
|
||||
|
||||
@@ -26,6 +26,8 @@ pub struct Entry {
|
||||
/// 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 {
|
||||
@@ -88,8 +90,10 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
.ok()
|
||||
.map(|p| p.manifest)
|
||||
};
|
||||
let (title, options) =
|
||||
manifest.map_or_else(|| (file.clone(), Vec::new()), |m| (m.name, m.options));
|
||||
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,
|
||||
@@ -97,6 +101,7 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
kind: Kind::Horizon,
|
||||
options,
|
||||
values,
|
||||
pages,
|
||||
});
|
||||
}
|
||||
let zip = path.is_file()
|
||||
@@ -109,6 +114,7 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
kind: Kind::Classic,
|
||||
options: Vec::new(),
|
||||
values: Vec::new(),
|
||||
pages: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2371,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();
|
||||
@@ -2387,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);
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, mob effect and entity type registries once content mods add to
|
||||
//! them (item stacks, effect and entity 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};
|
||||
@@ -108,10 +109,11 @@ fn hash_facts(bytes: &[u8]) -> Result<[u8; 32], IdentityError> {
|
||||
}
|
||||
|
||||
/// The built-in registries whose network ids content mods extend besides blocks.
|
||||
const REGISTRIES: [&str; 3] = [
|
||||
const REGISTRIES: [&str; 4] = [
|
||||
"minecraft:item",
|
||||
"minecraft:mob_effect",
|
||||
"minecraft:entity_type",
|
||||
"minecraft:menu",
|
||||
];
|
||||
|
||||
fn parse_registries(bytes: &[u8]) -> Result<Builtin, IdentityError> {
|
||||
@@ -180,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!(
|
||||
|
||||
@@ -2287,12 +2287,23 @@ const THROWABLES: [(ProjectileKind, &str); 3] = [
|
||||
(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 {
|
||||
@@ -2341,8 +2352,7 @@ pub fn throwable_to_nbt(
|
||||
/// `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 id = kind(tag)?;
|
||||
let (kind, _) = THROWABLES.iter().find(|(_, i)| *i == id)?.clone();
|
||||
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));
|
||||
@@ -4472,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());
|
||||
}
|
||||
}
|
||||
+329
-112
@@ -142,6 +142,7 @@ mod integrated;
|
||||
mod interactions;
|
||||
mod item_draw;
|
||||
mod item_particles;
|
||||
mod item_use;
|
||||
mod jigsaw_blocks;
|
||||
mod jigsaw_edit;
|
||||
mod jukebox;
|
||||
@@ -196,6 +197,7 @@ mod native_hooks;
|
||||
mod native_lifecycle;
|
||||
mod native_menus;
|
||||
mod native_mobs;
|
||||
mod native_motion;
|
||||
mod native_net;
|
||||
mod native_panel;
|
||||
mod native_panel_recipes;
|
||||
@@ -1647,6 +1649,8 @@ struct FallingBlock {
|
||||
age: i32,
|
||||
/// `Entity.isNoGravity`.
|
||||
no_gravity: bool,
|
||||
/// `FallingBlockEntity.tick` handles portals after moving, before landing.
|
||||
portal: horizon_client::nether_portal::PortalState,
|
||||
/// `FallingBlockEntity.dropItem`.
|
||||
drop_item: bool,
|
||||
/// `Entity.fallDistance` (powder snow holds a block that fell far at 0.9).
|
||||
@@ -1737,8 +1741,8 @@ struct WorldRest {
|
||||
/// recipes, loot tables and tags from the world's pack stack, over the
|
||||
/// resource root's own. Worlds without packs use `Resources.rest` directly.
|
||||
struct WorldTables {
|
||||
/// The pack files the tables were built from, for the advancement tree
|
||||
/// reload and the loot data.
|
||||
/// The pack files the tables were built from (a native mod's among
|
||||
/// them), for the advancement tree reload and the loot data.
|
||||
files: Vec<(String, Vec<Vec<u8>>)>,
|
||||
items: Items,
|
||||
recipes: Recipes,
|
||||
@@ -2226,6 +2230,8 @@ struct Game<'a> {
|
||||
luminance: Option<horizon_client::dynamic_light::Luminance>,
|
||||
/// Name tags drawn over everything (`RenderPipelines.TEXT_SEE_THROUGH`).
|
||||
see_through_vertices: Vec<Vertex>,
|
||||
/// The first-person player's model, which only a native pack's shadow pass draws.
|
||||
shadow_only_vertices: std::cell::RefCell<Vec<Vertex>>,
|
||||
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
|
||||
/// while `pack_ids` is set (game/shader_packs.rs).
|
||||
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
|
||||
@@ -2245,6 +2251,9 @@ struct Game<'a> {
|
||||
/// The glint clock and options, read once at the start of each frame
|
||||
/// (game/item_draw.rs).
|
||||
glint_frame: horizon_client::item_render::GlintFrame,
|
||||
/// Mod block entities' geometry their mods kept
|
||||
/// (game/native_block_entity_draw.rs).
|
||||
kept_block_entities: std::cell::RefCell<native_block_entity_draw::KeptGeometry>,
|
||||
/// `item_level` for one frame, game time, day time and dimension.
|
||||
item_level_cache: std::cell::RefCell<
|
||||
Option<(
|
||||
@@ -2332,6 +2341,10 @@ struct Game<'a> {
|
||||
/// Mobs a player hurt (`LivingEntity.lastHurtByPlayer`), with the guest
|
||||
/// it was by connection (None: the host).
|
||||
last_hurt_by_player: HashMap<u32, Option<u32>>,
|
||||
/// Leashed mobs a player killed, with the killer's UUID: the lead drops
|
||||
/// on the corpse's next tick (`Leashable.tickLeash`), for the mods. With
|
||||
/// the corpse's dimension: each level's tick prunes only its own.
|
||||
lead_killers: HashMap<u32, (u128, Dim)>,
|
||||
/// Animals a player fed into love (`Animal.loveCause`), with the guest it was by
|
||||
/// connection (None: the host). Breeding credits them, wherever they are.
|
||||
love_cause: HashMap<u32, Option<u32>>,
|
||||
@@ -2342,6 +2355,9 @@ struct Game<'a> {
|
||||
be_anims: horizon_client::block_entity_anim::BlockEntityAnims,
|
||||
/// Item being used (eaten): hotbar slot and ticks used.
|
||||
using: Option<(usize, u32)>,
|
||||
/// `getUseDuration` when that use started, as a mod left it
|
||||
/// (`game/item_use.rs`): consumables and mod items count to it.
|
||||
use_duration: i32,
|
||||
/// Camera.oldFovModifier / fovModifier.
|
||||
/// `Camera` (eye height and field-of-view easing, view placement).
|
||||
view: horizon_client::camera::Camera,
|
||||
@@ -2845,6 +2861,56 @@ impl Dim {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Inventory.add` on a player's 36 slots (hotbar first) and off hand,
|
||||
/// `max` the stack's own maximum: onto equal stacks first
|
||||
/// (`getSlotWithRemainingSpace`: the selected slot, the off hand, then the
|
||||
/// hotbar and the main inventory), then into empty slots of the inventory
|
||||
/// (`getFreeSlot`, never the off hand). What does not fit stays in `stack`;
|
||||
/// true when some of it is left.
|
||||
fn inventory_add(
|
||||
inventory: &mut [Option<ItemStack>],
|
||||
offhand: &mut Option<ItemStack>,
|
||||
selected: usize,
|
||||
max: u32,
|
||||
stack: &mut ItemStack,
|
||||
) -> bool {
|
||||
let order = std::iter::once(selected)
|
||||
.chain(std::iter::once(shield::OFFHAND_SLOT))
|
||||
.chain((0..36).filter(|&i| i != selected));
|
||||
for i in order {
|
||||
let slot = if i == shield::OFFHAND_SLOT {
|
||||
&mut *offhand
|
||||
} else {
|
||||
&mut inventory[i]
|
||||
};
|
||||
if let Some(slot) = slot
|
||||
&& slot.same_item_same_components(stack)
|
||||
&& slot.count < max
|
||||
{
|
||||
let moved = (max - slot.count).min(stack.count);
|
||||
slot.count += moved;
|
||||
stack.count -= moved;
|
||||
if stack.count == 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
for slot in inventory.iter_mut().take(36) {
|
||||
if slot.is_none() {
|
||||
let moved = stack.count.min(max);
|
||||
*slot = Some(ItemStack {
|
||||
count: moved,
|
||||
..stack.clone()
|
||||
});
|
||||
stack.count -= moved;
|
||||
if stack.count == 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn facing_offset(facing: &str) -> [i32; 3] {
|
||||
match facing {
|
||||
"north" => [0, 0, -1],
|
||||
@@ -3236,57 +3302,23 @@ impl Game<'_> {
|
||||
tutorial::Step::FindTree | tutorial::Step::PunchTree | tutorial::Step::CraftPlanks
|
||||
)
|
||||
.then(|| stack.clone());
|
||||
let mut added = false;
|
||||
let max = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(64, |i| i.max_stack);
|
||||
// getSlotWithRemainingSpace: the selected slot, the off hand, then
|
||||
// the hotbar and the main inventory.
|
||||
let order: Vec<usize> = std::iter::once(self.selected)
|
||||
.chain(std::iter::once(shield::OFFHAND_SLOT))
|
||||
.chain((0..36).filter(|&i| i != self.selected))
|
||||
.collect();
|
||||
for &i in &order {
|
||||
let slot = if i == shield::OFFHAND_SLOT {
|
||||
&mut self.offhand
|
||||
} else {
|
||||
&mut self.inventory[i]
|
||||
};
|
||||
if let Some(slot) = slot
|
||||
&& slot.same_item_same_components(&stack)
|
||||
&& slot.count < max
|
||||
{
|
||||
let moved = (max - slot.count).min(stack.count);
|
||||
slot.count += moved;
|
||||
stack.count -= moved;
|
||||
added |= moved > 0;
|
||||
if stack.count == 0 {
|
||||
self.tutorial_get_item(tutorial_item.as_ref());
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..36 {
|
||||
if self.inventory[i].is_none() {
|
||||
let moved = stack.count.min(max);
|
||||
self.inventory[i] = Some(ItemStack {
|
||||
count: moved,
|
||||
..stack.clone()
|
||||
});
|
||||
stack.count -= moved;
|
||||
added |= moved > 0;
|
||||
if stack.count == 0 {
|
||||
self.tutorial_get_item(tutorial_item.as_ref());
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if added {
|
||||
let before = stack.count;
|
||||
let left = inventory_add(
|
||||
&mut self.inventory,
|
||||
&mut self.offhand,
|
||||
self.selected,
|
||||
max,
|
||||
&mut stack,
|
||||
);
|
||||
if stack.count < before {
|
||||
self.tutorial_get_item(tutorial_item.as_ref());
|
||||
}
|
||||
Some(stack)
|
||||
left.then_some(stack)
|
||||
}
|
||||
/// The block's first up face (or any face) and its fixed tint, for block
|
||||
/// models drawn as entities (falling blocks, carried blocks).
|
||||
@@ -3539,6 +3571,10 @@ impl Game<'_> {
|
||||
// Block.popResource: Mth.nextDouble offsets from the level random; the
|
||||
// motion is `new ItemEntity(..)`'s own, from the item's random.
|
||||
let p = spill::pop_position(&mut self.world.random, pos);
|
||||
// Block.popResource draws its offsets before the private overload checks the gamerule.
|
||||
if !self.ui.rules.bool("block_drops") {
|
||||
return;
|
||||
}
|
||||
let v = dispenser::new_item_motion();
|
||||
self.spawn_item(stack, p, v, 10);
|
||||
}
|
||||
@@ -4696,6 +4732,7 @@ impl Game<'_> {
|
||||
velocity: Vec3::ZERO,
|
||||
age: 0,
|
||||
no_gravity: false,
|
||||
portal: Default::default(),
|
||||
drop_item: true,
|
||||
fall_distance: 0.0,
|
||||
stuck: Vec3::ZERO,
|
||||
@@ -5041,7 +5078,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// One `FallingBlockEntity.tick` move of `f`.
|
||||
fn fall_step(&self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
|
||||
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
|
||||
f.previous = f.position;
|
||||
f.age = f.age.wrapping_add(1);
|
||||
if !f.no_gravity {
|
||||
@@ -5087,30 +5124,11 @@ impl Game<'_> {
|
||||
f.position.z + movement.z,
|
||||
);
|
||||
let on_ground = wanted.y < 0.0 && movement.y != wanted.y;
|
||||
// FallingBlockEntity.tick: off the ground, it gives up after 600
|
||||
// ticks (100 outside the world) and drops itself as an item.
|
||||
let y = next.y.floor() as i32;
|
||||
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
|
||||
if !on_ground && (f.age > 100 && outside || f.age > 600) {
|
||||
return Ok(if f.drop_item && entity_drops {
|
||||
FallStep::Dropped(next)
|
||||
} else {
|
||||
FallStep::Gone
|
||||
});
|
||||
}
|
||||
if next.y < f.position.y {
|
||||
f.fall_distance += f.position.y - next.y;
|
||||
}
|
||||
let from = f.position;
|
||||
f.position = next;
|
||||
if on_ground {
|
||||
let pos = [
|
||||
f.position.x.floor() as i32,
|
||||
f.position.y.floor() as i32,
|
||||
f.position.z.floor() as i32,
|
||||
];
|
||||
return Ok(FallStep::Landed(pos));
|
||||
}
|
||||
// Entity.move stops it on the axes it collided on.
|
||||
if movement.x != wanted.x {
|
||||
f.velocity.x = 0.0;
|
||||
@@ -5121,6 +5139,10 @@ impl Game<'_> {
|
||||
if movement.z != wanted.z {
|
||||
f.velocity.z = 0.0;
|
||||
}
|
||||
if on_ground {
|
||||
self.falling_block_impact(f);
|
||||
f.fall_distance = 0.0;
|
||||
}
|
||||
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
|
||||
// (Entity.makeStuckInBlock), BubbleColumnBlock.entityInside lifts or
|
||||
// drags it. Entity.move records the move unless it was blocked short
|
||||
@@ -5152,12 +5174,44 @@ impl Game<'_> {
|
||||
if stuck {
|
||||
f.stuck = horizon_client::falling_block::web_multiplier();
|
||||
}
|
||||
// FallingBlockEntity.tick handles its portal after the move's block
|
||||
// effects, before placing/dropping on the floor under an End portal.
|
||||
let teleported = self.falling_through_portal(f);
|
||||
// FallingBlockEntity.forceTickAfterTeleportToDuplicate: crossing
|
||||
// either way through the End deliberately keeps the source's final
|
||||
// placement/drop branch, even though the source entity was removed.
|
||||
if let Some(target) = teleported
|
||||
&& self.dim != Dim::End
|
||||
&& target != Dim::End
|
||||
{
|
||||
return Ok(FallStep::Gone);
|
||||
}
|
||||
let y = next.y.floor() as i32;
|
||||
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
|
||||
if !on_ground && (f.age > 100 && outside || f.age > 600) {
|
||||
return Ok(if f.drop_item && entity_drops {
|
||||
FallStep::Dropped(next)
|
||||
} else {
|
||||
FallStep::Gone
|
||||
});
|
||||
}
|
||||
if on_ground {
|
||||
return Ok(FallStep::Landed([
|
||||
f.position.x.floor() as i32,
|
||||
f.position.y.floor() as i32,
|
||||
f.position.z.floor() as i32,
|
||||
]));
|
||||
}
|
||||
f.velocity = Vec3::new(
|
||||
f.velocity.x * 0.98,
|
||||
f.velocity.y * 0.98,
|
||||
f.velocity.z * 0.98,
|
||||
);
|
||||
Ok(FallStep::Falling)
|
||||
Ok(if teleported.is_some() {
|
||||
FallStep::Gone
|
||||
} else {
|
||||
FallStep::Falling
|
||||
})
|
||||
}
|
||||
/// `Entity.spawnAtLocation`: an item entity where the block was.
|
||||
fn drop_falling_block(&mut self, state: BlockStateId, at: Vec3) {
|
||||
@@ -5309,6 +5363,9 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, disarmed, 260));
|
||||
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
|
||||
}
|
||||
if creative && storage::is_shulker_box(&facts.name) {
|
||||
self.drop_creative_shulker(pos);
|
||||
}
|
||||
// BeehiveBlock.playerWillDestroy: a creative break still drops the hive
|
||||
// with its bees and honey level.
|
||||
if creative
|
||||
@@ -5835,6 +5892,12 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
// a mod's item used over time: its Item.use, once a block item's
|
||||
// useOn had its turn.
|
||||
if !on_block && (info.block.is_none() || block_hit.is_none()) && self.start_mod_use(&stack)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
if (!on_block && self.use_potion_item(&stack)?)
|
||||
|| ((block_hit.is_some() || !spawn_egg)
|
||||
&& (!on_block || spawn_egg)
|
||||
@@ -5844,9 +5907,7 @@ impl Game<'_> {
|
||||
}
|
||||
// BrushItem.useOn: brushing starts on the block aimed at (CONSUME).
|
||||
if block_hit.is_some() && &*stack.item == "minecraft:brush" {
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
self.start_using_item();
|
||||
return Ok(true);
|
||||
}
|
||||
if !on_block
|
||||
@@ -5873,9 +5934,7 @@ impl Game<'_> {
|
||||
}
|
||||
// BundleItem.use: startUsingItem, then onUseTick empties it.
|
||||
if !on_block && info.bundle {
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
self.start_using_item();
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -5884,10 +5943,8 @@ impl Game<'_> {
|
||||
return Ok(true);
|
||||
}
|
||||
if !on_block && &*stack.item == "minecraft:bow" {
|
||||
if self.using.is_none()
|
||||
&& (self.creative || self.projectile_slot("minecraft:bow").is_some())
|
||||
{
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
if self.creative || self.projectile_slot("minecraft:bow").is_some() {
|
||||
self.start_using_item();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -6043,17 +6100,28 @@ impl Game<'_> {
|
||||
/// `Player.canEat` (`Consumable.canConsume`), anything else is always
|
||||
/// consumed. The right click is held for `consume_seconds`.
|
||||
fn start_eating(&mut self, info: &horizon_client::items::ItemInfo) -> bool {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
if info.consumable.is_none()
|
||||
|| (info.food.is_some()
|
||||
&& !self.player.as_ref().is_some_and(|p| {
|
||||
info.can_always_eat || p.health.food.food < 20 || self.creative
|
||||
}))
|
||||
&& (self.player.is_none() || !self.can_eat(&stack, info.can_always_eat)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
// as Consumable.startConsuming does, no time is eaten at once, without a use
|
||||
// (the server's eating reaches a client through the inventory).
|
||||
if let Some(consumable) = info.consumable.as_ref()
|
||||
&& item_use::consumes_at_once(consumable.ticks())
|
||||
{
|
||||
if self.remote.is_none() {
|
||||
// the cooldown of ItemStack.use's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
self.finish_consuming(info, consumable, &stack);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
self.start_using_item();
|
||||
true
|
||||
}
|
||||
/// `InstrumentItem.use` for a goat horn: the player holds it up for the
|
||||
@@ -6065,9 +6133,7 @@ impl Game<'_> {
|
||||
let Some(instrument) = self.res.items().instrument(stack) else {
|
||||
return;
|
||||
};
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
self.start_using_item();
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
@@ -6188,6 +6254,9 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if self.tick_drinking(slot, ticks, &stack) {
|
||||
if self.using.is_none() {
|
||||
self.use_item_finished(&stack);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:bow" {
|
||||
@@ -6239,24 +6308,30 @@ impl Game<'_> {
|
||||
self.tick_bundle(slot, ticks);
|
||||
return;
|
||||
}
|
||||
if self.mod_use(&stack) {
|
||||
self.tick_mod_use(slot, ticks, &stack);
|
||||
return;
|
||||
}
|
||||
let Some(consumable) = info.consumable.as_ref() else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
// ItemStack.onUseTick: Consumable.shouldEmitParticlesAndSounds with
|
||||
// the remaining ticks before this tick counts them down.
|
||||
let duration = consumable.ticks().max(0) as u32;
|
||||
let remaining = duration - ticks.min(duration);
|
||||
// the duration is getUseDuration's when the use started.
|
||||
let remaining = self.use_duration - ticks as i32;
|
||||
let done = item_use::count_down(ticks, self.use_duration).is_none();
|
||||
let ticks = ticks + 1;
|
||||
if consumable.should_emit(remaining as i32) {
|
||||
if consumable.should_emit(remaining) {
|
||||
self.emit_consume(consumable, &stack, 5);
|
||||
}
|
||||
if ticks >= duration {
|
||||
if done {
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
self.finish_consuming(&info, consumable, &stack);
|
||||
self.use_item_finished(&stack);
|
||||
} else {
|
||||
self.using = Some((slot, ticks));
|
||||
}
|
||||
@@ -7659,7 +7734,12 @@ impl Game<'_> {
|
||||
// GuiGraphicsExtractor.itemDecorations, laid out at the origin and
|
||||
// moved to (x, y): the damage bar, the cooldown shade and the count.
|
||||
let info = self.items().get(&stack.item);
|
||||
let max_damage = info.map_or(0, |info| info.max_damage as i32);
|
||||
// ItemStack.isBarVisible: isDamaged, which an unbreakable stack never is.
|
||||
let max_damage = if horizon_client::stations::is_damageable(self.items(), stack) {
|
||||
info.map_or(0, |info| info.max_damage as i32)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cooldown =
|
||||
horizon_client::cooldowns::group(stack, info.and_then(|i| i.use_cooldown.as_ref()));
|
||||
let partial = self.clock.fraction() as f32;
|
||||
@@ -7686,6 +7766,7 @@ impl Game<'_> {
|
||||
));
|
||||
draws.push(hud_layout::GuiDraw::Pop);
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
self.native_item_decorations(stack, x, y, batch);
|
||||
}
|
||||
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
// The pack prompt replaces the connecting screen (setScreen), but
|
||||
@@ -10156,7 +10237,15 @@ impl Game<'_> {
|
||||
self.mob_geometry(camera, alpha, &mut out);
|
||||
// In first person only a shader pack's shadow pass draws the player.
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
let player_start = out.len();
|
||||
self.player_model_geometry(camera, self.clock.camera_fraction(), &mut out);
|
||||
// A native pack has no per-entity draws to hide it with: its shadow pass alone gets
|
||||
// the first-person model.
|
||||
if self.pack_ids.is_none() && !self.own_body_in_view() {
|
||||
self.shadow_only_vertices
|
||||
.borrow_mut()
|
||||
.extend(out.drain(player_start..));
|
||||
}
|
||||
self.mark_entity(out.len(), "minecraft:player");
|
||||
self.guest_model_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:evoker_fangs");
|
||||
@@ -10171,9 +10260,11 @@ impl Game<'_> {
|
||||
self.see_through_vertices = see_through;
|
||||
// Entity shadows (EntityRenderer.extractShadow).
|
||||
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
|
||||
let shadow_sprite = self
|
||||
.settings
|
||||
.entity_shadows
|
||||
// A native shader pack's sun shadows already fall under the entities in the Overworld:
|
||||
// the round blob would be a second one. The Nether and the End have no sun to cast them.
|
||||
let pack_shadows =
|
||||
self.dim == Dim::Overworld && self.renderer.as_ref().is_some_and(|r| r.native_shadow());
|
||||
let shadow_sprite = (self.settings.entity_shadows && !pack_shadows)
|
||||
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
|
||||
.flatten();
|
||||
if let Some(sprite) = shadow_sprite {
|
||||
@@ -11745,6 +11836,7 @@ impl Game<'_> {
|
||||
self.boss_overlay.reset();
|
||||
// GameRenderer.resetData (Minecraft.clearClientLevel).
|
||||
self.item_activation.reset();
|
||||
self.kept_block_entities.get_mut().clear();
|
||||
self.bosses = Default::default();
|
||||
self.streaming.paused = true;
|
||||
self.world = World::new(self.res.facts().air, 0);
|
||||
@@ -12986,6 +13078,18 @@ impl Game<'_> {
|
||||
let attack = attacks[i];
|
||||
let inverse_height =
|
||||
horizon_client::first_person::equip_displacement(heights[i], scale);
|
||||
let mut canceled = false;
|
||||
self.native_render_hand(
|
||||
hand,
|
||||
item.as_ref(),
|
||||
a,
|
||||
attack,
|
||||
inverse_height,
|
||||
&mut canceled,
|
||||
);
|
||||
if canceled {
|
||||
continue;
|
||||
}
|
||||
// The main hand on its arm, the off hand on the other one.
|
||||
let left = self.settings.main_hand_left != (hand == Hand::Off);
|
||||
let Some(stack) = item else {
|
||||
@@ -13078,24 +13182,18 @@ impl Game<'_> {
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(&stack));
|
||||
let animation = components
|
||||
let mut animation = components
|
||||
.get("minecraft:consumable")
|
||||
.map(horizon_client::first_person::consumable_animation);
|
||||
if matches!(
|
||||
.map_or(horizon_world::consumable::UseAnimation::None, |c| {
|
||||
horizon_client::first_person::consumable_animation(c)
|
||||
});
|
||||
self.native_use_animation(&stack, &mut animation);
|
||||
item_use::use_pose(
|
||||
animation,
|
||||
Some(
|
||||
horizon_world::consumable::UseAnimation::Eat
|
||||
| horizon_world::consumable::UseAnimation::Drink
|
||||
)
|
||||
) {
|
||||
ItemPose::Eat {
|
||||
remaining: owner.use_item_remaining_ticks as f32,
|
||||
duration: owner.use_duration as f32,
|
||||
partial: a,
|
||||
}
|
||||
} else {
|
||||
ItemPose::Using
|
||||
}
|
||||
owner.use_item_remaining_ticks as f32,
|
||||
owner.use_duration as f32,
|
||||
a,
|
||||
)
|
||||
}
|
||||
_ if spinning => ItemPose::SpinAttack,
|
||||
// ItemInHandRenderer: a STAB swing of this hand jabs.
|
||||
@@ -14529,6 +14627,12 @@ impl Game<'_> {
|
||||
|| !self.block_action_restricted(h.pos)
|
||||
|| self.can_place_on_in_adventure(h.pos)
|
||||
})
|
||||
.filter(|h| {
|
||||
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
|
||||
let mut canceled = false;
|
||||
self.native_block_highlight(h.pos, state, alpha as f32, &mut canceled);
|
||||
!canceled
|
||||
})
|
||||
.map(|h| {
|
||||
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
|
||||
let f = self.res.facts().get(state);
|
||||
@@ -14618,6 +14722,7 @@ impl Game<'_> {
|
||||
entities.clear();
|
||||
self.see_through_vertices.clear();
|
||||
self.glowing_vertices.get_mut().clear();
|
||||
self.shadow_only_vertices.get_mut().clear();
|
||||
if !title {
|
||||
entities = horizon_client::spans::time("entity_geometry", || {
|
||||
self.entity_geometry(camera, alpha, entities)
|
||||
@@ -14698,6 +14803,7 @@ impl Game<'_> {
|
||||
hud.patches.append(&mut self.map_patches);
|
||||
let entity_spans = std::mem::take(self.entity_spans.get_mut());
|
||||
let glowing = std::mem::take(self.glowing_vertices.get_mut());
|
||||
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
@@ -14790,6 +14896,7 @@ impl Game<'_> {
|
||||
},
|
||||
additive: &additive,
|
||||
see_through: &see_through,
|
||||
shadow_only: &shadow_only,
|
||||
celestial: if self.settings.horizon.sky || title {
|
||||
&celestial
|
||||
} else {
|
||||
@@ -14857,6 +14964,7 @@ impl Game<'_> {
|
||||
self.entity_vertices = entities;
|
||||
*self.entity_spans.get_mut() = entity_spans;
|
||||
*self.glowing_vertices.get_mut() = glowing;
|
||||
*self.shadow_only_vertices.get_mut() = shadow_only;
|
||||
self.see_through_vertices = see_through;
|
||||
result
|
||||
}
|
||||
@@ -15859,6 +15967,63 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ScreenEvent.KeyPressed.Pre: the open screen hears of the key before
|
||||
// it acts on it, and a cancelled press goes no further — no focus
|
||||
// move, no Escape-close, no inventory or hotbar key, no typing by the
|
||||
// key itself, no `native_key`. It fires after the window's own
|
||||
// shortcuts (fullscreen, screenshot, the debug keys, the narrator) so
|
||||
// those keep working with a mod that cancels; the key's release still
|
||||
// comes through.
|
||||
//
|
||||
// A key's characters are a separate callback of GLFW's, and it comes
|
||||
// after the key callback (`KeyboardHandler.charTyped`): they are taken
|
||||
// off the key here and dispatched once the screen has acted on it, on
|
||||
// the screen that is open then (`ScreenEvent.CharacterTyped.Pre`).
|
||||
// Options keeps its text on the key: its own key path routes a key's
|
||||
// text between its boxes and its key-bind capture, and no key that
|
||||
// carries text closes that screen or moves its focus.
|
||||
let mut typed: Option<String> = None;
|
||||
if let WindowEvent::KeyboardInput {
|
||||
event: key_event, ..
|
||||
} = &mut event
|
||||
&& key_event.state == ElementState::Pressed
|
||||
{
|
||||
if self.screen_hooks_open() && self.screen != Screen::Options {
|
||||
typed = key_event.text.take().map(|text| text.to_string());
|
||||
}
|
||||
let canceled = match key_event.physical_key {
|
||||
PhysicalKey::Code(key) => self.screen_key_pressed(key, key_event.repeat),
|
||||
PhysicalKey::Unidentified(_) => false,
|
||||
};
|
||||
if self.screen == Screen::Options && self.screen_hooks_open() {
|
||||
// The hook after the key's own, then the text the key path
|
||||
// types in place (its own routing keeps it).
|
||||
if let Some(text) = key_event.text.as_deref() {
|
||||
let left = self.screen_text_typed(text);
|
||||
if left.as_deref() != Some(text) {
|
||||
key_event.text = left.map(Into::into);
|
||||
}
|
||||
}
|
||||
}
|
||||
if canceled {
|
||||
// charTyped is a separate callback: the characters still
|
||||
// reach the text box, without the key's own effects. Options'
|
||||
// characters already passed the hook in place.
|
||||
if let Some(text) = typed.as_deref() {
|
||||
self.screen_chars_typed(Some(text));
|
||||
} else if self.screen == Screen::Options
|
||||
&& let Some(text) = key_event.text.as_deref()
|
||||
&& self.types_text()
|
||||
{
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
if !text.is_empty() {
|
||||
// Err only says no box of the screen takes it.
|
||||
let _ = self.type_chars_into_screen(&text);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Screen.keyPressed: Tab, the arrows and Enter move and press the
|
||||
// menu's focus, unless the focused widget or the screen keeps them.
|
||||
if let WindowEvent::KeyboardInput {
|
||||
@@ -15868,6 +16033,9 @@ impl Game<'_> {
|
||||
&& let PhysicalKey::Code(key) = key_event.physical_key
|
||||
&& self.key_focus_key(event_loop, key)
|
||||
{
|
||||
// The keyboard's focus took the key; its characters are a separate
|
||||
// callback and still come (`KeyboardHandler.charTyped`).
|
||||
self.screen_chars_typed(typed.as_deref());
|
||||
return;
|
||||
}
|
||||
let result: Result<()> = (|| {
|
||||
@@ -15903,6 +16071,7 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.key_focus_mouse_press(&event);
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
if !self.settings.quit_shortcuts
|
||||
@@ -16018,6 +16187,27 @@ impl Game<'_> {
|
||||
}) * wheel_sensitivity
|
||||
};
|
||||
let d = scaled(raw_y) as f32;
|
||||
// ScreenEvent.MouseScrolled.Pre: the open screen hears of
|
||||
// the wheel before anything scrolls, and a cancelled turn
|
||||
// scrolls nothing — no bundle, no list, slider or
|
||||
// scrollbar. The hotbar below reads the wheel only while
|
||||
// playing, which the gate excludes. A wheel the pad or the
|
||||
// keyboard's focus made up is not the mouse: it fires
|
||||
// nothing.
|
||||
if !self.pad_synthetic_input()
|
||||
&& let Some(screen) = self.screen_view_now()
|
||||
{
|
||||
let mut canceled = false;
|
||||
self.native_screen_mouse_scrolled_pre(
|
||||
&screen,
|
||||
[scaled(raw_x), scaled(raw_y)],
|
||||
glfw_modifiers(self.modifiers),
|
||||
&mut canceled,
|
||||
);
|
||||
if canceled {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if self.dialog_open() {
|
||||
self.dialog_scroll(d);
|
||||
} else if self.code_of_conduct.is_some() {
|
||||
@@ -16411,7 +16601,9 @@ impl Game<'_> {
|
||||
if let PhysicalKey::Code(key) = event.physical_key
|
||||
&& self.search_tab_hotbar_key(key)
|
||||
{
|
||||
// The hovered slot took the key (`ignoreTextInput`).
|
||||
// The hovered slot took the key, and its characters
|
||||
// go nowhere (`ignoreTextInput`).
|
||||
typed = None;
|
||||
} else if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
self.creative_search_shortcut(shortcut);
|
||||
} else if let PhysicalKey::Code(key) = event.physical_key {
|
||||
@@ -16853,6 +17045,10 @@ impl Game<'_> {
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
// ScreenEvent.CharacterTyped.Pre: GLFW's char callback comes after its
|
||||
// key callback, so a key's characters reach the screen the key left
|
||||
// open, after the key's own effects (`KeyboardHandler.charTyped`).
|
||||
self.screen_chars_typed(typed.as_deref());
|
||||
if let Err(e) = result {
|
||||
self.error = Some(e);
|
||||
self.exit_on_error(event_loop);
|
||||
@@ -17106,6 +17302,7 @@ impl<'a> Game<'a> {
|
||||
dynamic_lights: Vec::new(),
|
||||
luminance: None,
|
||||
see_through_vertices: Vec::new(),
|
||||
shadow_only_vertices: Default::default(),
|
||||
entity_spans: Default::default(),
|
||||
glowing_vertices: Default::default(),
|
||||
pack_ids: None,
|
||||
@@ -17117,6 +17314,7 @@ impl<'a> Game<'a> {
|
||||
pad: Default::default(),
|
||||
frames: 0,
|
||||
glint_frame: Default::default(),
|
||||
kept_block_entities: Default::default(),
|
||||
item_level_cache: Default::default(),
|
||||
world_frames: 0,
|
||||
fps: 0.0,
|
||||
@@ -17172,6 +17370,7 @@ impl<'a> Game<'a> {
|
||||
attack_ticks: 100,
|
||||
attack_item: None,
|
||||
last_hurt_by_player: HashMap::new(),
|
||||
lead_killers: HashMap::new(),
|
||||
love_cause: HashMap::new(),
|
||||
died: Default::default(),
|
||||
be_anims: {
|
||||
@@ -17180,6 +17379,7 @@ impl<'a> Game<'a> {
|
||||
anims
|
||||
},
|
||||
using: None,
|
||||
use_duration: 0,
|
||||
view: Default::default(),
|
||||
limiter: horizon_client::window::FramerateLimiter::new(0),
|
||||
frame_clock: Default::default(),
|
||||
@@ -17757,11 +17957,16 @@ impl Game<'_> {
|
||||
/// packs, and `Resources.rest` stays in use). This re-reads the tables,
|
||||
/// so it runs once per world open and on `/reload`.
|
||||
fn build_world_tables(&self) -> Option<WorldTables> {
|
||||
let files = functions::content_overlay(
|
||||
// The ask of this load, both here and on `/reload`: a native mod
|
||||
// recomputes the pack files it contributes every time.
|
||||
let mut contributed = Vec::new();
|
||||
self.native_data_pack_files(&mut contributed);
|
||||
let files = functions::overlay_files(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
&self.level.enabled_features,
|
||||
contributed,
|
||||
);
|
||||
if files.is_empty() {
|
||||
return None;
|
||||
@@ -18080,7 +18285,7 @@ fn gameplay_tables(
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (facts, gameplay_projection) =
|
||||
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json)
|
||||
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json, parallel)
|
||||
.map_err(|e| e.to_string())?;
|
||||
horizon_client::block_contact::set_mod_blocks(
|
||||
facts
|
||||
@@ -18122,6 +18327,8 @@ fn load_resources(
|
||||
publish: &mut dyn FnMut(UiResources, &std::path::Path),
|
||||
) -> Result<Loaded> {
|
||||
let threads = settings.horizon.worker_threads();
|
||||
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
|
||||
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_native_pack(settings));
|
||||
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
|
||||
// published at once so the title screen can appear while the world bundle
|
||||
// loads; the synchronous launch instead carries it in `Loaded`.
|
||||
@@ -18470,6 +18677,12 @@ fn prepare_renderer_textures(pack: &PackResources) -> horizon_client::gfx::Prepa
|
||||
&assets.sprite_mips,
|
||||
(&pack.forcefield.2, pack.forcefield.0),
|
||||
)
|
||||
.with_pbr(
|
||||
&assets.pbr_normals,
|
||||
&assets.pbr_specular,
|
||||
assets.pbr_size,
|
||||
assets.texture_layers,
|
||||
)
|
||||
}
|
||||
|
||||
/// Upload pack textures and create the renderer on the window's device.
|
||||
@@ -18492,6 +18705,8 @@ fn build_renderer(
|
||||
// Chinese and Japanese menu labels go blank when the loading screen hands
|
||||
// over to the game.
|
||||
atlas.font.reset_cache();
|
||||
// Made here when the worker did not: the renderer's own fallback has no LabPBR maps.
|
||||
let prepared = Some(prepared.unwrap_or_else(|| prepare_renderer_textures(pack)));
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
offscreen,
|
||||
@@ -18651,6 +18866,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
shadow_only: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
@@ -19929,6 +20145,7 @@ mod tests {
|
||||
velocity: Vec3::ZERO,
|
||||
age: 5,
|
||||
no_gravity: false,
|
||||
portal: Default::default(),
|
||||
drop_item: true,
|
||||
fall_distance: 0.0,
|
||||
stuck: Vec3::ZERO,
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,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()
|
||||
}
|
||||
@@ -2014,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", &[])),
|
||||
};
|
||||
@@ -2033,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" => {
|
||||
@@ -2125,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", &[])),
|
||||
};
|
||||
@@ -3569,6 +3569,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};
|
||||
|
||||
@@ -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);
|
||||
@@ -675,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" => {
|
||||
|
||||
@@ -1220,6 +1220,9 @@ impl Game<'_> {
|
||||
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 {
|
||||
@@ -1467,6 +1470,7 @@ impl Game<'_> {
|
||||
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");
|
||||
@@ -4891,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;
|
||||
@@ -4980,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,
|
||||
@@ -5482,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;
|
||||
@@ -5501,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,
|
||||
@@ -5536,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);
|
||||
@@ -5562,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -392,9 +392,7 @@ impl Game<'_> {
|
||||
// EnderEyeItem.use: startUsingItem (getUseDuration 0, so the use
|
||||
// lasts until the button lets go), then the eye is thrown towards
|
||||
// the nearest stronghold if there is one (CONSUME without).
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
self.start_using_item();
|
||||
self.throw_ender_eye();
|
||||
return Ok(true);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,9 @@ impl Game<'_> {
|
||||
// Spans tell the pack's passes which entities are hidden; without them, nothing changes.
|
||||
self.pack_shadow_casters = match (&self.renderer, &self.pack_ids) {
|
||||
(Some(r), Some(_)) => r.pack_shadow_casters(),
|
||||
// A native pack's shadow pass has no spans to hide entities with: it only needs
|
||||
// the first-person model.
|
||||
(Some(r), None) => (false, r.native_shadow()),
|
||||
_ => (false, false),
|
||||
};
|
||||
let at = camera.to_array().map(|v| v.floor() as i32);
|
||||
|
||||
@@ -5,18 +5,15 @@ use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::LevelEvent;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn finish_falling_block(&mut self, mut block: FallingBlock, pos: [i32; 3]) {
|
||||
/// `FallingBlockEntity.causeFallDamage`: impact damage and anvil wear,
|
||||
/// before block effects and portal handling can transfer the entity.
|
||||
pub(super) fn falling_block_impact(&mut self, block: &mut FallingBlock) {
|
||||
let name = self.res.facts().get(block.state).name.clone();
|
||||
let anvil = name.ends_with("anvil");
|
||||
let stalactite = matches!(
|
||||
&*name,
|
||||
"minecraft:pointed_dripstone" | "minecraft:sulfur_spike"
|
||||
);
|
||||
let sound_event = match &*name {
|
||||
"minecraft:pointed_dripstone" => Some(1045),
|
||||
"minecraft:sulfur_spike" => Some(1052),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(impact) = block.damage.impact(block.fall_distance) {
|
||||
if impact.amount > 0.0 {
|
||||
let mob_kind = if stalactite {
|
||||
@@ -79,6 +76,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_falling_block(&mut self, block: FallingBlock, pos: [i32; 3]) {
|
||||
let name = self.res.facts().get(block.state).name.clone();
|
||||
let anvil = name.ends_with("anvil");
|
||||
let sound_event = match &*name {
|
||||
"minecraft:pointed_dripstone" => Some(1045),
|
||||
"minecraft:sulfur_spike" => Some(1052),
|
||||
_ => None,
|
||||
};
|
||||
if block.damage.cancel_drop {
|
||||
if let Some(id) = sound_event {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
|
||||
@@ -184,8 +184,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `SpyglassItem.use`: start scoping.
|
||||
pub(super) fn use_spyglass(&mut self) {
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
if self.start_using_item() {
|
||||
let at = self.eye_position();
|
||||
self.audio.play("minecraft:item.spyglass.use", at, 1.0, 1.0);
|
||||
}
|
||||
|
||||
@@ -300,19 +300,33 @@ impl Game<'_> {
|
||||
let s = f.step().map(f64::from);
|
||||
let c = center(f);
|
||||
let at = [c[0] + s[0] * 0.15, c[1], c[2] + s[2] * 0.15];
|
||||
let mut drops = Vec::new();
|
||||
if with_frame {
|
||||
let frame = ItemStack {
|
||||
drops.push(ItemStack {
|
||||
item: Arc::from(f.kind()),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
self.spawn_item(frame, at, [0.0; 3], 10);
|
||||
});
|
||||
}
|
||||
if let Some(item) = f.item.clone()
|
||||
&& self.world.random.next_float() < f.drop_chance
|
||||
{
|
||||
self.spawn_item(item, at, [0.0; 3], 10);
|
||||
drops.push(item);
|
||||
}
|
||||
if by_player && !drops.is_empty() {
|
||||
let tool = self.held().cloned();
|
||||
self.native_use_drops(
|
||||
"",
|
||||
c,
|
||||
None,
|
||||
Some((f.id, f.kind())),
|
||||
tool.as_ref(),
|
||||
&mut drops,
|
||||
);
|
||||
}
|
||||
for stack in drops {
|
||||
self.spawn_item(stack, at, [0.0; 3], 10);
|
||||
}
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` for item frames: a frame within the blast
|
||||
|
||||
@@ -96,6 +96,48 @@ pub(super) fn content_overlay(
|
||||
DataStore::load(&packs).files.into_iter().collect()
|
||||
}
|
||||
|
||||
/// The files the content tables load from: the world's enabled packs'
|
||||
/// (`content_overlay`) with what native mods contribute for this load
|
||||
/// (`native_data_pack_files`) below them. `PackOverlay::top` is a path's last
|
||||
/// file, so a pack's file still overrides a mod's for the same path, and a
|
||||
/// later contribution overrides an earlier one, as packs do. `Game` builds
|
||||
/// its tables through here on a world's first open and on `/reload`, so the
|
||||
/// mods are asked once per load.
|
||||
pub(super) fn overlay_files(
|
||||
world_root: &std::path::Path,
|
||||
resources: &std::path::Path,
|
||||
vanilla_disabled: bool,
|
||||
enabled_features: &[String],
|
||||
contributed: Vec<(String, Vec<u8>)>,
|
||||
) -> Vec<(String, Vec<Vec<u8>>)> {
|
||||
let mut files = content_overlay(world_root, resources, vanilla_disabled, enabled_features);
|
||||
if contributed.is_empty() {
|
||||
return files;
|
||||
}
|
||||
// A path's mod files in contribution order, under the pack files.
|
||||
let mut mods: std::collections::BTreeMap<String, Vec<Vec<u8>>> = Default::default();
|
||||
for (path, bytes) in contributed {
|
||||
// One bad file would fail its whole table and drop the world's pack
|
||||
// content with it, so it is skipped alone: every kind is JSON.
|
||||
if let Err(e) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
log::warn!("Horizon: a native mod's data pack file {path} is not JSON, skipped: {e}");
|
||||
continue;
|
||||
}
|
||||
mods.entry(path).or_default().push(bytes);
|
||||
}
|
||||
for (path, versions) in files.iter_mut() {
|
||||
let Some(under) = mods.remove(path.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let mut merged = under;
|
||||
merged.append(versions);
|
||||
*versions = merged;
|
||||
}
|
||||
// The paths only a mod has.
|
||||
files.extend(mods);
|
||||
files
|
||||
}
|
||||
|
||||
impl FnState {
|
||||
/// The level function files compile at (`ServerFunctionLibrary`'s
|
||||
/// `functionCompilationPermissions`): `function-permission-level` on a
|
||||
@@ -948,4 +990,87 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a native mod contributes (`native_data_pack_files`) joins the load
|
||||
/// the tables are built from, a world's first open and `/reload` alike
|
||||
/// (`Game::build_world_tables` is both), and sits below the world's own
|
||||
/// packs. Each load asks again, so it is the current contribution that
|
||||
/// counts.
|
||||
#[test]
|
||||
fn contributed_files_join_the_load_below_the_worlds_packs() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-contributed-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let pack = dir.join("datapacks").join("pack");
|
||||
std::fs::create_dir_all(pack.join("data/pkg/tags/item")).unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(
|
||||
pack.join("data/pkg/tags/item/both.json"),
|
||||
r#"{"replace": true, "values": ["pkg:from_pack"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = serde_json::json!({"enabled": ["file/pack"], "disabled": []});
|
||||
std::fs::write(dir.join("datapacks.json"), settings.to_string()).unwrap();
|
||||
let tag = |entry: &str| format!(r#"{{"values": ["{entry}"]}}"#).into_bytes();
|
||||
let files = overlay_files(
|
||||
&dir,
|
||||
std::path::Path::new(""),
|
||||
true,
|
||||
&[],
|
||||
vec![
|
||||
("tags/pkg:item/both".to_owned(), tag("pkg:from_mod")),
|
||||
("tags/pkg:item/mod_only".to_owned(), tag("pkg:only_mod")),
|
||||
],
|
||||
);
|
||||
// The pack's file is the path's last (`PackOverlay::top`), and its
|
||||
// `replace` drops what the mod put below it.
|
||||
let (_, versions) = files
|
||||
.iter()
|
||||
.find(|(id, _)| id == "tags/pkg:item/both")
|
||||
.unwrap();
|
||||
assert!(horizon_world::loot::PackOverlay::top(versions).starts_with(b"{\"replace\""));
|
||||
let overlay = horizon_world::loot::PackOverlay { files: &files };
|
||||
let tags = horizon_items::items::load_tags(&dir, "item", &overlay).unwrap();
|
||||
assert!(tags["pkg:both"].contains("pkg:from_pack"));
|
||||
assert!(!tags["pkg:both"].contains("pkg:from_mod"));
|
||||
// A path only the mod has reaches the tables too.
|
||||
assert_eq!(
|
||||
tags["pkg:mod_only"].iter().collect::<Vec<_>>(),
|
||||
["pkg:only_mod"]
|
||||
);
|
||||
// The next load asks again: the new contribution, not the first's.
|
||||
let files = overlay_files(
|
||||
&dir,
|
||||
std::path::Path::new(""),
|
||||
true,
|
||||
&[],
|
||||
vec![("tags/pkg:item/mod_only".to_owned(), tag("pkg:second_load"))],
|
||||
);
|
||||
let overlay = horizon_world::loot::PackOverlay { files: &files };
|
||||
let tags = horizon_items::items::load_tags(&dir, "item", &overlay).unwrap();
|
||||
assert_eq!(
|
||||
tags["pkg:mod_only"].iter().collect::<Vec<_>>(),
|
||||
["pkg:second_load"]
|
||||
);
|
||||
// A file that isn't JSON is skipped alone, not the whole load.
|
||||
let files = overlay_files(
|
||||
&dir,
|
||||
std::path::Path::new(""),
|
||||
true,
|
||||
&[],
|
||||
vec![
|
||||
("tags/pkg:item/mod_only".to_owned(), b"{not json".to_vec()),
|
||||
("tags/pkg:item/good".to_owned(), tag("pkg:good")),
|
||||
],
|
||||
);
|
||||
let overlay = horizon_world::loot::PackOverlay { files: &files };
|
||||
let tags = horizon_items::items::load_tags(&dir, "item", &overlay).unwrap();
|
||||
assert!(!tags.contains_key("pkg:mod_only"));
|
||||
assert!(tags["pkg:good"].contains("pkg:good"));
|
||||
// With nothing contributed the overlay is the packs' own.
|
||||
assert_eq!(
|
||||
overlay_files(&dir, std::path::Path::new(""), true, &[], Vec::new()),
|
||||
content_overlay(&dir, std::path::Path::new(""), true, &[])
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,13 @@ impl Game<'_> {
|
||||
pub(super) fn ime_commit(&mut self, text: &str) {
|
||||
preedit::set_preedit(None);
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
// ScreenEvent.CharacterTyped.Pre: the input method's committed text
|
||||
// reaches a text box by characters, as a key's own text does, so the
|
||||
// open screen hears of each of them first and a cancelled one never
|
||||
// reaches the box.
|
||||
let Some(text) = self.screen_text_typed(&text) else {
|
||||
return;
|
||||
};
|
||||
if text.is_empty() || !self.text_field_focused() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -268,10 +268,13 @@ impl Game<'_> {
|
||||
&& self.stack_in(slot) == Some(stack)
|
||||
{
|
||||
let view = item_render::stack_view(stack);
|
||||
let duration = use_duration(&self.pack.item_renderer.models.components(&view));
|
||||
let mut duration = use_duration(&self.pack.item_renderer.models.components(&view));
|
||||
self.native_use_duration(stack, &mut duration);
|
||||
owner.using_this_item = true;
|
||||
owner.use_duration = duration;
|
||||
owner.use_item_remaining_ticks = duration - ticks as i32;
|
||||
// getUseItemRemainingTicks: the duration the use started with,
|
||||
// as LivingEntityUseItemEvent.Start left it (`start_using_item`).
|
||||
owner.use_item_remaining_ticks = self.use_duration - ticks as i32;
|
||||
owner.crossbow_charge_duration = Self::charge_ticks(stack).max(1) as i32;
|
||||
}
|
||||
owner.fishing_cast_with_this_item = stack
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! `LivingEntity.startUsingItem` and the use over time of items vanilla's
|
||||
//! `Item` subclasses don't know: what `Item.getUseDuration` answers, a
|
||||
//! native mod's own item counted down to its `finishUsingItem`, and the hand
|
||||
//! pose of a `getUseAnimation` (`native_use_duration`, `native_use_animation`,
|
||||
//! `native_living_entity_use_item_start`, `native_living_entity_use_item_finish`).
|
||||
use super::Game;
|
||||
use horizon_client::{items::ItemStack, spear::Kinetic};
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.startUsingItem` for the acting player's hand: nothing
|
||||
/// when a use runs already or the hand is empty. The use lasts
|
||||
/// `getUseDuration` ticks, which mods may change or refuse
|
||||
/// (`LivingEntityUseItemEvent.Start`), on both sides as in vanilla. True
|
||||
/// when the use started.
|
||||
pub(super) fn start_using_item(&mut self) -> bool {
|
||||
let Some(stack) = self.held().cloned().filter(|_| self.using.is_none()) else {
|
||||
return false;
|
||||
};
|
||||
let mut duration = self.use_duration(&stack);
|
||||
let mut canceled = false;
|
||||
let hand = self.hand;
|
||||
self.native_living_entity_use_item_start(&stack, None, hand, &mut duration, &mut canceled);
|
||||
if canceled {
|
||||
return false;
|
||||
}
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
self.use_duration = duration;
|
||||
true
|
||||
}
|
||||
/// `ItemStack.getUseDuration`: a consumable's time, 72000 for what is
|
||||
/// held until let go, the fixed times of brushes, bundles, spyglasses and
|
||||
/// goat horns, 0 for the rest, then a mod's answer.
|
||||
pub(super) fn use_duration(&self, stack: &ItemStack) -> i32 {
|
||||
let info = self.items().get(&stack.item);
|
||||
let mut duration = match &*stack.item {
|
||||
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" => 72_000,
|
||||
"minecraft:spyglass" => 1200,
|
||||
"minecraft:brush" => 200,
|
||||
_ if info.is_some_and(|i| i.bundle) => 200,
|
||||
_ if let Some(i) = self.res.items().instrument(stack) => i.use_ticks(),
|
||||
_ => match info {
|
||||
Some(i) if let Some(c) = &i.consumable => c.ticks(),
|
||||
Some(i) if i.blocks_attacks.is_some() => 72_000,
|
||||
_ if Kinetic::of(&stack.item).is_some() => 72_000,
|
||||
_ => 0,
|
||||
},
|
||||
};
|
||||
self.native_use_duration(stack, &mut duration);
|
||||
duration
|
||||
}
|
||||
/// Whether `stack` is a mod's item that only a mod uses over time: no
|
||||
/// `consumable`, `blocks_attacks` or kinetic weapon of vanilla's own, and
|
||||
/// a `native_use_duration` above 0. Its use is its own `Item.use`.
|
||||
pub(super) fn mod_use(&self, stack: &ItemStack) -> bool {
|
||||
!stack.item.starts_with("minecraft:")
|
||||
&& self
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.consumable.is_none() && i.blocks_attacks.is_none())
|
||||
&& Kinetic::of(&stack.item).is_none()
|
||||
&& self.use_duration(stack) > 0
|
||||
}
|
||||
/// A right click with a `mod_use` item: `startUsingItem` (CONSUME).
|
||||
pub(super) fn start_mod_use(&mut self, stack: &ItemStack) -> bool {
|
||||
if !self.mod_use(stack) {
|
||||
return false;
|
||||
}
|
||||
self.start_using_item();
|
||||
true
|
||||
}
|
||||
/// `LivingEntity.updateUsingItem` for a `mod_use` item used `ticks` so far:
|
||||
/// `completeUsingItem` once its duration ran out.
|
||||
pub(super) fn tick_mod_use(&mut self, slot: usize, ticks: u32, stack: &ItemStack) {
|
||||
if let Some(ticks) = count_down(ticks, self.use_duration) {
|
||||
self.using = Some((slot, ticks));
|
||||
return;
|
||||
}
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
// in ItemStack.finishUsingItem, Item's own gives the stack back.
|
||||
self.apply_use_cooldown(stack);
|
||||
self.use_item_finished(stack);
|
||||
}
|
||||
/// `LivingEntityUseItemEvent.Finish` after `finishUsingItem` of `stack`
|
||||
/// (no ticks left): the hand holds what a mod made the result.
|
||||
pub(super) fn use_item_finished(&mut self, stack: &ItemStack) {
|
||||
let mut result = self.held().cloned();
|
||||
let before = result.clone();
|
||||
let hand = self.hand;
|
||||
self.native_living_entity_use_item_finish(stack, None, hand, 0, &mut result);
|
||||
if result != before {
|
||||
*self.held_mut() = result.filter(|s| s.count > 0);
|
||||
}
|
||||
}
|
||||
/// `Player.canEat(canAlwaysEat)` for `stack`, food that is eaten only
|
||||
/// when hungry, then a mod's answer.
|
||||
pub(super) fn can_eat(&self, stack: &ItemStack, can_always_eat: bool) -> bool {
|
||||
let mut can_eat = can_always_eat
|
||||
|| self.creative
|
||||
|| self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.food.food < 20);
|
||||
self.native_can_eat(stack, &mut can_eat);
|
||||
can_eat
|
||||
}
|
||||
}
|
||||
|
||||
/// `Consumable.startConsuming`: a time of 0 or less is no use, the item is
|
||||
/// consumed at once (`onConsume`).
|
||||
pub(super) fn consumes_at_once(consume_ticks: i32) -> bool {
|
||||
consume_ticks <= 0
|
||||
}
|
||||
|
||||
/// `updateUsingItem`'s `--useItemRemaining == 0` for a use `ticks` old of
|
||||
/// `duration` ticks: the ticks used after this one, none on the tick it runs
|
||||
/// out. A time of 0 or less, or one a mod cut below what was used, never
|
||||
/// does: the use lasts until let go.
|
||||
pub(super) fn count_down(ticks: u32, duration: i32) -> Option<u32> {
|
||||
let ticks = ticks + 1;
|
||||
(i64::from(duration) - i64::from(ticks) != 0).then_some(ticks)
|
||||
}
|
||||
|
||||
/// The first person pose of a hand using an item of `animation`
|
||||
/// (`ItemInHandRenderer.renderArmWithItem`'s switch), for an item of no
|
||||
/// pose of its own.
|
||||
pub(super) fn use_pose(
|
||||
animation: UseAnimation,
|
||||
remaining: f32,
|
||||
duration: f32,
|
||||
partial: f32,
|
||||
) -> horizon_client::first_person::ItemPose {
|
||||
use horizon_client::first_person::ItemPose;
|
||||
let ticks_used = duration - (remaining - partial + 1.0);
|
||||
match animation {
|
||||
UseAnimation::Eat | UseAnimation::Drink => ItemPose::Eat {
|
||||
remaining,
|
||||
duration,
|
||||
partial,
|
||||
},
|
||||
UseAnimation::Bow => ItemPose::Bow { ticks_used },
|
||||
UseAnimation::Trident => ItemPose::Trident { ticks_used },
|
||||
UseAnimation::Brush => ItemPose::Brush { remaining, partial },
|
||||
_ => ItemPose::Using,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{consumes_at_once, count_down, use_pose};
|
||||
use horizon_client::first_person::ItemPose;
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
|
||||
#[test]
|
||||
fn a_use_runs_out_on_its_last_tick() {
|
||||
// a 32 tick food: ticks 0 to 30 go on, the 32nd tick finishes it.
|
||||
assert_eq!(count_down(0, 32), Some(1));
|
||||
assert_eq!(count_down(30, 32), Some(31));
|
||||
assert_eq!(count_down(31, 32), None);
|
||||
// a one tick use finishes on its first tick.
|
||||
assert_eq!(count_down(0, 1), None);
|
||||
// no time, or one below what was used, waits for the release.
|
||||
assert_eq!(count_down(0, 0), Some(1));
|
||||
assert_eq!(count_down(10, 4), Some(11));
|
||||
assert_eq!(count_down(5, 72_000), Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_started_use_finishes_after_its_duration() {
|
||||
// start at 0 ticks, count down each tick, finish when it runs out.
|
||||
for duration in [1, 16, 32, 40] {
|
||||
let (mut ticks, mut ticked) = (0, 0);
|
||||
while let Some(next) = count_down(ticks, duration) {
|
||||
ticks = next;
|
||||
ticked += 1;
|
||||
}
|
||||
assert_eq!(ticked + 1, duration);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_food_of_no_time_is_eaten_at_once() {
|
||||
assert!(consumes_at_once(0));
|
||||
assert!(consumes_at_once(-3));
|
||||
assert!(!consumes_at_once(1));
|
||||
assert!(!consumes_at_once(32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_use_animation_picks_the_hand_pose() {
|
||||
let pose = |animation| use_pose(animation, 12.0, 32.0, 0.5);
|
||||
assert!(matches!(
|
||||
pose(UseAnimation::Drink),
|
||||
ItemPose::Eat { remaining, duration, partial }
|
||||
if (remaining, duration, partial) == (12.0, 32.0, 0.5)
|
||||
));
|
||||
// ticks used: 32 - (12 - 0.5 + 1).
|
||||
assert!(
|
||||
matches!(pose(UseAnimation::Bow), ItemPose::Bow { ticks_used } if ticks_used == 19.5)
|
||||
);
|
||||
assert!(matches!(
|
||||
pose(UseAnimation::Trident),
|
||||
ItemPose::Trident { ticks_used } if ticks_used == 19.5
|
||||
));
|
||||
assert!(matches!(
|
||||
pose(UseAnimation::Brush),
|
||||
ItemPose::Brush { remaining, partial } if (remaining, partial) == (12.0, 0.5)
|
||||
));
|
||||
for animation in [
|
||||
UseAnimation::None,
|
||||
UseAnimation::Block,
|
||||
UseAnimation::Spyglass,
|
||||
] {
|
||||
assert!(matches!(pose(animation), ItemPose::Using));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ pub(super) struct GuestPlay {
|
||||
mining: Option<Mining>,
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_duration: i32,
|
||||
use_delay: u32,
|
||||
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
|
||||
pub(super) shield_cooldown: u32,
|
||||
@@ -172,6 +173,7 @@ pub(super) struct GuestSwap {
|
||||
mining: Option<Mining>,
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_duration: i32,
|
||||
use_delay: u32,
|
||||
pub(super) shield_cooldown: u32,
|
||||
/// The guest's item cooldowns (`Game::cooldowns` for its turn).
|
||||
@@ -412,6 +414,7 @@ impl Game<'_> {
|
||||
motion.sprinting = g.sprinting;
|
||||
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
|
||||
motion.flying = g.flying;
|
||||
motion.flying_speed = g.flying_speed;
|
||||
// FireworkRocketItem.use boosts only a gliding player.
|
||||
motion.fall_flying = g.fall_flying;
|
||||
motion.invulnerable = motion.may_fly;
|
||||
@@ -466,6 +469,7 @@ impl Game<'_> {
|
||||
let mining = std::mem::replace(&mut self.mining, play.mining.take());
|
||||
let destroy_delay = std::mem::replace(&mut self.destroy_delay, play.destroy_delay);
|
||||
let using = std::mem::replace(&mut self.using, play.using.take());
|
||||
let use_duration = std::mem::replace(&mut self.use_duration, play.use_duration);
|
||||
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
|
||||
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
|
||||
let cooldowns = std::mem::replace(&mut self.cooldowns, std::mem::take(&mut play.cooldowns));
|
||||
@@ -579,6 +583,7 @@ impl Game<'_> {
|
||||
mining,
|
||||
destroy_delay,
|
||||
using,
|
||||
use_duration,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
@@ -654,6 +659,7 @@ impl Game<'_> {
|
||||
mining,
|
||||
destroy_delay,
|
||||
using,
|
||||
use_duration,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
@@ -771,6 +777,7 @@ impl Game<'_> {
|
||||
play.mining = std::mem::replace(&mut self.mining, mining);
|
||||
play.destroy_delay = std::mem::replace(&mut self.destroy_delay, destroy_delay);
|
||||
play.using = std::mem::replace(&mut self.using, using);
|
||||
play.use_duration = std::mem::replace(&mut self.use_duration, use_duration);
|
||||
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
|
||||
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
|
||||
play.cooldowns = std::mem::replace(&mut self.cooldowns, cooldowns);
|
||||
@@ -1183,6 +1190,12 @@ impl Game<'_> {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// A mod menu type only for a client that has it: as
|
||||
// `ServerPlayer.openMenu` with no menu, nothing opens.
|
||||
let brand = host.hub.guest(conn).map_or("", |g| g.brand.as_str());
|
||||
if !horizon_client::containers::menu_reaches(brand, menu) {
|
||||
return false;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let hidden = self.native_hidden_slots(pos, items.len());
|
||||
@@ -2462,6 +2475,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a block outline occludes the straight line from `eye` to `point`.
|
||||
fn block_between(&self, eye: [f64; 3], point: [f64; 3]) -> bool {
|
||||
let d = [point[0] - eye[0], point[1] - eye[1], point[2] - eye[2]];
|
||||
let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if dist < 1e-9 {
|
||||
return false;
|
||||
}
|
||||
let dir = [d[0] / dist, d[1] / dist, d[2] / dist];
|
||||
self.clip_ray(eye, dir, dist).is_some_and(|h| {
|
||||
let hd = [
|
||||
h.point[0] - eye[0],
|
||||
h.point[1] - eye[1],
|
||||
h.point[2] - eye[2],
|
||||
];
|
||||
let hit_dist = (hd[0] * hd[0] + hd[1] * hd[1] + hd[2] * hd[2]).sqrt();
|
||||
hit_dist < dist - 1e-6
|
||||
})
|
||||
}
|
||||
|
||||
/// Aim the swapped-in player at an entity; returns where it aims and the
|
||||
/// eye. A boat, minecart, armor stand, item frame or painting is aimed at
|
||||
/// the point the client hit (the interact packet's location) when that
|
||||
@@ -2502,12 +2534,13 @@ impl Game<'_> {
|
||||
let on = (0..3).all(|k| (point[k] - inside[k]).abs() < 0.1);
|
||||
Some(if on { inside } else { middle })
|
||||
};
|
||||
let centre = match self.mobs.iter().find(|m| m.id as i32 == id) {
|
||||
Some(m) => [
|
||||
m.position.x,
|
||||
m.position.y + m.kind.height * 0.5,
|
||||
m.position.z,
|
||||
],
|
||||
let mob_box = self.mobs.iter().find(|m| m.id as i32 == id).map(|m| {
|
||||
let b = m.bounds();
|
||||
let (mn, mx) = (b.min_position(), b.max_position());
|
||||
([mn.x, mn.y, mn.z], [mx.x, mx.y, mx.z])
|
||||
});
|
||||
let centre = match mob_box {
|
||||
Some((lo, hi)) => visible_aim_point(eye, lo, hi, |p| self.block_between(eye, p)),
|
||||
None if self.object_target(id).is_some() => object()?,
|
||||
// A dragon part or an end crystal (game/dragon.rs).
|
||||
None => {
|
||||
@@ -2750,6 +2783,30 @@ impl Game<'_> {
|
||||
command: &str,
|
||||
signed: Option<Vec<(String, horizon_client::lan::hub::chat::ChatMessage)>>,
|
||||
) {
|
||||
// The mods hear every command first, and may stop it.
|
||||
let mut canceled = false;
|
||||
let mut replies = Vec::new();
|
||||
if let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) {
|
||||
self.native_player_command(
|
||||
uuid,
|
||||
&name,
|
||||
command.trim_start_matches('/'),
|
||||
&mut canceled,
|
||||
&mut replies,
|
||||
);
|
||||
}
|
||||
for line in replies {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if canceled {
|
||||
return;
|
||||
}
|
||||
if self.guest_message_command(host, conn, command, signed.as_ref()) {
|
||||
return;
|
||||
}
|
||||
@@ -3086,6 +3143,7 @@ impl Game<'_> {
|
||||
// restartClientLoadTimerAfterRespawn.
|
||||
g.dead = false;
|
||||
g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT;
|
||||
g.loading = true;
|
||||
g.death_time = 0;
|
||||
g.health = 20.0;
|
||||
g.food = 20;
|
||||
@@ -4735,3 +4793,140 @@ mod bundle_slot_tests {
|
||||
assert_eq!(bundle_inventory_slot(45, None), Some(45));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the server aims a guest's attack or interact on a mob. The packet names only the
|
||||
/// entity, so the server picked the mob's centre; a slab, trapdoor, stair or fence between the
|
||||
/// eyes and that centre then hid a mob the guest's own crosshair hit through the gap (#5772).
|
||||
/// This returns the centre when it is visible, else the visible point of the box nearest to it (the one nearer the eyes when two are equally near)
|
||||
/// (`occluded` tells whether a block outline hides a point from the eyes); the centre again
|
||||
/// when the whole box is hidden.
|
||||
fn visible_aim_point(
|
||||
eye: [f64; 3],
|
||||
lo: [f64; 3],
|
||||
hi: [f64; 3],
|
||||
occluded: impl Fn([f64; 3]) -> bool,
|
||||
) -> [f64; 3] {
|
||||
let centre = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
|
||||
if !occluded(centre) {
|
||||
return centre;
|
||||
}
|
||||
let margin = [0, 1, 2].map(|k| ((hi[k] - lo[k]) / 2.0).min(0.02));
|
||||
let across = |k: usize, steps: usize| {
|
||||
(0..=steps)
|
||||
.map(|i| {
|
||||
lo[k] + margin[k] + (hi[k] - lo[k] - 2.0 * margin[k]) * i as f64 / steps as f64
|
||||
})
|
||||
.collect::<Vec<f64>>()
|
||||
};
|
||||
let (xs, ys, zs) = (across(0, 2), across(1, 4), across(2, 2));
|
||||
let mut candidates = Vec::with_capacity(xs.len() * ys.len() * zs.len());
|
||||
for &x in &xs {
|
||||
for &y in &ys {
|
||||
for &z in &zs {
|
||||
candidates.push([x, y, z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let nearness = |p: &[f64; 3]| (0..3).map(|k| (p[k] - centre[k]).powi(2)).sum::<f64>();
|
||||
let from_eye = |p: &[f64; 3]| (0..3).map(|k| (p[k] - eye[k]).powi(2)).sum::<f64>();
|
||||
candidates.sort_by(|a, b| {
|
||||
nearness(a)
|
||||
.total_cmp(&nearness(b))
|
||||
.then(from_eye(a).total_cmp(&from_eye(b)))
|
||||
.then(a[1].total_cmp(&b[1]))
|
||||
.then(a[0].total_cmp(&b[0]))
|
||||
.then(a[2].total_cmp(&b[2]))
|
||||
});
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|p| !occluded(*p))
|
||||
.unwrap_or(centre)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod aim_point_tests {
|
||||
use super::visible_aim_point;
|
||||
|
||||
const EYE: [f64; 3] = [-760.7, 70.27, -450.4];
|
||||
// A zombie one block behind a top slab: its centre is above the 0.5 gap under the slab.
|
||||
const LO: [f64; 3] = [-763.0, 70.0, -450.9];
|
||||
const HI: [f64; 3] = [-762.4, 71.95, -450.3];
|
||||
|
||||
/// A top slab filling x in [-762, -761] with its underside at y 70.5: a point is hidden when
|
||||
/// the line from the eyes is above 70.5 anywhere inside that column.
|
||||
fn behind_top_slab(p: [f64; 3]) -> bool {
|
||||
let y_at = |x: f64| EYE[1] + (p[1] - EYE[1]) * (x - EYE[0]) / (p[0] - EYE[0]);
|
||||
p[0] < -762.0 && (y_at(-761.0) > 70.5 || y_at(-762.0) > 70.5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_visible_centre_is_kept() {
|
||||
let centre = [
|
||||
(LO[0] + HI[0]) / 2.0,
|
||||
(LO[1] + HI[1]) / 2.0,
|
||||
(LO[2] + HI[2]) / 2.0,
|
||||
];
|
||||
assert_eq!(visible_aim_point(EYE, LO, HI, |_| false), centre);
|
||||
}
|
||||
|
||||
/// A bottom slab hides everything under y 70.9: the aim goes above it, through the gap over the slab.
|
||||
#[test]
|
||||
fn a_mob_behind_a_bottom_slab_is_aimed_at_above_the_slab() {
|
||||
let centre = [
|
||||
(LO[0] + HI[0]) / 2.0,
|
||||
(LO[1] + HI[1]) / 2.0,
|
||||
(LO[2] + HI[2]) / 2.0,
|
||||
];
|
||||
let p = visible_aim_point(EYE, LO, HI, |pt| pt[1] < 70.9);
|
||||
assert!(p[1] >= 70.9, "{p:?} is still under the slab");
|
||||
assert!(p[1] > centre[1] - 0.5, "{p:?} is far from the centre");
|
||||
}
|
||||
|
||||
/// With only the centre hidden, two equally near candidates go to the one nearer the eyes.
|
||||
#[test]
|
||||
fn an_equally_near_point_on_the_side_of_the_eyes_wins() {
|
||||
let eye = [5.0, 70.5, 0.0];
|
||||
let (lo, hi) = ([-0.3, 70.0, -0.3], [0.3, 71.9, 0.3]);
|
||||
let centre = [
|
||||
(lo[0] + hi[0]) / 2.0,
|
||||
(lo[1] + hi[1]) / 2.0,
|
||||
(lo[2] + hi[2]) / 2.0,
|
||||
];
|
||||
let p = visible_aim_point(eye, lo, hi, |pt| pt == centre);
|
||||
assert!(p[0] > centre[0], "{p:?} is on the far side from the eyes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mob_seen_only_through_a_gap_is_aimed_at_through_the_gap() {
|
||||
let centre = [
|
||||
(LO[0] + HI[0]) / 2.0,
|
||||
(LO[1] + HI[1]) / 2.0,
|
||||
(LO[2] + HI[2]) / 2.0,
|
||||
];
|
||||
assert!(behind_top_slab(centre), "the slab hides the centre");
|
||||
let p = visible_aim_point(EYE, LO, HI, behind_top_slab);
|
||||
assert!(!behind_top_slab(p), "{p:?} is still hidden");
|
||||
for k in 0..3 {
|
||||
assert!(LO[k] <= p[k] && p[k] <= HI[k], "{p:?} is outside the mob");
|
||||
}
|
||||
assert!(p[1] < centre[1], "{p:?} is not lower than the centre");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fully_hidden_mob_keeps_the_centre() {
|
||||
let centre = [
|
||||
(LO[0] + HI[0]) / 2.0,
|
||||
(LO[1] + HI[1]) / 2.0,
|
||||
(LO[2] + HI[2]) / 2.0,
|
||||
];
|
||||
assert_eq!(visible_aim_point(EYE, LO, HI, |_| true), centre);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_choice_is_deterministic() {
|
||||
assert_eq!(
|
||||
visible_aim_point(EYE, LO, HI, behind_top_slab),
|
||||
visible_aim_point(EYE, LO, HI, behind_top_slab)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use horizon_client::lan::hub::Hub;
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_protocol::{
|
||||
entity_data::{Particle, ParticleData},
|
||||
entity_data::{MOD_PARTICLE_ID, Particle, ParticleData},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -28,6 +28,7 @@ fn send_native_nearby(
|
||||
host: &LanHost,
|
||||
dimension: &str,
|
||||
packet: Vec<u8>,
|
||||
horizon_only: bool,
|
||||
within_range: impl Fn([f64; 3]) -> bool,
|
||||
) {
|
||||
for guest in host
|
||||
@@ -36,10 +37,22 @@ fn send_native_nearby(
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.dimension == dimension && within_range(g.position))
|
||||
{
|
||||
// A vanilla client cannot decode a native mod's particle id; see
|
||||
// `mod_particle_only_for_horizon`.
|
||||
if horizon_only && guest.brand != "horizon" {
|
||||
continue;
|
||||
}
|
||||
guest.sender.send(packet.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// A native mod's particle travels as `MOD_PARTICLE_ID` (-1) plus its name.
|
||||
/// Vanilla reads the id with `IdMap.byIdOrThrow`, which throws on -1 and
|
||||
/// drops the connection, so such a particle may only go to Horizon clients.
|
||||
fn mod_particle_only_for_horizon(particle: &Particle) -> bool {
|
||||
matches!(particle.data, Some(ParticleData::Mod(_)))
|
||||
}
|
||||
|
||||
/// One of a packet's `count` particles (`handleParticleEvent`): its position
|
||||
/// and speed. DEFAULT (0) draws both from a Gaussian; ALTERNATIVE (1) and
|
||||
/// ALTERNATIVE_WITH_SPEED (2) spread over `[0, dist)`, the first keeping
|
||||
@@ -152,6 +165,7 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let horizon_only = mod_particle_only_for_horizon(&particle);
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
@@ -163,6 +177,9 @@ impl Game<'_> {
|
||||
randomization: 0,
|
||||
});
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
if horizon_only && g.brand != "horizon" {
|
||||
continue;
|
||||
}
|
||||
// ServerLevel compares the player's block centre, strictly within 32.
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
@@ -252,6 +269,13 @@ impl Game<'_> {
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
) -> Option<Particle> {
|
||||
// A native mod's own type: no registry id, so the sentinel and the name.
|
||||
if let Some(name) = &options.mod_particle {
|
||||
return Some(Particle {
|
||||
id: MOD_PARTICLE_ID,
|
||||
data: Some(ParticleData::Mod(name.clone())),
|
||||
});
|
||||
}
|
||||
let name = format!("minecraft:{}", options.ty.name());
|
||||
let id = hub.registry_id("minecraft:particle_type", &name)?;
|
||||
use ParticleType as T;
|
||||
@@ -292,6 +316,11 @@ impl Game<'_> {
|
||||
p: &Particle,
|
||||
at: [f64; 3],
|
||||
) -> Option<ParticleOptions> {
|
||||
if p.id == MOD_PARTICLE_ID
|
||||
&& let Some(ParticleData::Mod(name)) = &p.data
|
||||
{
|
||||
return Some(ParticleOptions::named(name.clone()));
|
||||
}
|
||||
let name = self
|
||||
.remote
|
||||
.as_ref()?
|
||||
@@ -434,6 +463,7 @@ impl Game<'_> {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let horizon_only = mod_particle_only_for_horizon(&particle);
|
||||
let body = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
@@ -447,7 +477,7 @@ impl Game<'_> {
|
||||
.encode();
|
||||
// BlockPos.closerToCenterThan(at, 32): from the centre of the
|
||||
// player's block.
|
||||
send_native_nearby(host, dimension, body, |position| {
|
||||
send_native_nearby(host, dimension, body, horizon_only, |position| {
|
||||
let d: f64 = (0..3)
|
||||
.map(|k| (position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
@@ -480,7 +510,7 @@ impl Game<'_> {
|
||||
)
|
||||
.encode();
|
||||
let range = f64::from(sound.volume.max(1.0)) * 16.0;
|
||||
send_native_nearby(host, sound.dimension, packet, |position| {
|
||||
send_native_nearby(host, sound.dimension, packet, false, |position| {
|
||||
let distance: f64 = (0..3).map(|k| (position[k] - sound.at[k]).powi(2)).sum();
|
||||
distance <= range * range
|
||||
});
|
||||
|
||||
@@ -112,13 +112,24 @@ impl Game<'_> {
|
||||
if !integrated && (!host.settings.guest_commands || !allowed) {
|
||||
return false;
|
||||
}
|
||||
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
|
||||
let Some((permission, uuid, name)) = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.map(|g| (g.permission, g.uuid, g.name.clone()))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let command = line.trim_start_matches('/');
|
||||
if !self.signable_arguments(permission, command).is_empty() {
|
||||
return false;
|
||||
}
|
||||
// The mods hear it as they hear a typed command; the batch is quiet,
|
||||
// so their replies go nowhere.
|
||||
let mut canceled = false;
|
||||
self.native_player_command(uuid, &name, command, &mut canceled, &mut Vec::new());
|
||||
if canceled {
|
||||
return false;
|
||||
}
|
||||
self.run_guest_command(GuestCommand {
|
||||
conn,
|
||||
command: command.to_owned(),
|
||||
|
||||
@@ -1205,8 +1205,14 @@ impl Game<'_> {
|
||||
None => {
|
||||
let mut handled = false;
|
||||
rules.edit(|b| handled = b.key(key, ctrl, false));
|
||||
if !handled && types_text {
|
||||
self.world_options_text(event.text.as_deref());
|
||||
// Only a key with text types: a `None` text is a
|
||||
// backspace to `world_options_text`, which is the
|
||||
// control socket's `\b`, not a key's.
|
||||
if !handled
|
||||
&& types_text
|
||||
&& let Some(text) = event.text.as_deref()
|
||||
{
|
||||
self.world_options_text(Some(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1312,8 +1318,13 @@ impl Game<'_> {
|
||||
o.port.shortcut(shortcut, &mut self.clipboard);
|
||||
return;
|
||||
}
|
||||
if !o.port.key(key, ctrl, false) && types_text {
|
||||
self.world_options_text(event.text.as_deref());
|
||||
// Only a key with text types (a `None` is a backspace to
|
||||
// `world_options_text`, the control socket's `\b`).
|
||||
if !o.port.key(key, ctrl, false)
|
||||
&& types_text
|
||||
&& let Some(text) = event.text.as_deref()
|
||||
{
|
||||
self.world_options_text(Some(text));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,14 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Leashable.dropLeash` / `removeLeash`: the lead falls as an item.
|
||||
pub(super) fn break_leash(&mut self, id: u32, drop: bool, sound: bool) {
|
||||
self.drop_leash(id, drop, sound, false);
|
||||
}
|
||||
/// `break_leash` by the acting player's use (untying it, shears).
|
||||
fn break_leash_used(&mut self, id: u32, drop: bool) {
|
||||
self.drop_leash(id, drop, false, true);
|
||||
}
|
||||
fn drop_leash(&mut self, id: u32, drop: bool, sound: bool, used: bool) {
|
||||
let killer = self.lead_killers.remove(&id).map(|(killer, _)| killer);
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
@@ -136,16 +144,54 @@ impl Game<'_> {
|
||||
// Leashable.tickLeash gates a dying entity's lead drop on entity_drops.
|
||||
let drop = drop && (m.alive() || self.ui.rules.bool("entity_drops"));
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
if sound {
|
||||
self.lead_sound("break", at);
|
||||
}
|
||||
if drop {
|
||||
if !drop {
|
||||
return;
|
||||
}
|
||||
// The mods' say when a player is behind it: the user, else the
|
||||
// killer of a mob that died leashed.
|
||||
let player = if used {
|
||||
Some(self.native_acting_id())
|
||||
} else {
|
||||
killer
|
||||
};
|
||||
let Some(player) = player else {
|
||||
self.drop_lead_item(at);
|
||||
return;
|
||||
};
|
||||
let mut drops = vec![ItemStack::new("minecraft:lead", 1)];
|
||||
let context = super::native_hooks::NativeLootDrops {
|
||||
table: "",
|
||||
dimension: self.dim.id(),
|
||||
origin: at,
|
||||
block: None,
|
||||
entity: Some((id, &kind)),
|
||||
player: Some(player),
|
||||
tool: None,
|
||||
explosion: None,
|
||||
used,
|
||||
};
|
||||
self.native_loot_drops(&context, &mut drops);
|
||||
for stack in drops {
|
||||
if stack.count > 0 && &*stack.item != "minecraft:air" {
|
||||
let v = super::dispenser::new_item_motion();
|
||||
self.spawn_item(stack, at, v, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Holders read from a save become real once the entity is found; a
|
||||
/// holder that never shows up drops the lead after 100 ticks.
|
||||
pub(super) fn tick_leads(&mut self) {
|
||||
// A corpse gone without dropping its lead takes its killer along.
|
||||
// Only this level's: the others' mobs aren't in place.
|
||||
if !self.lead_killers.is_empty() {
|
||||
let (mobs, dim) = (&self.mobs, self.dim);
|
||||
self.lead_killers
|
||||
.retain(|id, (_, at)| *at != dim || mobs.iter().any(|m| m.id == *id && !m.removed));
|
||||
}
|
||||
let player_uuid = self.level.player_uuid.unwrap_or([0, 0, 0, 1]);
|
||||
for i in 0..self.mobs.len() {
|
||||
let Some(pending) = self.mobs[i].extra.leash_pending else {
|
||||
@@ -222,7 +268,7 @@ impl Game<'_> {
|
||||
if item == "minecraft:shears" {
|
||||
let mut dropped = false;
|
||||
if self.mobs[index].extra.leash.is_some() {
|
||||
self.break_leash(id, true, false);
|
||||
self.break_leash_used(id, true);
|
||||
dropped = true;
|
||||
}
|
||||
let others: Vec<u32> = self
|
||||
@@ -232,7 +278,7 @@ impl Game<'_> {
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
for o in others {
|
||||
self.break_leash(o, true, false);
|
||||
self.break_leash_used(o, true);
|
||||
dropped = true;
|
||||
}
|
||||
let boats = self.boats_on(Holder::Mob(id));
|
||||
@@ -255,7 +301,7 @@ impl Game<'_> {
|
||||
.is_none_or(|p| p == self.acting_player_uuid())
|
||||
{
|
||||
let creative = self.creative;
|
||||
self.break_leash(id, !creative, false);
|
||||
self.break_leash_used(id, !creative);
|
||||
self.lead_game_event("minecraft:entity_interact", at);
|
||||
self.lead_sound("untied", at);
|
||||
return true;
|
||||
@@ -264,7 +310,7 @@ impl Game<'_> {
|
||||
let center = self.player_center();
|
||||
if self.can_attach_lead(index, center) {
|
||||
if self.mobs[index].extra.leash.is_some() {
|
||||
self.break_leash(id, true, false);
|
||||
self.break_leash_used(id, true);
|
||||
}
|
||||
let holder = self.acting_player_uuid();
|
||||
self.mobs[index].extra.leash = Some(Holder::Player);
|
||||
@@ -335,7 +381,7 @@ impl Game<'_> {
|
||||
if knot_hit && held_item.as_deref() == Some("minecraft:shears") {
|
||||
for i in existing {
|
||||
let id = self.mobs[i].id;
|
||||
self.break_leash(id, true, false);
|
||||
self.break_leash_used(id, true);
|
||||
}
|
||||
self.drop_boat_leashes(&existing_boats);
|
||||
self.audio
|
||||
|
||||
@@ -46,6 +46,8 @@ pub(super) struct Parked {
|
||||
tickets: ChunkTickets,
|
||||
random_tick_sections: RandomTickSections,
|
||||
level_events: Vec<LevelEvent>,
|
||||
/// Entity portal processes and cooldowns belong to this level's entities.
|
||||
portal_states: HashMap<u32, horizon_client::nether_portal::PortalState>,
|
||||
falling: Vec<FallingBlock>,
|
||||
paintings: Vec<horizon_client::entities::Painting>,
|
||||
item_frames: Vec<horizon_client::entities::ItemFrame>,
|
||||
@@ -179,6 +181,7 @@ impl Game<'_> {
|
||||
tickets: Default::default(),
|
||||
random_tick_sections: Default::default(),
|
||||
level_events: Vec::new(),
|
||||
portal_states: HashMap::new(),
|
||||
falling: Vec::new(),
|
||||
paintings: Vec::new(),
|
||||
item_frames: Vec::new(),
|
||||
@@ -233,6 +236,7 @@ impl Game<'_> {
|
||||
swap(&mut self.tickets, &mut p.tickets);
|
||||
swap(&mut self.random_tick_sections, &mut p.random_tick_sections);
|
||||
swap(&mut self.level_events, &mut p.level_events);
|
||||
swap(&mut self.portals.entities, &mut p.portal_states);
|
||||
swap(&mut self.falling, &mut p.falling);
|
||||
swap(&mut self.paintings, &mut p.paintings);
|
||||
swap(&mut self.item_frames, &mut p.item_frames);
|
||||
|
||||
@@ -183,7 +183,7 @@ impl Game<'_> {
|
||||
}
|
||||
let state = horizon_world::chunk::BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
properties: properties.into(),
|
||||
};
|
||||
match self.pack.assets.registry.resolve(&state) {
|
||||
Ok(id) => self.level_set(pos, id).is_ok(),
|
||||
|
||||
@@ -103,12 +103,14 @@ pub(super) fn content_root(
|
||||
}
|
||||
register_mob_effects(&mods);
|
||||
register_entity_types(&mods);
|
||||
register_menu_types(&mods);
|
||||
out
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: content mods not loaded: {e}");
|
||||
register_mob_effects(&[]);
|
||||
register_entity_types(&[]);
|
||||
register_menu_types(&[]);
|
||||
root.to_owned()
|
||||
}
|
||||
}
|
||||
@@ -161,6 +163,25 @@ fn register_entity_types(mods: &[horizon_client::mod_content::ContentMod]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// the mods' menu types, as their entity types: set once, from the startup
|
||||
/// root.
|
||||
fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
|
||||
use horizon_client::containers;
|
||||
let menus = horizon_client::mod_content::menu_types(mods);
|
||||
let set = containers::register_mod_menu_types(menus.iter().map(|m| {
|
||||
let results = m.results.iter().map(|&r| usize::from(r)).collect();
|
||||
(m.id.clone(), m.slots.into(), m.data_slots.into(), results)
|
||||
}));
|
||||
if !set
|
||||
&& containers::mod_menu_types()
|
||||
.iter()
|
||||
.map(|m| m.id)
|
||||
.ne(menus.iter().map(|m| m.id.as_str()))
|
||||
{
|
||||
log::warn!("Horizon: the mods' menu types changed; restart the game to use them");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
|
||||
const BUILD_INFO: &str = "native-mods.json";
|
||||
|
||||
|
||||
@@ -7,13 +7,16 @@ use super::{Dim, Game};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_assets::{
|
||||
bake::{bake_flat_face, default_uv},
|
||||
item_model::DisplayContext,
|
||||
model::{Direction, Material},
|
||||
};
|
||||
use horizon_client::{
|
||||
block_entity_render::{diffuse, diffuse_lights, vertex_light},
|
||||
containers::BlockEntity,
|
||||
containers::{BlockEntity, ModBlockEntity},
|
||||
fast_hash::FxHashMap,
|
||||
gfx::Cull,
|
||||
hud::Rect,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -50,9 +53,12 @@ pub struct BlockEntityDraw {
|
||||
/// (`LevelRenderer.getLightCoords`), 16 times sky light in bits 0..8 and
|
||||
/// 16 times block light in bits 8..16; set it to light a part otherwise.
|
||||
pub light: u32,
|
||||
/// The block drawn, for `native_draw_item`'s owner.
|
||||
block: [i32; 3],
|
||||
poses: Vec<Mat4>,
|
||||
lights: (Vec3, Vec3),
|
||||
vertices: Vec<Vertex>,
|
||||
kept: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -88,6 +94,19 @@ impl BlockEntityDraw {
|
||||
pub fn scale(&mut self, by: [f32; 3]) {
|
||||
self.apply(Mat4::from_scale(Vec3::from_array(by)));
|
||||
}
|
||||
/// Keeps what this call drew: the block is drawn the same again, with
|
||||
/// neither `native_block_entity_submit` nor
|
||||
/// `native_block_entity_render_bounding_box` called, until its state,
|
||||
/// its block entity (what the server sent), its light or the resource
|
||||
/// packs change. Call it only when what you drew reads none of
|
||||
/// `partial`, `game_time`, the blocks around it or anything else of
|
||||
/// yours, and moves nothing by itself: an idle machine, not a turning
|
||||
/// shaft. Kept geometry stands still: an enchanted item's glint, and an
|
||||
/// item drawn with `native_draw_item` whose model reads the time (a clock,
|
||||
/// a compass), stop where they were. API @API:block_entity_keep@.
|
||||
pub fn keep(&mut self) {
|
||||
self.kept = true;
|
||||
}
|
||||
fn apply(&mut self, m: Mat4) {
|
||||
if let Some(pose) = self.poses.last_mut() {
|
||||
*pose *= m;
|
||||
@@ -141,6 +160,98 @@ fn shaded(tint: u32, brightness: f32) -> u32 {
|
||||
0xFF00_0000 | c(0) << 16 | c(8) << 8 | c(16)
|
||||
}
|
||||
|
||||
/// Game ticks kept geometry stays after it was last drawn, as signs' text.
|
||||
const KEPT_FOR: i64 = 100;
|
||||
|
||||
/// What kept geometry was drawn with besides its block entity: drawn again
|
||||
/// when any of it changes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct KeptKey {
|
||||
dim: Dim,
|
||||
state: u32,
|
||||
light: u32,
|
||||
}
|
||||
|
||||
struct KeptEntry {
|
||||
key: KeptKey,
|
||||
entity: ModBlockEntity,
|
||||
bounds: [f32; 6],
|
||||
/// Game time it was last drawn.
|
||||
used: i64,
|
||||
/// Relative to the block's corner.
|
||||
vertices: Vec<Vertex>,
|
||||
}
|
||||
|
||||
/// Geometry mods kept (`BlockEntityDraw::keep`), by position. As
|
||||
/// `SignTexts` keeps a sign's text, it is kept relative to the block and
|
||||
/// moved to the camera each frame.
|
||||
#[derive(Default)]
|
||||
pub(super) struct KeptGeometry {
|
||||
entries: FxHashMap<[i32; 3], KeptEntry>,
|
||||
/// Game time of the last sweep of what is out of view.
|
||||
swept: i64,
|
||||
}
|
||||
|
||||
impl KeptGeometry {
|
||||
/// Drops what was not drawn for `KEPT_FOR` ticks, once every `KEPT_FOR`.
|
||||
fn sweep(&mut self, now: i64) {
|
||||
if (now - self.swept).abs() >= KEPT_FOR {
|
||||
self.swept = now;
|
||||
self.entries
|
||||
.retain(|_, e| (now - KEPT_FOR..=now).contains(&e.used));
|
||||
}
|
||||
}
|
||||
/// What is kept at `pos` when it was drawn with `key` and `entity`,
|
||||
/// marked drawn at `now`.
|
||||
fn get(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
key: KeptKey,
|
||||
entity: &ModBlockEntity,
|
||||
now: i64,
|
||||
) -> Option<&KeptEntry> {
|
||||
let entry = self
|
||||
.entries
|
||||
.get_mut(&pos)
|
||||
.filter(|e| e.key == key && e.entity == *entity)?;
|
||||
entry.used = now;
|
||||
Some(entry)
|
||||
}
|
||||
fn keep(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
key: KeptKey,
|
||||
entity: &ModBlockEntity,
|
||||
bounds: [f32; 6],
|
||||
vertices: &[Vertex],
|
||||
now: i64,
|
||||
) {
|
||||
let mut kept = self
|
||||
.entries
|
||||
.remove(&pos)
|
||||
.map_or_else(Vec::new, |e| e.vertices);
|
||||
kept.clear();
|
||||
kept.extend_from_slice(vertices);
|
||||
self.entries.insert(
|
||||
pos,
|
||||
KeptEntry {
|
||||
key,
|
||||
entity: entity.clone(),
|
||||
bounds,
|
||||
used: now,
|
||||
vertices: kept,
|
||||
},
|
||||
);
|
||||
}
|
||||
fn forget(&mut self, pos: [i32; 3]) {
|
||||
self.entries.remove(&pos);
|
||||
}
|
||||
/// Forgets everything: the atlas's sprites moved.
|
||||
pub(super) fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `native_block_entity_submit` for each of `blocks` (in drawn sections,
|
||||
/// drawn by no vanilla renderer) whose block entity is a mod's, closer than
|
||||
@@ -158,10 +269,15 @@ impl Game<'_> {
|
||||
partial,
|
||||
game_time: self.world.game_time,
|
||||
light: 0,
|
||||
block: [0; 3],
|
||||
poses: Vec::new(),
|
||||
lights: diffuse_lights(self.dim == Dim::Nether),
|
||||
vertices: std::mem::take(out),
|
||||
kept: false,
|
||||
};
|
||||
let now = self.world.game_time;
|
||||
let mut kept = self.kept_block_entities.borrow_mut();
|
||||
kept.sweep(now);
|
||||
for &(pos, state) in blocks {
|
||||
let corner = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
|
||||
// Vec3.closerThan: strictly closer.
|
||||
@@ -173,9 +289,27 @@ impl Game<'_> {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let origin = (corner - camera).as_vec3();
|
||||
let light = vertex_light(self.world.light(pos[0], pos[1], pos[2]));
|
||||
let key = KeptKey {
|
||||
dim: self.dim,
|
||||
state,
|
||||
light,
|
||||
};
|
||||
if let Some(entry) = kept.get(pos, key, entity, now) {
|
||||
if in_frustum(self.view_frustum.as_ref(), origin, entry.bounds) {
|
||||
let start = draw.vertices.len();
|
||||
self.mark_block_entity(start, state);
|
||||
draw.vertices.extend(entry.vertices.iter().map(|v| Vertex {
|
||||
pos: (Vec3::from_array(v.pos) + origin).to_array(),
|
||||
..*v
|
||||
}));
|
||||
self.cull_entity_vertices(&mut draw.vertices, start);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let id = BlockStateId(state);
|
||||
let block = &*self.res.facts().get(id).name;
|
||||
let origin = (corner - camera).as_vec3();
|
||||
let mut bounds = DEFAULT_BOUNDS;
|
||||
self.native_block_entity_render_bounding_box(pos, block, id, entity, &mut bounds);
|
||||
if !in_frustum(self.view_frustum.as_ref(), origin, bounds) {
|
||||
@@ -183,10 +317,22 @@ impl Game<'_> {
|
||||
}
|
||||
let start = draw.vertices.len();
|
||||
self.mark_block_entity(start, state);
|
||||
// Drawn relative to the block, then moved to the camera, so what
|
||||
// is kept can be moved again.
|
||||
draw.block = pos;
|
||||
draw.poses.clear();
|
||||
draw.poses.push(Mat4::from_translation(origin));
|
||||
draw.light = vertex_light(self.world.light(pos[0], pos[1], pos[2]));
|
||||
draw.poses.push(Mat4::IDENTITY);
|
||||
draw.light = light;
|
||||
draw.kept = false;
|
||||
self.native_block_entity_submit(pos, block, id, entity, &mut draw);
|
||||
if draw.kept {
|
||||
kept.keep(pos, key, entity, bounds, &draw.vertices[start..], now);
|
||||
} else {
|
||||
kept.forget(pos);
|
||||
}
|
||||
for v in &mut draw.vertices[start..] {
|
||||
v.pos = (Vec3::from_array(v.pos) + origin).to_array();
|
||||
}
|
||||
self.cull_entity_vertices(&mut draw.vertices, start);
|
||||
}
|
||||
*out = draw.vertices;
|
||||
@@ -290,6 +436,57 @@ impl Game<'_> {
|
||||
self.block_model_vertices(id, 42, pose, draw.light, true, &mut draw.vertices);
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws `stack` under `draw`'s pose as an item is drawn in the world
|
||||
/// (`ItemModelResolver.updateForTopItem` and `ItemStackRenderState.submit`), centred on the
|
||||
/// pose's origin, in its model's `display` transform: `Fixed` as a
|
||||
/// campfire's or an item frame's, `Ground` as a dropped item's. `seed`
|
||||
/// picks among random models (a campfire passes its slot plus its
|
||||
/// position's hash). `owner_yaw` makes the block entity the item's
|
||||
/// owner (`ItemOwner`), at the block's centre and turned that many
|
||||
/// degrees (`getVisualRotationYInDegrees`), as a shelf is: a compass on
|
||||
/// it points to its target from there. None draws it with no owner, as
|
||||
/// a campfire does. False for an empty stack or one with no model. The
|
||||
/// client's copy of a block entity has no item slots (`entity.items` is
|
||||
/// empty there): send the stacks you draw in
|
||||
/// `native_block_entity_update_tag` and read them from `entity.nbt`.
|
||||
/// API @API:block_entity_draw_item@.
|
||||
pub(super) fn native_draw_item(
|
||||
&self,
|
||||
draw: &mut BlockEntityDraw,
|
||||
stack: &ItemStack,
|
||||
display: DisplayContext,
|
||||
owner_yaw: Option<f32>,
|
||||
seed: i32,
|
||||
) -> bool {
|
||||
// ShelfBlockEntity.position(): Vec3.atCenterOf(getBlockPos()).
|
||||
let owner = owner_yaw
|
||||
.map(|yaw| super::item_draw::owner_at(draw.block.map(|c| f64::from(c) + 0.5), yaw, ""));
|
||||
let item = self.resolve_item(stack, display, owner.as_ref(), seed);
|
||||
if item.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let pose = draw.pose();
|
||||
self.submit_item_world(&item, pose, draw.light, &mut draw.vertices);
|
||||
true
|
||||
}
|
||||
|
||||
/// Property `key` of block state `state` (a machine's `facing`, from the
|
||||
/// `state` `native_block_entity_submit` gets), if it has one: no lookup in
|
||||
/// the world, so cheap each frame. API @API:block_entity_draw_item@.
|
||||
pub(super) fn native_block_state_property(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
key: &str,
|
||||
) -> Option<&str> {
|
||||
self.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)?
|
||||
.properties
|
||||
.get(key)
|
||||
.map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -301,9 +498,11 @@ mod tests {
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
light: 0xF0F0,
|
||||
block: [0; 3],
|
||||
poses: vec![Mat4::from_translation(Vec3::new(10.0, 0.0, 0.0))],
|
||||
lights: diffuse_lights(false),
|
||||
vertices: Vec::new(),
|
||||
kept: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,4 +587,81 @@ mod tests {
|
||||
// The vertex colour is RGBA8 read little-endian: red in the low byte.
|
||||
assert_eq!(shaded(0x10_20_30, 1.0), 0xFF_30_20_10);
|
||||
}
|
||||
|
||||
fn machine(progress: i32) -> ModBlockEntity {
|
||||
ModBlockEntity {
|
||||
id: "mymod:machine".into(),
|
||||
data: vec![progress],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn vertex(x: f32) -> Vertex {
|
||||
Vertex {
|
||||
pos: [x, 0.0, 0.0],
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}
|
||||
}
|
||||
|
||||
const KEY: KeptKey = KeptKey {
|
||||
dim: Dim::Overworld,
|
||||
state: 7,
|
||||
light: 0xF0F0,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn kept_geometry_is_drawn_again_until_what_it_was_drawn_with_changes() {
|
||||
let mut kept = KeptGeometry::default();
|
||||
let pos = [1, 2, 3];
|
||||
kept.keep(pos, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.5)], 0);
|
||||
let entry = kept.get(pos, KEY, &machine(0), 1).expect("kept");
|
||||
assert_eq!(entry.vertices.len(), 1);
|
||||
assert_eq!(entry.vertices[0].pos, [0.5, 0.0, 0.0]);
|
||||
assert_eq!(entry.bounds, DEFAULT_BOUNDS);
|
||||
// New data from the server, another state, light or dimension, or
|
||||
// another position: none of them is the kept geometry.
|
||||
assert!(kept.get(pos, KEY, &machine(1), 1).is_none());
|
||||
for changed in [
|
||||
KeptKey { state: 8, ..KEY },
|
||||
KeptKey {
|
||||
light: 0x00F0,
|
||||
..KEY
|
||||
},
|
||||
KeptKey {
|
||||
dim: Dim::Nether,
|
||||
..KEY
|
||||
},
|
||||
] {
|
||||
assert!(kept.get(pos, changed, &machine(0), 1).is_none());
|
||||
}
|
||||
assert!(kept.get([1, 2, 4], KEY, &machine(0), 1).is_none());
|
||||
// Drawn again and kept with the new data, it replaces the old.
|
||||
kept.keep(pos, KEY, &machine(1), DEFAULT_BOUNDS, &[vertex(0.25)], 2);
|
||||
assert!(kept.get(pos, KEY, &machine(0), 2).is_none());
|
||||
let entry = kept.get(pos, KEY, &machine(1), 2).expect("kept again");
|
||||
assert_eq!(entry.vertices[0].pos, [0.25, 0.0, 0.0]);
|
||||
// Drawn without keeping: forgotten.
|
||||
kept.forget(pos);
|
||||
assert!(kept.get(pos, KEY, &machine(1), 3).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_geometry_out_of_view_for_its_ticks_is_swept() {
|
||||
let mut kept = KeptGeometry::default();
|
||||
let (seen, gone) = ([0, 0, 0], [5, 0, 0]);
|
||||
kept.keep(seen, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.0)], 0);
|
||||
kept.keep(gone, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.0)], 0);
|
||||
kept.get(gone, KEY, &machine(0), 50);
|
||||
// A sweep at 100 keeps both: each was drawn in the last 100 ticks.
|
||||
kept.sweep(100);
|
||||
assert!(kept.entries.contains_key(&gone));
|
||||
assert!(kept.entries.contains_key(&seen));
|
||||
kept.get(seen, KEY, &machine(0), 150);
|
||||
// At 199 no sweep is due yet; at 200 the one last drawn at 50 goes.
|
||||
kept.sweep(199);
|
||||
assert!(kept.entries.contains_key(&gone));
|
||||
kept.sweep(200);
|
||||
assert!(!kept.entries.contains_key(&gone));
|
||||
assert!(kept.get(seen, KEY, &machine(0), 200).is_some());
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,7 @@ impl Game<'_> {
|
||||
stuck: Vec3::ZERO,
|
||||
damage,
|
||||
saved: None,
|
||||
portal: Default::default(),
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
@@ -289,6 +289,7 @@ use horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud::Corners,
|
||||
fast_hash::FxHashMap,
|
||||
first_person::Hand,
|
||||
health::{HurtSource, Hurting},
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
@@ -304,6 +305,7 @@ use horizon_core::{
|
||||
ticks::{ScheduleResult, TickPriority},
|
||||
};
|
||||
use horizon_world::{
|
||||
consumable::UseAnimation,
|
||||
fluid::{FluidKind, FluidState},
|
||||
level::{BlockId, LevelAccess, PlacementContext, Ticks},
|
||||
nbt::Compound,
|
||||
@@ -676,7 +678,9 @@ pub(crate) struct NativeLootDrops<'a> {
|
||||
pub(crate) origin: [f64; 3],
|
||||
/// The block broken: its position and state.
|
||||
pub(crate) block: Option<([i32; 3], BlockStateId)>,
|
||||
/// The mob that died: its entity ID and type (`minecraft:zombie`).
|
||||
/// The mob that died, or the entity a player's use dropped them from (the
|
||||
/// sheep sheared, the item frame broken): its entity ID and type
|
||||
/// (`minecraft:zombie`).
|
||||
pub(crate) entity: Option<(u32, &'a str)>,
|
||||
/// The player who broke the block or killed the mob, when known. A mob
|
||||
/// names one only while it remembers the player's hit (100 ticks), so one
|
||||
@@ -686,6 +690,16 @@ pub(crate) struct NativeLootDrops<'a> {
|
||||
pub(crate) tool: Option<&'a ItemStack>,
|
||||
/// The radius of the explosion that broke the block.
|
||||
pub(crate) explosion: Option<f32>,
|
||||
/// A player's use dropped them rather than a break or a death: shears on a
|
||||
/// sheep, a mooshroom, a snow golem or a bogged, a brush on an armadillo or
|
||||
/// a suspicious block, shears carving a pumpkin, picking sweet berries or
|
||||
/// cave vines, a hit taking an item frame or its item, or untying a lead
|
||||
/// or cutting it with shears. `player` names them; `block` or `entity` says
|
||||
/// what dropped them. API @API:use_drops@. A mob that a player killed while
|
||||
/// leashed drops its lead on its next tick as a call of its own (`entity`
|
||||
/// the mob, `player` its killer, `table` empty, `used` false). API
|
||||
/// @API:lead_drops@.
|
||||
pub(crate) used: bool,
|
||||
}
|
||||
|
||||
/// A depth-tested outline of the client's block selection shape.
|
||||
@@ -802,10 +816,12 @@ pub struct NativeScreen {
|
||||
pub own_labels: bool,
|
||||
}
|
||||
|
||||
/// The open screen, for the screen hooks (`native_screen_render_post`,
|
||||
/// `native_screen_mouse_button_pressed_pre`, `..._released_pre`): what it is,
|
||||
/// its menu and title, where its container panel is, how many slots its menu
|
||||
/// has, and where the mouse and the frame's partial tick are.
|
||||
/// The open screen, for the screen hooks (`native_screen_render_post`, the
|
||||
/// mouse ones `native_screen_mouse_button_pressed_pre`, `..._released_pre` and
|
||||
/// `native_screen_mouse_scrolled_pre`, and the key ones
|
||||
/// `native_screen_key_pressed_pre` and `native_screen_char_typed_pre`): what
|
||||
/// it is, its menu and title, where its container panel is, how many slots its
|
||||
/// menu has, and where the mouse and the frame's partial tick are.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeScreenView<'a> {
|
||||
@@ -888,6 +904,17 @@ pub(crate) struct NativePlayer {
|
||||
pub(crate) dimension: &'static str,
|
||||
}
|
||||
|
||||
/// The world's clock as a client mod reads it (`native_clock`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct NativeClock {
|
||||
/// Ticks since the world was created (`Level.getGameTime`).
|
||||
pub(crate) game_time: i64,
|
||||
/// The time of day in ticks (`Level.getOverworldClockTime`): 0 at sunrise, 24000 a
|
||||
/// day. It keeps counting past 24000 (`/time add`, days going by), so take
|
||||
/// it modulo 24000 for the hour; `/time set` can make it negative.
|
||||
pub(crate) day_time: i64,
|
||||
}
|
||||
|
||||
/// A native mod as Options > Mods lists it (`native_mods`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct NativeMod {
|
||||
@@ -1074,16 +1101,61 @@ const MAPPINGS: [(&str, Option<KeyCode>); 9] = [
|
||||
("key.use", None),
|
||||
];
|
||||
|
||||
/// What a `ScreenEvent.CharacterTyped.Pre` leaves of `text`, given `canceled`
|
||||
/// (whether a hook cancelled character `c`): the cancelled printable
|
||||
/// characters are gone and every other character stays, control characters
|
||||
/// included — a text box reads Backspace, Delete or Escape as keys and vanilla
|
||||
/// fires no `charTyped` for them, so they are never offered to the hook.
|
||||
/// `None` when nothing is left to type.
|
||||
fn screen_text_hooked(text: &str, mut canceled: impl FnMut(char) -> bool) -> Option<String> {
|
||||
let mut left = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
if !c.is_control() && canceled(c) {
|
||||
continue;
|
||||
}
|
||||
left.push(c);
|
||||
}
|
||||
if left.is_empty() && !text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(left)
|
||||
}
|
||||
|
||||
/// What a key press's characters leave for a text box after the
|
||||
/// `ScreenEvent.CharacterTyped.Pre` hook: the characters no mod cancelled,
|
||||
/// control ones gone (the key's own handling takes those, and vanilla fires
|
||||
/// no `charTyped` for them). `None` when nothing is left to type.
|
||||
fn chars_to_type(text: &str, canceled: impl FnMut(char) -> bool) -> Option<String> {
|
||||
let left = screen_text_hooked(text, canceled)?;
|
||||
let text: String = left.chars().filter(|c| !c.is_control()).collect();
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
/// The hook points. Each does nothing in Horizon.
|
||||
impl Game<'_> {
|
||||
/// Once a frame, in a world or not, after Horizon's extras.
|
||||
pub(super) fn native_frame(&mut self, elapsed: Duration) {
|
||||
let _ = elapsed;
|
||||
}
|
||||
/// Render-thread notification after a resource-pack reload has applied successfully.
|
||||
/// Atlas, models, renderer and client caches already refer to the new resources.
|
||||
/// Selection/F3+T and server-pack reloads share this point; initial startup does not.
|
||||
pub(super) fn native_client_resources_reloaded(&mut self) {}
|
||||
/// Once a tick, in every `Game`: the window's (in single player, on LAN
|
||||
/// and on servers), and the server's own (`native_window` is false
|
||||
/// there: the single player server thread, a dedicated server).
|
||||
pub(super) fn native_tick(&mut self) {}
|
||||
/// Before an eligible targeted block's selection outline is built.
|
||||
/// Cancel skips only the default block highlight, after normal HUD and game-mode gates.
|
||||
pub(super) fn native_block_highlight(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
partial: f32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (pos, state, partial, canceled);
|
||||
}
|
||||
/// The world frame's local visual sources. Push requests; Horizon validates
|
||||
/// and limits them before rendering. Called only while a world is visible.
|
||||
pub(super) fn native_visuals(
|
||||
@@ -1111,6 +1183,22 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (context, phase, error_text);
|
||||
}
|
||||
/// The server is assembling its data tables from the world's data packs
|
||||
/// (`ReloadableServerResources`): a world is opening, or `/reload` was
|
||||
/// run, and the recipes, tags, loot and advancements are built after
|
||||
/// this. Push `(path, bytes)` pack files and they join this load. `path`
|
||||
/// is the overlay's key, `<kind>/<namespace>:<path>` without `.json`, over
|
||||
/// the kinds the tables read: `recipe/mymod:thing`,
|
||||
/// `tags/mymod:block/thing`, `loot_table/…`, `advancement/…`,
|
||||
/// `trade_set/…`, `villager_trade/…`, `dialog/…`. A file that isn't JSON
|
||||
/// is skipped with a warning. Asked once per load on both paths, so this
|
||||
/// is where a mod recomputes them.
|
||||
/// Contributed files sit below the world's own data packs: a pack's file
|
||||
/// for the same path still overrides a mod's. Server-side (the single
|
||||
/// player's integrated server included). API @API:data_pack_files@.
|
||||
pub(super) fn native_data_pack_files(&self, files: &mut Vec<(String, Vec<u8>)>) {
|
||||
let _ = files;
|
||||
}
|
||||
/// The server is about to open its worlds: nothing of the world is
|
||||
/// loaded yet (NeoForge's `ServerAboutToStartEvent`, "called before the
|
||||
/// server begins loading anything"). Vanilla's `MinecraftServer.loadLevel`
|
||||
@@ -1251,6 +1339,21 @@ impl Game<'_> {
|
||||
pub(super) fn native_camera_shake(&self, partial: f32, shake: &mut NativeShake) {
|
||||
let _ = (partial, shake);
|
||||
}
|
||||
/// Before one first-person hand and its displayed stack are drawn (RenderHandEvent).
|
||||
/// Equip progress is the inverse arm height, including the item's swap animation scale.
|
||||
/// Cancel skips that hand's default geometry, including its map and arm geometry.
|
||||
/// It only hides: the hook has no way to draw a replacement.
|
||||
pub(super) fn native_render_hand(
|
||||
&self,
|
||||
hand: horizon_client::first_person::Hand,
|
||||
stack: Option<&ItemStack>,
|
||||
partial: f32,
|
||||
swing: f32,
|
||||
equip: f32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (hand, stack, partial, swing, equip, canceled);
|
||||
}
|
||||
|
||||
/// The values native mods hand their shader packs in this frame,
|
||||
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
|
||||
@@ -1330,9 +1433,11 @@ impl Game<'_> {
|
||||
/// set `container` when the block is yours. Asked once for each mod
|
||||
/// block when the ticking table is built; at most 256 slots, and slot
|
||||
/// numbers past the count are left out. Its `menu`, one of
|
||||
/// `containers::MOD_MENUS` with as many slots, opens when the block is
|
||||
/// used (its template must open a menu, as a chest or a furnace does);
|
||||
/// another is left out. API 17.
|
||||
/// `containers::MOD_MENUS` or a content mod's menu type
|
||||
/// (`data/<id>/horizon/menu/`, API @API:mod_menu_types@) with as many
|
||||
/// slots, opens when the block is used (its template must open a menu, as
|
||||
/// a chest or a furnace does); another is left out. A mod menu type shows
|
||||
/// on Horizon clients through the mod's `native_screen` only. API 17.
|
||||
pub(super) fn native_block_entity_container(
|
||||
&self,
|
||||
block: &str,
|
||||
@@ -1396,12 +1501,13 @@ impl Game<'_> {
|
||||
/// (vanilla's `BlockEntityRenderer.submit`: within 64 blocks, its chunk
|
||||
/// faded in, and its bounds, `native_block_entity_render_bounding_box`,
|
||||
/// in the frustum), when its `id` is yours. `state` is the block's state
|
||||
/// (`blockEntity.getBlockState()`), `entity` holds what the server sent
|
||||
/// (`blockEntity.getBlockState()`: read its `facing` with
|
||||
/// `native_block_state_property`), `entity` holds what the server sent
|
||||
/// (`native_block_entity_update_tag`). Add geometry with
|
||||
/// `native_draw_quad`, `native_draw_cube` and `native_draw_block` under
|
||||
/// `draw`'s pose stack, which starts at the block's lower north-west
|
||||
/// corner, in blocks; `draw` also has the partial tick, the game time and
|
||||
/// the block's light. API 71.
|
||||
/// `native_draw_quad`, `native_draw_cube`, `native_draw_block` and
|
||||
/// `native_draw_item` under `draw`'s pose stack, which starts at the
|
||||
/// block's lower north-west corner, in blocks; `draw` also has the partial
|
||||
/// tick, the game time and the block's light. API 71.
|
||||
pub(super) fn native_block_entity_submit(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
@@ -1524,6 +1630,105 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (screen, button, modifiers, canceled);
|
||||
}
|
||||
/// The wheel turned over the open screen (`ScreenEvent.MouseScrolled.Pre`,
|
||||
/// vanilla's `Screen.mouseScrolled`): `screen` is the screen it is,
|
||||
/// `screen.mouse` where the pointer is (so there is no position of its
|
||||
/// own), `scroll` the wheel's movement in x and y as the game's own scroll
|
||||
/// handler scales it (a line times the wheel sensitivity: with Discrete
|
||||
/// Scrolling on, a notch is ±1, its sign only; a touchpad's pixels are
|
||||
/// divided by 30), and
|
||||
/// `modifiers` GLFW's bits (shift 1, control 2, alt 4, super 8). Set
|
||||
/// `canceled` and nothing the screen would scroll moves: not the bundle
|
||||
/// under the pointer, nor a list, slider, scrollbar or map. Nothing else
|
||||
/// reads the wheel while a screen of the game's is open, so no hotbar slot
|
||||
/// changes either.
|
||||
///
|
||||
/// It fires before the screen scrolls anything, for the window's wheel
|
||||
/// (horizon-ctl's `scroll-at` sends it through the window too), and
|
||||
/// only while a screen of the game's is open (Options and the pack list
|
||||
/// opened from the title screen count, with no player and no `menu`). It
|
||||
/// does not fire on the title screen itself, the connect and loading
|
||||
/// screens, nor for a server dialog, the server pack, link and
|
||||
/// code-of-conduct prompts, a book or a sign editor drawn over gameplay,
|
||||
/// nor while a world loads or saves: the scroll there stays the game's
|
||||
/// own. A wheel a gamepad or the keyboard's focus sends is not the mouse
|
||||
/// and fires nothing.
|
||||
/// API @API:screen_mouse_scrolled@.
|
||||
pub(super) fn native_screen_mouse_scrolled_pre(
|
||||
&self,
|
||||
screen: &NativeScreenView<'_>,
|
||||
scroll: [f64; 2],
|
||||
modifiers: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (screen, scroll, modifiers, canceled);
|
||||
}
|
||||
/// A key went down over the open screen (`ScreenEvent.KeyPressed.Pre`,
|
||||
/// vanilla's `Screen.keyPressed`): `screen` is the screen it is, `key`
|
||||
/// vanilla's name for the key (`key.keyboard.e`, the name `native_key`
|
||||
/// hands over) — a key with no name fires nothing — `repeat` whether it is
|
||||
/// the keyboard's auto-repeat of a held press (Horizon's extra; NeoForge
|
||||
/// folds repeats into the event), and `modifiers` GLFW's bits (shift 1,
|
||||
/// control 2, alt 4, super 8). Set `canceled` and the press goes no
|
||||
/// further: no focus move, no Escape-close, no inventory or hotbar key, no
|
||||
/// typing by the key itself, no `native_key`.
|
||||
///
|
||||
/// It fires for the window's presses and their repeats, and for the
|
||||
/// control socket's `key` (a release fires nothing), after the window's
|
||||
/// own shortcuts have run — the fullscreen and screenshot keys, the debug
|
||||
/// keys and the narrator — and only while a screen of the game's is open
|
||||
/// (Options and the pack list opened from the title screen count, with no
|
||||
/// player and no `menu`). It does not fire on the title screen itself, the
|
||||
/// connect and loading screens, nor for a server dialog, the server pack,
|
||||
/// link and code-of-conduct prompts, a book or a sign editor drawn over
|
||||
/// gameplay, nor while a world loads or saves: the key there stays the
|
||||
/// game's own. A cancelled press's release still runs, so a `native_key`
|
||||
/// listener sees that release without its press, and its characters are
|
||||
/// GLFW's separate char callback and still come; see
|
||||
/// `native_screen_char_typed_pre`.
|
||||
/// API @API:screen_key_input@.
|
||||
pub(super) fn native_screen_key_pressed_pre(
|
||||
&self,
|
||||
screen: &NativeScreenView<'_>,
|
||||
key: &str,
|
||||
repeat: bool,
|
||||
modifiers: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (screen, key, repeat, modifiers, canceled);
|
||||
}
|
||||
/// A character was typed at the open screen
|
||||
/// (`ScreenEvent.CharacterTyped.Pre`, vanilla's `Screen.charTyped`):
|
||||
/// `screen` is the screen it is, `c` the character, and `modifiers` GLFW's
|
||||
/// bits (shift 1, control 2, alt 4, super 8). Set `canceled` and that one
|
||||
/// character never reaches a text box.
|
||||
///
|
||||
/// It fires once per printable character of a key press's text and once
|
||||
/// per character of the input method's committed text, never for a control
|
||||
/// character (Enter, Backspace, Tab, Delete, Escape, a Control letter):
|
||||
/// those are keys, and the game reads them as keys, not as text. A key's
|
||||
/// characters fire after the key has acted, as GLFW's char callback comes
|
||||
/// after its key callback (`KeyboardHandler.charTyped`): `screen` is the
|
||||
/// screen open then, so a key that closes the screen (the inventory key)
|
||||
/// leaves none to hear its character, where vanilla's closed screen hears
|
||||
/// nothing, and one that moves the focus types into the box it moved to.
|
||||
/// They fire even when `native_screen_key_pressed_pre` cancelled the press
|
||||
/// that carried the text, as NeoForge delivers `CharacterTyped.Pre` for a
|
||||
/// cancelled `KeyPressed.Pre`: the characters left reach the text box
|
||||
/// without the key's own effects. Cancelling one character drops only that
|
||||
/// one. The control socket's `type` fires it too, but not for its `\b`
|
||||
/// backspace. Same screens as the key press, and it fires with no text box
|
||||
/// focused (there is nothing to take the character then).
|
||||
/// API @API:screen_key_input@.
|
||||
pub(super) fn native_screen_char_typed_pre(
|
||||
&self,
|
||||
screen: &NativeScreenView<'_>,
|
||||
c: char,
|
||||
modifiers: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (screen, c, modifiers, canceled);
|
||||
}
|
||||
/// The open screen as a native mod sees it (`NativeScreenView`): its id,
|
||||
/// the open menu's type and title, its slot count, and its panel's
|
||||
/// rectangle, with the mouse and the partial tick. `screen` is the screen
|
||||
@@ -1582,6 +1787,74 @@ impl Game<'_> {
|
||||
let batch = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
Some(self.screen_view(&batch, self.screen))
|
||||
}
|
||||
/// Whether the key press `key` over the open screen went no further
|
||||
/// (`native_screen_key_pressed_pre`): what `handle_window_event` and the
|
||||
/// control socket's `key` command both ask before a screen acts on a key.
|
||||
/// False when no screen of the hooks' is open, and for a key with no
|
||||
/// vanilla name.
|
||||
pub(super) fn screen_key_pressed(&self, key: KeyCode, repeat: bool) -> bool {
|
||||
let Some(name) = keybinds::key_name(key) else {
|
||||
return false;
|
||||
};
|
||||
let Some(screen) = self.screen_view_now() else {
|
||||
return false;
|
||||
};
|
||||
let mut canceled = false;
|
||||
self.native_screen_key_pressed_pre(
|
||||
&screen,
|
||||
name,
|
||||
repeat,
|
||||
super::glfw_modifiers(self.modifiers),
|
||||
&mut canceled,
|
||||
);
|
||||
canceled
|
||||
}
|
||||
/// Text on its way to a text box after the open screen's character hooks
|
||||
/// (`native_screen_char_typed_pre` per printable character): the cancelled
|
||||
/// characters are gone and every other one stays, control characters
|
||||
/// included (a text box reads Backspace, Delete and the like as keys).
|
||||
/// `None` when none of them is left to type.
|
||||
pub(super) fn screen_text_typed(&self, text: &str) -> Option<String> {
|
||||
let Some(screen) = self.screen_view_now() else {
|
||||
return Some(text.to_owned());
|
||||
};
|
||||
let modifiers = super::glfw_modifiers(self.modifiers);
|
||||
screen_text_hooked(text, |c| {
|
||||
let mut canceled = false;
|
||||
self.native_screen_char_typed_pre(&screen, c, modifiers, &mut canceled);
|
||||
canceled
|
||||
})
|
||||
}
|
||||
/// A key press's characters (`ScreenEvent.CharacterTyped.Pre`, vanilla's
|
||||
/// `KeyboardHandler.charTyped`), dispatched once the screen has acted on
|
||||
/// the key: GLFW's char callback comes after its key callback, so the
|
||||
/// characters go to the screen that is open now — a key that closed the
|
||||
/// screen leaves none to hear them, as vanilla's closed screen hears
|
||||
/// nothing, and one that moved the focus types into the box it moved to.
|
||||
/// Each printable one is offered to `native_screen_char_typed_pre`; the
|
||||
/// survivors go to the focused text box, control characters (keys) gone.
|
||||
pub(super) fn screen_chars_typed(&mut self, text: Option<&str>) {
|
||||
let Some(text) = text else {
|
||||
return;
|
||||
};
|
||||
let Some(screen) = self.screen_view_now() else {
|
||||
return;
|
||||
};
|
||||
let modifiers = super::glfw_modifiers(self.modifiers);
|
||||
let Some(text) = chars_to_type(text, |c| {
|
||||
let mut canceled = false;
|
||||
self.native_screen_char_typed_pre(&screen, c, modifiers, &mut canceled);
|
||||
canceled
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
// A held edit modifier makes a shortcut of the key, not text.
|
||||
if !self.types_text() {
|
||||
return;
|
||||
}
|
||||
// `Err` only says no text box of the screen takes the characters.
|
||||
let _ = self.type_chars_into_screen(&text);
|
||||
}
|
||||
/// A left or right (`right`) press at `at` (GUI pixels from the screen's
|
||||
/// top left, so outside it too) on block `block`'s screen, a mod's, over
|
||||
/// no slot: set `button` to a menu button id when it is on one of yours.
|
||||
@@ -1656,6 +1929,39 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (player, command, ran);
|
||||
}
|
||||
/// A command a player typed (NeoForge's `CommandEvent`, before
|
||||
/// `Commands.performCommand` runs it), without its slash: any command,
|
||||
/// vanilla's and the mods' own, also one the player may not run. Set
|
||||
/// `canceled` to stop it: nothing runs and nothing is answered but the
|
||||
/// lines pushed to `replies`, which go to the player as system chat.
|
||||
/// No player's state is swapped in and the server's player list is busy
|
||||
/// with this one, so `native_tell` and the other helpers that take a UUID
|
||||
/// reach nobody here: answer through `replies`. Once for each command a
|
||||
/// player sends, on a server, and for each line of a quiet command batch
|
||||
/// (`horizon:quiet_commands`, whose replies are dropped). A command the
|
||||
/// server refuses before reading it (unsigned chat arguments, the chat
|
||||
/// rate limit) never reaches it. @API:native_player_command@.
|
||||
pub(super) fn native_player_command(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
command: &str,
|
||||
canceled: &mut bool,
|
||||
replies: &mut Vec<Text>,
|
||||
) {
|
||||
let _ = (player, name, command, canceled, replies);
|
||||
}
|
||||
/// A command a mod registered, typed on the dedicated server's console
|
||||
/// or sent through `horizon-server console`
|
||||
/// (`DedicatedServer.handleConsoleInputs`), without its slash: set `ran`
|
||||
/// when it is yours. There is no player: the source is the console, at
|
||||
/// permission level 4, so `native_acting_player` is None and the
|
||||
/// player-only helpers give nothing; `native_feedback` answers on the
|
||||
/// console. A name no mod registered goes to vanilla's dispatcher as
|
||||
/// before. @API:native_perform_console_command@.
|
||||
pub(super) fn native_perform_console_command(&mut self, command: &str, ran: &mut bool) {
|
||||
let _ = (command, ran);
|
||||
}
|
||||
/// A click in a menu a mod opened (`native_open_menu`), which moved
|
||||
/// nothing: the menu's `slot` (its own first, then the player's
|
||||
/// inventory, `native_menu_inventory_slot`; -999 outside), the `button`
|
||||
@@ -1714,6 +2020,20 @@ impl Game<'_> {
|
||||
pub(super) fn native_item_bar(&self, stack: &ItemStack, bar: &mut Option<(i32, u32)>) {
|
||||
let _ = (stack, bar);
|
||||
}
|
||||
/// Append an item's custom decorations after its default bar, cooldown and count.
|
||||
/// Coordinates are the icon's top-left in GUI units, as the batch takes them;
|
||||
/// no item translation is active.
|
||||
/// Runs wherever the shared item-decoration path is used (hotbar, menus, tooltips,
|
||||
/// and the other screens that draw item icons: advancements, beacon, stats, trading).
|
||||
pub(super) fn native_item_decorations(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
batch: &mut HudBatch,
|
||||
) {
|
||||
let _ = (stack, x, y, batch);
|
||||
}
|
||||
/// A player uses `stack` (right click) with `self.hand` on the block at
|
||||
/// `pos`, `face` the face clicked (`[0, 1, 0]` is the top), once the
|
||||
/// block itself let the click through (a chest still opens): where
|
||||
@@ -1757,6 +2077,79 @@ impl Game<'_> {
|
||||
pub(super) fn native_use_item(&mut self, stack: &ItemStack, claimed: &mut bool) {
|
||||
let _ = (stack, claimed);
|
||||
}
|
||||
/// `Item.getUseDuration` of `stack`, in ticks: `duration` arrives with
|
||||
/// vanilla's (a consumable's time, 72000 for a shield, a spear, a bow,
|
||||
/// 0 for an item with no use over time). A mod's own item (outside
|
||||
/// `minecraft:`) with no `consumable`, `blocks_attacks` or kinetic
|
||||
/// weapon that answers above 0 is used over time on a right click, as an
|
||||
/// `Item.use` that calls `startUsingItem`, until it is let go or the time
|
||||
/// runs out (`native_living_entity_use_item_finish`). A consumable's time
|
||||
/// changes too, potions and the other vanilla items keep their own.
|
||||
/// Asked when a use starts, each tick a mod's item is in use, and each
|
||||
/// frame the hand draws one in use, on both sides. It has no user, as
|
||||
/// it is asked so often: answer from the stack alone. API @API:item_use_over_time@.
|
||||
pub(super) fn native_use_duration(&self, stack: &ItemStack, duration: &mut i32) {
|
||||
let _ = (stack, duration);
|
||||
}
|
||||
/// `Item.getUseAnimation` of `stack` in use, for the first person hand:
|
||||
/// `animation` arrives with its consumable's (`eat`, `drink`), else
|
||||
/// `None`. `Eat` and `Drink` bring the food to the mouth, `Bow` draws,
|
||||
/// `Trident` raises, `Brush` sweeps; the rest hold the item still.
|
||||
/// Asked each frame the hand draws an item in use, for an item with no
|
||||
/// pose of its own (bows, crossbows, tridents, brushes and spears keep
|
||||
/// theirs). Client-side. API @API:item_use_over_time@.
|
||||
pub(super) fn native_use_animation(&self, stack: &ItemStack, animation: &mut UseAnimation) {
|
||||
let _ = (stack, animation);
|
||||
}
|
||||
/// `Player.canEat` for the food `stack`, from `Consumable.canConsume`
|
||||
/// when eating starts: `can_eat` arrives with vanilla's (creative,
|
||||
/// `can_always_eat`, or hungry). Clear it to refuse the food, set it to
|
||||
/// let a full player eat. Both sides (the client starts eating as the
|
||||
/// server does), so answer from the stack and the player alone. API @API:item_use_over_time@.
|
||||
pub(super) fn native_can_eat(&self, stack: &ItemStack, can_eat: &mut bool) {
|
||||
let _ = (stack, can_eat);
|
||||
}
|
||||
/// NeoForge's `LivingEntityUseItemEvent.Start`: `entity` starts using
|
||||
/// `stack` in `hand` (`LivingEntity.startUsingItem`), for `duration`
|
||||
/// ticks (`native_use_duration`'s answer). `entity` is a mob's
|
||||
/// `Entity.getId`, None for a player (`native_acting_player` says who);
|
||||
/// only players fire it so far. Change `duration` (consumables other
|
||||
/// than potions, and mods' items, count to it; 0 or less lasts until let
|
||||
/// go), or set `canceled` and the use doesn't start. Both sides, as
|
||||
/// vanilla's: wherever the world runs (the server, single player
|
||||
/// in-frame, a LAN host) for every player of the world, and on a
|
||||
/// server's client for the uses it predicts. Answer the same on both,
|
||||
/// and change the world only where this `Game` runs the world, not on a
|
||||
/// server's client: no helper tells the two apart yet (`native_window`
|
||||
/// is also true in-frame and on an in-frame LAN host). API @API:item_use_over_time@.
|
||||
pub(super) fn native_living_entity_use_item_start(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
entity: Option<i32>,
|
||||
hand: Hand,
|
||||
duration: &mut i32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (stack, entity, hand, duration, canceled);
|
||||
}
|
||||
/// NeoForge's `LivingEntityUseItemEvent.Finish`: `entity`'s use of
|
||||
/// `stack` in `hand` ran its time (`LivingEntity.completeUsingItem`),
|
||||
/// `duration` the ticks left, always 0. `entity` is as in
|
||||
/// `native_living_entity_use_item_start`. `result` arrives with what
|
||||
/// `finishUsingItem` left in the hand (the bowl of a stew, none for the
|
||||
/// last apple; a mod's own item is kept) and may be replaced. Fired for
|
||||
/// consumables, potions and mods' items. Server-side, for every player
|
||||
/// of the world (the helpers read the acting one). API @API:item_use_over_time@.
|
||||
pub(super) fn native_living_entity_use_item_finish(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
entity: Option<i32>,
|
||||
hand: Hand,
|
||||
duration: i32,
|
||||
result: &mut Option<ItemStack>,
|
||||
) {
|
||||
let _ = (stack, entity, hand, duration, result);
|
||||
}
|
||||
/// A player breaks the block `state` at `pos`, once the game allowed it
|
||||
/// (the held item can break blocks, the debug stick didn't take the
|
||||
/// click): where `ServerPlayerGameMode.destroyBlock` starts. Set
|
||||
@@ -1963,7 +2356,9 @@ impl Game<'_> {
|
||||
/// entity's data (a shulker box's items) after this. Add, change or take out
|
||||
/// stacks; empty ones don't drop (NeoForge's global loot modifiers, Fabric's
|
||||
/// `LootTableEvents.MODIFY_DROPS`); `context` says what dropped them and
|
||||
/// how, and it is called even when `drops` is empty. It hears blocks that a
|
||||
/// how, and it is called even when `drops` is empty. A player's use drops
|
||||
/// through it too (`NativeLootDrops::used`: shears, a brush, an item
|
||||
/// frame), with that player named. It hears blocks that a
|
||||
/// player, an explosion or a mob breaks, not other breaks (leaves decaying,
|
||||
/// a torch losing its wall, a piston, `/setblock ... destroy`), nor a
|
||||
/// creative break or a tool that can't harvest the block, nor experience.
|
||||
@@ -2289,6 +2684,10 @@ impl GameLevel<'_> {
|
||||
/// directional particle. The return value means accepted, not delivered.
|
||||
/// Block particle types require their block state; item, trail and
|
||||
/// vibration particles are not supported by the current network dispatch.
|
||||
/// `ParticleOptions::named("mymod:spark")` sends a mod's own type
|
||||
/// (API @API:mod_particle_types@): Horizon clients draw it from the sprite
|
||||
/// set in the mod's `assets/<namespace>/particles/<path>.json`, a client
|
||||
/// without that file draws nothing, and vanilla clients aren't sent it.
|
||||
pub(super) fn native_send_particles(
|
||||
&mut self,
|
||||
options: &ParticleOptions,
|
||||
@@ -2299,6 +2698,16 @@ impl GameLevel<'_> {
|
||||
count: i32,
|
||||
) -> bool {
|
||||
use ParticleType as T;
|
||||
// A native mod's own type: the name has to be namespaced, and a client
|
||||
// draws it only when its pack has the sprite set.
|
||||
if let Some(name) = &options.mod_particle {
|
||||
let namespaced = name
|
||||
.split_once(':')
|
||||
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
|
||||
if !self.native_authoritative || !namespaced {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let supported = match options.ty {
|
||||
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
|
||||
state.is_some_and(|id| self.registry.state(id).is_some())
|
||||
@@ -2525,6 +2934,62 @@ impl Game<'_> {
|
||||
let _ = self.process_level_events();
|
||||
changed
|
||||
}
|
||||
/// The strongest redstone signal reaching the block at `pos` from its six
|
||||
/// neighbours, 0 to 15 (`SignalGetter.getBestNeighborSignal`, what a
|
||||
/// dispenser or a piston reads), in the level in place, `dimension`. 0 on
|
||||
/// a client's copy of the world and for another dimension. A mod's
|
||||
/// explosive, launcher or machine reads it in its block entity's tick to
|
||||
/// go off or switch on. API @API:native_neighbor_signal@.
|
||||
pub(super) fn native_neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 {
|
||||
if (self.serving.is_none() && self.options.dedicated.is_none())
|
||||
|| dimension != self.dim.id()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.best_neighbor_signal(level, ticks, at))
|
||||
}
|
||||
/// The light at `pos` in the world in place, as `(block, sky)`, each 0 to
|
||||
/// 15 (`Level.getBrightness` for `LightLayer.BLOCK` and `LightLayer.SKY`).
|
||||
/// A solar panel checks for full sky light above it, a lamp or a mob farm
|
||||
/// for darkness. `(0, 0)` in a chunk that isn't loaded. Like the other
|
||||
/// reads here it reads the world this game holds: the level in place on
|
||||
/// a server, the server's copy on a client. API @API:native_world_state@.
|
||||
pub(super) fn native_light(&self, pos: [i32; 3]) -> (u8, u8) {
|
||||
if !self.world.loaded(pos[0], pos[2]) {
|
||||
return (0, 0);
|
||||
}
|
||||
let light = self.world.light(pos[0], pos[1], pos[2]);
|
||||
(light & 15, light >> 4)
|
||||
}
|
||||
/// The rain and thunder levels of the world in place, each 0.0 to 1.0
|
||||
/// (`Level.getRainLevel` and `Level.getThunderLevel`): what a solar panel
|
||||
/// or a wind turbine reads. Both 0 in the Nether and the End, which have
|
||||
/// no weather (`Level.canHaveWeather`). API @API:native_world_state@.
|
||||
pub(super) fn native_weather(&self) -> (f32, f32) {
|
||||
if self.dim != super::Dim::Overworld {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
self.world.weather.levels(1.0)
|
||||
}
|
||||
/// The world's time of day in ticks (`Level.getOverworldClockTime`), the
|
||||
/// same as `native_clock`'s `day_time`; it keeps counting past 24000, so
|
||||
/// take it modulo 24000 for the hour. API @API:native_world_state@.
|
||||
pub(super) fn native_day_time(&self) -> i64 {
|
||||
self.world.day_time
|
||||
}
|
||||
/// The lowest block y and the height of the world in place
|
||||
/// (`LevelHeightAccessor.getMinY` and `getHeight`): how deep a quarry or a
|
||||
/// miner may dig. API @API:native_world_state@.
|
||||
pub(super) fn native_world_height(&self) -> (i32, i32) {
|
||||
(self.world.shape.min_y, self.world.shape.height)
|
||||
}
|
||||
/// The most of the item `item` one slot holds (`Item.getDefaultMaxStackSize`),
|
||||
/// or `None` for an unknown item: for a mod's container that fills or
|
||||
/// merges stacks itself. API @API:native_world_state@.
|
||||
pub(super) fn native_max_stack_size(&self, item: &str) -> Option<u32> {
|
||||
self.res.items().get(item).map(|i| i.max_stack)
|
||||
}
|
||||
/// The random of the server level `dimension`: vanilla's `Level.random`
|
||||
/// (`level.getRandom()`), the one its explosions, block drops and other
|
||||
/// level draws use, so a mod's draws move vanilla's later ones as a Java
|
||||
@@ -2912,6 +3377,16 @@ impl Game<'_> {
|
||||
dimension: self.dim.id(),
|
||||
})
|
||||
}
|
||||
/// The clock of the world this window shows, for client mods: its game
|
||||
/// time and time of day as the server last sent them
|
||||
/// (`ClientboundSetTimePacket`), advanced each tick between packets; none
|
||||
/// on the title screen. API @API:native_clock@.
|
||||
pub(super) fn native_clock(&self) -> Option<NativeClock> {
|
||||
self.player.is_some().then_some(NativeClock {
|
||||
game_time: self.world.game_time,
|
||||
day_time: self.world.day_time,
|
||||
})
|
||||
}
|
||||
/// The frame counter: frames in the last second, the slowest frame and
|
||||
/// the 1% low of the last 5 seconds, in frames per second.
|
||||
pub(super) fn native_frame_rate(&self) -> (u32, u32, u32) {
|
||||
@@ -3145,6 +3620,33 @@ impl Game<'_> {
|
||||
player,
|
||||
tool,
|
||||
explosion,
|
||||
used: false,
|
||||
};
|
||||
self.native_loot_drops(&context, drops);
|
||||
drops.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
|
||||
}
|
||||
/// The mods' say on what the acting player's use drops
|
||||
/// (`NativeLootDrops::used`): `table` is the loot table rolled, if any.
|
||||
pub(super) fn native_use_drops(
|
||||
&mut self,
|
||||
table: &str,
|
||||
origin: [f64; 3],
|
||||
block: Option<([i32; 3], BlockStateId)>,
|
||||
entity: Option<(u32, &str)>,
|
||||
tool: Option<&ItemStack>,
|
||||
drops: &mut Vec<ItemStack>,
|
||||
) {
|
||||
let player = Some(self.native_acting_id());
|
||||
let context = NativeLootDrops {
|
||||
table,
|
||||
dimension: self.dim.id(),
|
||||
origin,
|
||||
block,
|
||||
entity,
|
||||
player,
|
||||
tool,
|
||||
explosion: None,
|
||||
used: true,
|
||||
};
|
||||
self.native_loot_drops(&context, drops);
|
||||
drops.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
|
||||
@@ -3912,9 +4414,12 @@ fn debug_takes(corner: Corner, debug: bool, charts: [bool; 2]) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// A menu of `MOD_MENUS`, by its name.
|
||||
/// A menu of `MOD_MENUS` or a content mod's menu type, by its name.
|
||||
fn mod_menu(name: &str) -> Option<(&'static str, usize)> {
|
||||
MOD_MENUS.into_iter().find(|(menu, _)| *menu == name)
|
||||
MOD_MENUS
|
||||
.into_iter()
|
||||
.find(|(menu, _)| *menu == name)
|
||||
.or_else(|| horizon_client::containers::mod_menu_type(name).map(|m| (m.id, m.slots)))
|
||||
}
|
||||
|
||||
/// `state` with its property `key` set to `value`; none when the block has
|
||||
@@ -4006,6 +4511,54 @@ fn box_meets(b: &horizon_core::geometry::Aabb, min: [f64; 3], max: [f64; 3]) ->
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// The characters a `ScreenEvent.CharacterTyped.Pre` sees are the
|
||||
/// printable ones of a text: a cancelled one leaves that text and a
|
||||
/// control character (a key, not text) is never offered a hook.
|
||||
#[test]
|
||||
fn typed_text_keeps_control_characters_and_drops_cancelled_ones() {
|
||||
let mut offered = Vec::new();
|
||||
let left = super::screen_text_hooked("a\u{7f}b\u{1b}c", |c| {
|
||||
offered.push(c);
|
||||
c == 'b'
|
||||
});
|
||||
assert_eq!(offered, ['a', 'b', 'c']);
|
||||
assert_eq!(left.as_deref(), Some("a\u{7f}\u{1b}c"));
|
||||
// Every printable character cancelled: no text is left to type.
|
||||
assert_eq!(super::screen_text_hooked("ab", |_| true), None);
|
||||
// Nothing to offer: an empty text stays empty and keys alone stay whole.
|
||||
assert_eq!(super::screen_text_hooked("", |_| true).as_deref(), Some(""));
|
||||
assert_eq!(
|
||||
super::screen_text_hooked("\u{7f}\t", |_| true).as_deref(),
|
||||
Some("\u{7f}\t")
|
||||
);
|
||||
// What no hook cancels is handed back as it came in.
|
||||
assert_eq!(
|
||||
super::screen_text_hooked("hello", |_| false).as_deref(),
|
||||
Some("hello")
|
||||
);
|
||||
}
|
||||
|
||||
/// The window's key path: a key whose press a mod cancelled still types
|
||||
/// the characters no mod cancelled (vanilla's separate `charTyped`), while
|
||||
/// the key characters never reach the text box.
|
||||
#[test]
|
||||
fn a_cancelled_key_still_types_the_characters_left() {
|
||||
// `axb` with 'x' cancelled: the key went no further, `ab` still types.
|
||||
assert_eq!(
|
||||
super::chars_to_type("axb", |c| c == 'x').as_deref(),
|
||||
Some("ab")
|
||||
);
|
||||
// Backspace, Escape and the like are keys, not text.
|
||||
assert_eq!(
|
||||
super::chars_to_type("a\u{7f}\u{1b}b", |_| false).as_deref(),
|
||||
Some("ab")
|
||||
);
|
||||
// Every character cancelled: the box takes none of them.
|
||||
assert_eq!(super::chars_to_type("ab", |_| true), None);
|
||||
// Nothing typed: nothing to hand over.
|
||||
assert_eq!(super::chars_to_type("", |_| false), None);
|
||||
}
|
||||
|
||||
/// A shake is scaled by Distortion Effects, bounded on each axis, and a
|
||||
/// value that isn't finite is none.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! The player's motion for mods (docs/native-mods.md, "A player's motion"): a push for the local player, on a
|
||||
//! client, and a player's creative flying speed, on a server.
|
||||
use super::Game;
|
||||
|
||||
/// Each component of a push is clamped to this, in blocks a tick.
|
||||
const PUSH_MAX: f32 = 4.0;
|
||||
/// The fastest flying speed a mod may set: 1000 times vanilla's 0.05, for crossing a world's distances in seconds.
|
||||
/// It only stops a stray value from throwing the player millions of blocks in a tick; past about 2 the player
|
||||
/// outruns the chunks loading. Past about 0.45 (sprinting; 0.9 without) a player flies more than 10 blocks a
|
||||
/// tick: the single-player owner is never checked, as in vanilla, but anyone else meets the server's moved-too-quickly
|
||||
/// check, which can send them back (`Hub::position_change`, `ServerGamePacketListenerImpl.handleMovePlayer`).
|
||||
const FLYING_MAX: f32 = 50.0;
|
||||
/// Vanilla's creative flying speed (`Abilities.flyingSpeed`).
|
||||
const FLYING_VANILLA: f32 = 0.05;
|
||||
|
||||
/// The velocity after a push: `kick` added, each component clamped; none when one of `kick`'s isn't a number.
|
||||
fn pushed(velocity: [f64; 3], kick: [f32; 3]) -> Option<[f64; 3]> {
|
||||
if !kick.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(std::array::from_fn(|i| {
|
||||
velocity[i] + f64::from(kick[i].clamp(-PUSH_MAX, PUSH_MAX))
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// For a client mod: a push added to the local player's velocity, in blocks a tick, as an
|
||||
/// explosion's knockback is (the window's player moves itself, as vanilla's client does). Each component is
|
||||
/// clamped to -4..4; a spectator, or a value that is not a number, is left alone. API @API:native_motion@.
|
||||
pub(super) fn native_push_player(&mut self, kick: [f32; 3]) {
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !p.spectator
|
||||
&& let Some([x, y, z]) = pushed([p.velocity.x, p.velocity.y, p.velocity.z], kick)
|
||||
{
|
||||
p.velocity = horizon_core::geometry::Vec3::new(x, y, z);
|
||||
if kick[1] > 0.0 {
|
||||
p.ground.on_ground = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// For a server mod: `player`'s creative flying speed (vanilla's 0.05 when none, at most `FLYING_MAX`), kept on
|
||||
/// the server's player and sent to their client. A respawn or a game mode change keeps it, and it is saved
|
||||
/// with the player (`abilities.flySpeed`, as vanilla saves it). False for a player not here or a client of a
|
||||
/// server, and inside a player's hook, where the server's player list is busy: call it from a tick hook.
|
||||
/// API @API:native_motion@.
|
||||
pub(super) fn native_set_flying_speed(&mut self, player: u128, speed: Option<f32>) -> bool {
|
||||
let speed = speed
|
||||
.filter(|s| s.is_finite())
|
||||
.map_or(FLYING_VANILLA, |s| s.clamp(0.0, FLYING_MAX));
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let Some(conn) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.uuid == player && g.placed)
|
||||
.map(|g| g.conn)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
host.hub.set_flying_speed(conn, speed);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod motion_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_push_adds_to_the_velocity_clamped() {
|
||||
assert_eq!(
|
||||
pushed([0.1, -0.08, 0.0], [0.8, 0.55, -0.5]),
|
||||
Some([0.1 + 0.8f32 as f64, -0.08 + 0.55f32 as f64, -0.5])
|
||||
);
|
||||
assert_eq!(pushed([0.0; 3], [9.0, -9.0, 0.0]), Some([4.0, -4.0, 0.0]));
|
||||
assert_eq!(pushed([1.0; 3], [f32::NAN, 0.0, 0.0]), None);
|
||||
assert_eq!(pushed([1.0; 3], [0.0, f32::INFINITY, 0.0]), None);
|
||||
}
|
||||
}
|
||||
@@ -275,20 +275,13 @@ impl Game<'_> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// A system line in that player's chat; false when they aren't on.
|
||||
pub(super) fn native_tell(&self, uuid: u128, line: Text) -> bool {
|
||||
let Some(host) = &self.lan else {
|
||||
return false;
|
||||
};
|
||||
let Some(g) = host.hub.guests.iter().find(|g| g.placed && g.uuid == uuid) else {
|
||||
return false;
|
||||
};
|
||||
host.hub.send_to(g.conn, &system_chat(&line));
|
||||
true
|
||||
/// A system line in that player's chat, also inside a player's hook for
|
||||
/// the acting player; false when they aren't on.
|
||||
pub(super) fn native_tell(&mut self, uuid: u128, line: Text) -> bool {
|
||||
self.native_send(uuid, system_chat(&line))
|
||||
}
|
||||
/// A line over that player's hotbar (the action bar), also inside a
|
||||
/// player's hook for the acting player (`native_tell` reaches nobody
|
||||
/// there); false when they aren't on. API 21.
|
||||
/// player's hook for the acting player; false when they aren't on. API 21.
|
||||
pub(super) fn native_action_bar(&mut self, uuid: u128, line: Text) -> bool {
|
||||
let packet = C::SystemChat(play::SystemChat {
|
||||
content: component_nbt(&line.component()),
|
||||
@@ -702,6 +695,188 @@ impl Game<'_> {
|
||||
count,
|
||||
)
|
||||
}
|
||||
/// Gives `stack` whole (its damage, name, enchantments... kept) to the
|
||||
/// player `uuid`, as `Inventory.add` places it: onto equal stacks first,
|
||||
/// then into empty inventory slots, never the armour or an empty off
|
||||
/// hand. What did not fit comes back (nothing is dropped): all of it for
|
||||
/// a player who isn't here, is dead, or whose state is swapped out by
|
||||
/// another player's (inside that player's hook, only they are reached).
|
||||
/// The player's screen follows as for any change to their inventory.
|
||||
/// What an auto-pickup or a machine's output to a player needs, from any
|
||||
/// server hook. API @API:give_stack@.
|
||||
pub(super) fn native_give_stack(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
mut stack: ItemStack,
|
||||
) -> Option<ItemStack> {
|
||||
if stack.count == 0 {
|
||||
return None;
|
||||
}
|
||||
if uuid == self.native_acting_id() {
|
||||
// Their state is in place: the host's, or a guest's swapped in.
|
||||
if self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_none_or(|p| p.health.health() <= 0.0)
|
||||
{
|
||||
return Some(stack);
|
||||
}
|
||||
return self.give(stack);
|
||||
}
|
||||
// Inside a player's hook their state is in place and the records of
|
||||
// the others may hold swapped state (turns nest): only they are
|
||||
// reached, as the doc says.
|
||||
if self.lan_acting.is_some() {
|
||||
return Some(stack);
|
||||
}
|
||||
let max = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(64, |i| i.max_stack);
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return Some(stack);
|
||||
};
|
||||
let Some(g) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter_mut()
|
||||
.find(|g| g.placed && !g.dead && g.uuid == uuid)
|
||||
else {
|
||||
return Some(stack);
|
||||
};
|
||||
// inventory_add indexes the 36 main slots.
|
||||
if g.inventory.len() < 36 {
|
||||
g.inventory.resize(36, None);
|
||||
}
|
||||
let left = super::inventory_add(
|
||||
&mut g.inventory,
|
||||
&mut g.offhand,
|
||||
g.selected,
|
||||
max,
|
||||
&mut stack,
|
||||
);
|
||||
left.then_some(stack)
|
||||
}
|
||||
}
|
||||
|
||||
/// An experience orb as a server mod reads it (`native_experience_orbs`).
|
||||
/// Read by mods only.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct NativeExperienceOrb {
|
||||
/// The entity's ID, as `native_entity_join_level` names it.
|
||||
pub id: u32,
|
||||
pub position: [f64; 3],
|
||||
/// `ExperienceOrb.value`: the points one pickup gives.
|
||||
pub value: u32,
|
||||
/// `ExperienceOrb.count`: orbs merged into this one, each worth `value`.
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// Whether an orb at `position` (its box: 0.5 wide and high, standing on
|
||||
/// it) meets the box `min`..`max`, touching faces excluded (`AABB.intersects`).
|
||||
fn orb_meets(position: [f64; 3], min: [f64; 3], max: [f64; 3]) -> bool {
|
||||
position[0] - 0.25 < max[0]
|
||||
&& position[0] + 0.25 > min[0]
|
||||
&& position[1] < max[1]
|
||||
&& position[1] + 0.5 > min[1]
|
||||
&& position[2] - 0.25 < max[2]
|
||||
&& position[2] + 0.25 > min[2]
|
||||
}
|
||||
|
||||
/// Experience for server mods. API @API:give_experience@.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The experience orbs of the server level `dimension` whose box meets
|
||||
/// the box `min`..`max`, in the level's order: what a mod reads before it
|
||||
/// cancels an orb in `native_entity_join_level`, to give its points with
|
||||
/// `native_give_experience`. Empty for a level not in place and on a
|
||||
/// client of a server.
|
||||
pub(super) fn native_experience_orbs(
|
||||
&self,
|
||||
dimension: &str,
|
||||
min: [f64; 3],
|
||||
max: [f64; 3],
|
||||
) -> Vec<NativeExperienceOrb> {
|
||||
if (self.serving.is_none() && self.options.dedicated.is_none())
|
||||
|| dimension != self.dim.id()
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
self.orbs
|
||||
.iter()
|
||||
.filter(|o| o.count > 0)
|
||||
.map(|o| NativeExperienceOrb {
|
||||
id: o.id,
|
||||
position: [o.position.x, o.position.y, o.position.z],
|
||||
value: o.value,
|
||||
count: o.count,
|
||||
})
|
||||
.filter(|o| orb_meets(o.position, min, max))
|
||||
.collect()
|
||||
}
|
||||
/// Gives `points` of experience to the player `uuid` as an orb they touch
|
||||
/// does (`ExperienceOrb.playerTouch`): Mending repairs their damaged items
|
||||
/// with it first (`repairPlayerItems`), then the rest goes to their
|
||||
/// experience (`Player.giveExperiencePoints`), with the level-up sound
|
||||
/// every fifth level. No orb pickup sound. False, and nothing given, for
|
||||
/// a player who isn't here or is dead, and for another player than the one
|
||||
/// whose state is swapped in (inside that player's hook, only they are
|
||||
/// reached), and while the server's player list is busy elsewhere in the
|
||||
/// tick: give first, and cancel an orb only when this returned true.
|
||||
/// Their bar follows as for any change to their experience.
|
||||
pub(super) fn native_give_experience(&mut self, uuid: u128, points: u32) -> bool {
|
||||
self.native_as_player(uuid, |game| {
|
||||
let rest = if points > 0 { game.mending(points) } else { 0 };
|
||||
if rest > 0 {
|
||||
game.give_orb_points(rest);
|
||||
}
|
||||
})
|
||||
}
|
||||
/// Adds `levels` to the player `uuid`'s level, or takes them off when
|
||||
/// negative (`Player.giveExperienceLevels`): what the anvil and the
|
||||
/// enchanting table charge with, and a mod can charge or reward with.
|
||||
/// Below level 0 the level and the points are 0. The progress toward the
|
||||
/// next level is kept. False, and nothing changed, as for
|
||||
/// `native_give_experience`.
|
||||
pub(super) fn native_give_levels(&mut self, uuid: u128, levels: i32) -> bool {
|
||||
self.native_as_player(uuid, |game| game.give_levels(levels))
|
||||
}
|
||||
/// Runs `f` with the player `uuid`'s state in place: theirs already when
|
||||
/// they are the acting player (the host, or a guest swapped in), else a
|
||||
/// guest's through `as_guest`, only where no other player is swapped in.
|
||||
/// False when it didn't run: no such player here, or dead.
|
||||
fn native_as_player(&mut self, uuid: u128, f: impl FnOnce(&mut Self)) -> bool {
|
||||
if uuid == self.native_acting_id() {
|
||||
if self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_none_or(|p| p.health.health() <= 0.0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
f(self);
|
||||
return true;
|
||||
}
|
||||
if self.lan_acting.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return false;
|
||||
};
|
||||
let conn = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && !g.dead && g.uuid == uuid)
|
||||
.map(|g| g.conn);
|
||||
let ran = conn
|
||||
.and_then(|conn| self.as_guest(&mut host, conn, f))
|
||||
.is_some();
|
||||
self.lan = Some(host);
|
||||
ran
|
||||
}
|
||||
}
|
||||
|
||||
fn system_chat(line: &Text) -> C {
|
||||
@@ -773,6 +948,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A console line for the mods: true when a mod registered its command
|
||||
/// (`native_register_commands`). The console is level 4, so a registered
|
||||
/// usage is always allowed; a command no mod takes answers as vanilla's
|
||||
/// unknown command.
|
||||
pub(super) fn perform_native_console_command(&mut self, line: &str) -> bool {
|
||||
let Some(host) = &self.lan else {
|
||||
return false;
|
||||
};
|
||||
if Self::native_command_level(host, line).is_none() {
|
||||
return false;
|
||||
}
|
||||
let mut ran = false;
|
||||
self.native_perform_console_command(line, &mut ran);
|
||||
if !ran {
|
||||
self.native_feedback(
|
||||
Text::translate("command.unknown.command", &[])
|
||||
.with_color(horizon_client::text::RED),
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -843,6 +1042,59 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orbs_are_found_by_their_half_block_box() {
|
||||
// A block's cell, as a mod asks for a broken block's drops.
|
||||
let (min, max) = ([10.0, 64.0, -3.0], [11.0, 65.0, -2.0]);
|
||||
assert!(orb_meets([10.5, 64.2, -2.5], min, max), "inside");
|
||||
assert!(
|
||||
orb_meets([11.2, 64.0, -2.5], min, max),
|
||||
"its box reaches in"
|
||||
);
|
||||
assert!(
|
||||
orb_meets([10.5, 63.6, -2.5], min, max),
|
||||
"standing just below"
|
||||
);
|
||||
assert!(!orb_meets([11.25, 64.0, -2.5], min, max), "a face touching");
|
||||
assert!(!orb_meets([10.5, 63.5, -2.5], min, max), "its top touching");
|
||||
assert!(!orb_meets([10.5, 65.0, -2.5], min, max), "standing on top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_stacks_are_given_onto_equal_stacks_only() {
|
||||
use super::super::inventory_add;
|
||||
let mut inv = vec![stack("minecraft:dirt", 64); 36];
|
||||
inv[2] = stack("minecraft:iron_sword", 1);
|
||||
inv[2].as_mut().unwrap().damage = 5;
|
||||
inv[5] = stack("minecraft:arrow", 60);
|
||||
inv[7] = None;
|
||||
inv[20] = None;
|
||||
let mut off = stack("minecraft:arrow", 62);
|
||||
|
||||
// A damaged sword never joins another stack: the first free slot.
|
||||
let mut sword = stack("minecraft:iron_sword", 1).unwrap();
|
||||
sword.damage = 40;
|
||||
assert!(!inventory_add(&mut inv, &mut off, 5, 1, &mut sword));
|
||||
assert_eq!(inv[7].as_ref().unwrap().damage, 40, "its damage is kept");
|
||||
assert_eq!(inv[2].as_ref().unwrap().damage, 5);
|
||||
|
||||
// Arrows: the selected slot, then the off hand, then a free slot,
|
||||
// and what is left over comes back.
|
||||
let mut arrows = stack("minecraft:arrow", 80).unwrap();
|
||||
assert!(inventory_add(&mut inv, &mut off, 5, 64, &mut arrows));
|
||||
assert_eq!(inv[5].as_ref().unwrap().count, 64);
|
||||
assert_eq!(off.as_ref().unwrap().count, 64);
|
||||
assert_eq!(inv[20].as_ref().unwrap().count, 64);
|
||||
assert_eq!(arrows.count, 80 - 4 - 2 - 64);
|
||||
|
||||
// A full inventory with an empty off hand takes nothing.
|
||||
let mut off = None;
|
||||
let mut apple = stack("minecraft:apple", 3).unwrap();
|
||||
assert!(inventory_add(&mut inv, &mut off, 0, 64, &mut apple));
|
||||
assert!(off.is_none(), "never into an empty off hand (getFreeSlot)");
|
||||
assert_eq!(apple.count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usages_merge_into_one_tree_with_runnable_nodes() {
|
||||
let (t, levels, problems) = tree(&[
|
||||
|
||||
@@ -62,15 +62,38 @@ impl Game<'_> {
|
||||
let options = &pack.manifest.options;
|
||||
let values = horizon_client::shader_pack_list::load_values(path, options);
|
||||
let constants = options.iter().map(|o| o.id.clone()).zip(values).collect();
|
||||
let sprites = self.pack.render.sprite_layers.iter();
|
||||
let tags = pack
|
||||
.manifest
|
||||
.sprite_tags(sprites.map(|(name, layer)| (name.as_str(), *layer)));
|
||||
let sprites = || {
|
||||
let layers = self.pack.render.sprite_layers.iter();
|
||||
layers.map(|(name, layer)| (name.as_str(), *layer))
|
||||
};
|
||||
let tags = pack.manifest.sprite_tags(sprites());
|
||||
let materials = pack.manifest.sprite_materials(sprites());
|
||||
// Each light-giving block state's `[[lights]]` colour, with its level.
|
||||
let mut lights = horizon_client::gfx::block_light::LightTable::default();
|
||||
if !pack.manifest.lights.is_empty() {
|
||||
for (id, state) in self.res.facts().states.iter().enumerate() {
|
||||
if state.emission == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(color) = pack.manifest.light_of(&state.name) {
|
||||
lights.insert(id as u32, (color, state.emission));
|
||||
}
|
||||
}
|
||||
}
|
||||
let labels = renderer
|
||||
.set_native_shader_pack(Some((report, constants, tags)))
|
||||
.set_native_shader_pack(Some((report, constants, tags, materials, lights)))
|
||||
.map_err(|e| vec![e])?;
|
||||
Ok((pack.manifest, labels))
|
||||
});
|
||||
// The resources were loaded without a native pack, so without the resource packs'
|
||||
// LabPBR maps its material stage reads: load them once, now that one runs.
|
||||
if result.as_ref().is_ok_and(|(m, _)| m.material.is_some())
|
||||
&& !horizon_client::sprites::labpbr_maps()
|
||||
{
|
||||
horizon_client::sprites::set_labpbr_maps(true);
|
||||
log::info!("Horizon: reloading resources for the LabPBR maps a native pack reads");
|
||||
self.reload_packs();
|
||||
}
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
match result {
|
||||
Ok((m, labels)) => {
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
//! End portals here (`EndPortalBlock.entityInside` / `getPortalDestination`);
|
||||
//! the player's End portals are game/end.rs's.
|
||||
//!
|
||||
//! Only one dimension is loaded at a time. The player's destination is found
|
||||
//! once the other dimension has loaded around it. An item or mob that goes
|
||||
//! through waits as an `Arrival` (its saved compound and where it stood in its
|
||||
//! portal) until the player is in that dimension and its exit is loaded.
|
||||
//! A server keeps one level per dimension. Entity portal processes and
|
||||
//! cooldowns follow their level; shared `Arrival`s hold each traveller's
|
||||
//! saved compound until its destination chunks are loaded.
|
||||
use super::{Dim, Game, Screen};
|
||||
use horizon_client::Result;
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
@@ -86,7 +85,7 @@ pub(super) struct Portals {
|
||||
/// `LocalPlayer.portalProcess.isInsidePortalThisTick`.
|
||||
client_inside: bool,
|
||||
/// Items and mobs, by entity id.
|
||||
entities: HashMap<u32, PortalState>,
|
||||
pub(super) entities: HashMap<u32, PortalState>,
|
||||
/// Positions before this tick's moves, by entity id.
|
||||
pub(super) before: FxHashMap<u32, Vec3>,
|
||||
arrivals: Vec<Arrival>,
|
||||
@@ -1103,6 +1102,60 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `FallingBlockEntity.tick`: block effects mark its portal, then
|
||||
/// `Entity.handlePortal` transfers it before the landing branch runs.
|
||||
pub(super) fn falling_through_portal(&mut self, f: &mut super::FallingBlock) -> Option<Dim> {
|
||||
if self.remote.is_some() {
|
||||
return None;
|
||||
}
|
||||
let mut sections = std::mem::take(&mut self.portals.sections);
|
||||
let entry = self.swept_portal(f.previous, f.position, 0.98, 0.98, &mut sections);
|
||||
self.portals.sections = sections;
|
||||
if let Some(entry) = entry {
|
||||
f.portal.set_inside(entry, ENTITY_DELAY);
|
||||
}
|
||||
let entry = f.portal.handle(true, 0, ENTITY_DELAY)?;
|
||||
let plan = self.entity_plan(entry, f.position, 0.98, 0.98, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return None;
|
||||
}
|
||||
let uuid = f.saved.as_ref().map_or_else(
|
||||
|| {
|
||||
if f.id == 0 {
|
||||
horizon_client::save::random_uuid()
|
||||
} else {
|
||||
self.uuid_for(f.id)
|
||||
}
|
||||
},
|
||||
|saved| saved.0,
|
||||
);
|
||||
let modelled = horizon_client::entity_nbt::falling_block_to_nbt(
|
||||
self.state_to_nbt(f.state),
|
||||
f.position,
|
||||
f.velocity,
|
||||
f.age,
|
||||
f.drop_item,
|
||||
f.damage,
|
||||
f.fall_distance,
|
||||
uuid,
|
||||
);
|
||||
let mut tag = super::persist::with_raw(modelled, f.saved.as_ref().map(|s| &s.1));
|
||||
if let Tag::Compound(fields) = &mut tag {
|
||||
fields.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
if f.no_gravity {
|
||||
fields.insert("NoGravity".into(), Tag::Byte(1));
|
||||
} else {
|
||||
fields.remove(&"NoGravity".into());
|
||||
}
|
||||
}
|
||||
if f.id != 0 {
|
||||
self.forget_entity(f.id);
|
||||
}
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
Some(plan.dim)
|
||||
}
|
||||
|
||||
// ---- persistence ----
|
||||
|
||||
fn arrivals_path(&self) -> std::path::PathBuf {
|
||||
|
||||
@@ -441,6 +441,11 @@ impl Game<'_> {
|
||||
}
|
||||
/// Settings that Horizon applies beyond render distance, volume and GUI scale.
|
||||
pub(super) fn apply_ui_settings(&mut self) {
|
||||
if horizon_client_render::mipmap::set_dither_cutouts(
|
||||
self.settings.horizon.dither_cutout_mips,
|
||||
) {
|
||||
self.reload_packs();
|
||||
}
|
||||
self.apply_fullscreen();
|
||||
let s = &self.settings;
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
|
||||
@@ -331,6 +331,8 @@ impl Game<'_> {
|
||||
.set_resource_root(self.options.resources.clone());
|
||||
self.maps.slots.forget();
|
||||
self.map_patches.clear();
|
||||
// Kept mod block entities name the old atlas's sprites.
|
||||
self.kept_block_entities.get_mut().clear();
|
||||
// The atlas's sprites moved: the particle engine re-reads every rect
|
||||
// and the packs' sprite sets, or every particle draws a stale region.
|
||||
self.particles
|
||||
@@ -370,6 +372,7 @@ impl Game<'_> {
|
||||
self.ui.chat.rescale(&self.gui.atlas);
|
||||
// The creative screen survives the reload; item names may not.
|
||||
self.creative_screen.forget_items();
|
||||
self.native_client_resources_reloaded();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1183,8 +1183,7 @@ impl Game<'_> {
|
||||
|
||||
/// The real mouse, before the screen takes it: where it is, and a press
|
||||
/// or a scroll hands it the pointer back from the keyboard, at the
|
||||
/// place the mouse left it (`setLastInputType(MOUSE)`). A left press
|
||||
/// focuses what it lands on, for the next Tab or arrow.
|
||||
/// place the mouse left it (`setLastInputType(MOUSE)`).
|
||||
pub(super) fn key_focus_mouse(&mut self, event_loop: &dyn EventLoopCtl, event: &WindowEvent) {
|
||||
if self.pad.menu.synthetic {
|
||||
return;
|
||||
@@ -1195,27 +1194,38 @@ impl Game<'_> {
|
||||
}
|
||||
WindowEvent::MouseInput {
|
||||
state: ElementState::Pressed,
|
||||
button,
|
||||
..
|
||||
} => {
|
||||
self.key_focus_to_mouse(event_loop);
|
||||
if *button == MouseButton::Left && self.pad_in_menu() {
|
||||
let (cx, cy) = self.cursor;
|
||||
self.pad.menu.origin = self
|
||||
.key_targets()
|
||||
.into_iter()
|
||||
.find(|t| {
|
||||
let [x, y, w, h] = t.rect;
|
||||
cx >= x && cy >= y && cx < x + w && cy < y + h
|
||||
})
|
||||
.map(|t| t.rect);
|
||||
}
|
||||
}
|
||||
} => self.key_focus_to_mouse(event_loop),
|
||||
WindowEvent::MouseWheel { .. } => self.key_focus_to_mouse(event_loop),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only a press accepted by ScreenEvent.Pre may focus the widget under it.
|
||||
pub(super) fn key_focus_mouse_press(&mut self, event: &WindowEvent) {
|
||||
if !self.pad.menu.synthetic
|
||||
&& self.pad_in_menu()
|
||||
&& matches!(
|
||||
event,
|
||||
WindowEvent::MouseInput {
|
||||
state: ElementState::Pressed,
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
}
|
||||
)
|
||||
{
|
||||
let (cx, cy) = self.cursor;
|
||||
self.pad.menu.origin = self
|
||||
.key_targets()
|
||||
.into_iter()
|
||||
.find(|t| {
|
||||
let [x, y, w, h] = t.rect;
|
||||
cx >= x && cy >= y && cx < x + w && cy < y + h
|
||||
})
|
||||
.map(|t| t.rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyboard lets go of the pointer: it goes back to the real mouse.
|
||||
fn key_focus_to_mouse(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
if !std::mem::take(&mut self.pad.menu.keyboard) {
|
||||
|
||||
@@ -94,7 +94,7 @@ fn anon_key(kind: &str, p: Vec3) -> (String, [u64; 3]) {
|
||||
}
|
||||
/// The loaded compound underneath the fields Horizon models: what the entity was
|
||||
/// loaded with, with every key of `modelled` replacing its loaded value.
|
||||
fn with_raw(modelled: Tag, raw: Option<&Tag>) -> Tag {
|
||||
pub(super) fn with_raw(modelled: Tag, raw: Option<&Tag>) -> Tag {
|
||||
let (Some(Tag::Compound(raw)), Tag::Compound(modelled)) = (raw, &modelled) else {
|
||||
return modelled;
|
||||
};
|
||||
@@ -305,7 +305,7 @@ impl Game<'_> {
|
||||
if uuid.is_some_and(|u| self.restored_vehicle(u).is_some()) {
|
||||
continue;
|
||||
}
|
||||
if matches!(
|
||||
if (matches!(
|
||||
kind.as_str(),
|
||||
"minecraft:experience_orb"
|
||||
| "minecraft:arrow"
|
||||
@@ -319,7 +319,8 @@ impl Game<'_> {
|
||||
| "minecraft:falling_block"
|
||||
| "minecraft:end_crystal"
|
||||
| "minecraft:ender_dragon"
|
||||
) && let Some(at) = entity_nbt::position(&tag)
|
||||
) || horizon_client::projectiles::thrown_type(&kind).is_some())
|
||||
&& let Some(at) = entity_nbt::position(&tag)
|
||||
{
|
||||
self.persist
|
||||
.anon
|
||||
@@ -384,7 +385,7 @@ impl Game<'_> {
|
||||
self.add_projectile(p);
|
||||
}
|
||||
}
|
||||
"minecraft:snowball" | "minecraft:egg" | "minecraft:ender_pearl" => {
|
||||
id if entity_nbt::throwable_kind(id).is_some() => {
|
||||
if let Some(mut p) = entity_nbt::throwable_from_nbt(&tag, player_uuid) {
|
||||
self.claim_owner(&mut p);
|
||||
self.add_projectile(p);
|
||||
@@ -406,6 +407,14 @@ impl Game<'_> {
|
||||
velocity,
|
||||
age,
|
||||
no_gravity,
|
||||
portal: horizon_client::nether_portal::PortalState {
|
||||
cooldown: horizon_client::save::number(
|
||||
tag.get("PortalCooldown"),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
as i32,
|
||||
process: None,
|
||||
},
|
||||
drop_item,
|
||||
fall_distance,
|
||||
stuck: Vec3::ZERO,
|
||||
@@ -1093,9 +1102,11 @@ impl Game<'_> {
|
||||
f.fall_distance,
|
||||
uuid,
|
||||
);
|
||||
out.entry(chunk_of(f.position))
|
||||
.or_default()
|
||||
.push(with_raw(tag, raw.as_ref()));
|
||||
let mut tag = with_raw(tag, raw.as_ref());
|
||||
if let Tag::Compound(fields) = &mut tag {
|
||||
fields.insert("PortalCooldown".into(), Tag::Int(f.portal.cooldown));
|
||||
}
|
||||
out.entry(chunk_of(f.position)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
let boats: Vec<usize> = (0..self.boats.len())
|
||||
|
||||
@@ -425,9 +425,7 @@ impl Game<'_> {
|
||||
pub(super) fn use_potion_item(&mut self, stack: &ItemStack) -> horizon_client::Result<bool> {
|
||||
match &*stack.item {
|
||||
"minecraft:potion" | "minecraft:ominous_bottle" => {
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
self.start_using_item();
|
||||
Ok(true)
|
||||
}
|
||||
"minecraft:splash_potion"
|
||||
|
||||
@@ -115,7 +115,11 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let g = host.hub.guest(conn).expect("found above");
|
||||
if excused(g, &self.world, host.states.as_slice(), &host.hub.guests) {
|
||||
// A client that hasn't said it loaded the level is still on its loading screen, where it
|
||||
// doesn't move (the server stopped waiting for it after 60 ticks): the replay falls or drifts
|
||||
// where it stands. Its first move counts as any other.
|
||||
let loading = g.loading && !over.moved;
|
||||
if loading || excused(g, &self.world, host.states.as_slice(), &host.hub.guests) {
|
||||
if let Some(replay) = host
|
||||
.hub
|
||||
.guest_mut(conn)
|
||||
|
||||
@@ -42,11 +42,18 @@ impl Game<'_> {
|
||||
// ItemEntity in front of the carved side, moved by the level random
|
||||
// (no pickup delay).
|
||||
let (dx, dz) = (f64::from(dx), f64::from(dz));
|
||||
for seeds in self.sequence_loot(
|
||||
"minecraft:carve/pumpkin",
|
||||
"minecraft:carve/pumpkin",
|
||||
let table = "minecraft:carve/pumpkin";
|
||||
let mut drops = self.sequence_loot(table, table, Some(shears));
|
||||
let origin = pos.map(|c| f64::from(c) + 0.5);
|
||||
self.native_use_drops(
|
||||
table,
|
||||
origin,
|
||||
Some((pos, state)),
|
||||
None,
|
||||
Some(shears),
|
||||
) {
|
||||
&mut drops,
|
||||
);
|
||||
for seeds in drops {
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5 + dx * 0.65,
|
||||
f64::from(pos[1]) + 0.1,
|
||||
|
||||
@@ -1064,6 +1064,8 @@ impl Game<'_> {
|
||||
self.titles.overlay_time = 0;
|
||||
// GameRenderer.resetData and narrator.clear (Minecraft.disconnect).
|
||||
self.item_activation.reset();
|
||||
// Kept mod block entity geometry belongs to the world leaving.
|
||||
self.kept_block_entities.get_mut().clear();
|
||||
self.clear_narrator();
|
||||
// A world is leaving either way: its icon attempt goes with it.
|
||||
self.world_icon = None;
|
||||
@@ -2716,8 +2718,12 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// A content mod's menu type: its container, then the inventory.
|
||||
// A workstation: its inputs, its result, then the inventory.
|
||||
m => {
|
||||
if let Some(t) = horizon_client::containers::mod_menu_type(m) {
|
||||
return slots(t.slots);
|
||||
}
|
||||
let kind = Self::remote_station_kind(m)?;
|
||||
let inputs = kind.inputs();
|
||||
let own = inputs + usize::from(kind.has_result());
|
||||
@@ -3447,6 +3453,14 @@ impl Game<'_> {
|
||||
self.using = synced_local_use(using, *flags, selected, |slot| {
|
||||
self.stack_in(slot).is_some()
|
||||
});
|
||||
// onSyncedDataUpdated's useItemRemaining: getUseDuration,
|
||||
// without startUsingItem's event.
|
||||
if using.is_none()
|
||||
&& let Some((slot, _)) = self.using
|
||||
&& let Some(stack) = self.stack_in(slot).cloned()
|
||||
{
|
||||
self.use_duration = self.use_duration(&stack);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -7036,11 +7050,7 @@ impl Game<'_> {
|
||||
} else if held.as_ref().is_some_and(|s| &*s.item == "minecraft:brush") {
|
||||
// BrushItem.useOn: brushing starts here (CONSUME, no swing); the
|
||||
// server brushes and the dust is this client's own.
|
||||
self.with_hand(hand, |g| {
|
||||
if g.using.is_none() {
|
||||
g.using = Some((g.held_slot(), 0));
|
||||
}
|
||||
});
|
||||
self.with_hand(hand, Game::start_using_item);
|
||||
return true;
|
||||
} else if held
|
||||
.as_ref()
|
||||
@@ -7431,6 +7441,10 @@ impl Game<'_> {
|
||||
if self.cooldowns.is_on_cooldown(&group) {
|
||||
return false;
|
||||
}
|
||||
// a mod's item used over time starts here too, as the server starts it.
|
||||
if self.with_hand(hand, |g| g.start_mod_use(&held)) {
|
||||
return true;
|
||||
}
|
||||
// Item.use: Equippable.swapWithEquipmentSlot runs on this client too, so
|
||||
// the armor slot and the hand change at once (the server confirms).
|
||||
if info.equip_slot.is_some() && self.with_hand(hand, |g| g.equip_from_hand(&held)) {
|
||||
@@ -7604,7 +7618,7 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
if start {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
self.start_using_item();
|
||||
}
|
||||
start || !matches!(&*held.item, "minecraft:bow" | "minecraft:crossbow")
|
||||
}
|
||||
@@ -7618,7 +7632,7 @@ impl Game<'_> {
|
||||
if self.cooldowns.is_on_cooldown(&group) {
|
||||
return;
|
||||
}
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
self.start_using_item();
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
@@ -7631,9 +7645,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// Whether `stack` stays in use until the button lets go (bows,
|
||||
/// crossbows, tridents, spyglasses, shields, eyes of ender, whose
|
||||
/// `getUseDuration` 0 never counts down to the end): its use only counts
|
||||
/// ticks here.
|
||||
/// `getUseDuration` 0 never counts down to the end) or the server ends
|
||||
/// it (a mod's item): its use only counts ticks here.
|
||||
fn held_until_released(&self, stack: &ItemStack) -> bool {
|
||||
if self.mod_use(stack) {
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
&*stack.item,
|
||||
"minecraft:bow"
|
||||
@@ -7761,7 +7778,8 @@ impl Game<'_> {
|
||||
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. The
|
||||
// countdown goes on past zero (LivingEntity.updateUsingItem completes
|
||||
// only on the server) until the server's USE_ITEM_COMPLETE.
|
||||
let remaining = consumable.ticks() - ticks as i32;
|
||||
// getUseItemRemainingTicks: the duration this use started with.
|
||||
let remaining = self.use_duration - ticks as i32;
|
||||
if consumable.should_emit(remaining) {
|
||||
// `emit_consume` plays the eat sound and spawns the bites (when the
|
||||
// item has consume particles), as `tick_used_item` does on the
|
||||
@@ -8602,6 +8620,7 @@ impl Game<'_> {
|
||||
self.remote_open_merchant(p.container, title, menu_title(&p.title));
|
||||
return;
|
||||
}
|
||||
let mod_type = horizon_client::containers::mod_menu_type(&menu);
|
||||
let entity = match menu.as_str() {
|
||||
"minecraft:crafting" => None,
|
||||
"minecraft:furnace" | "minecraft:blast_furnace" | "minecraft:smoker" => {
|
||||
@@ -8638,6 +8657,14 @@ impl Game<'_> {
|
||||
.unwrap_or(3);
|
||||
Some(BlockEntity::Chest(vec![None; rows * 9]))
|
||||
}
|
||||
// A content mod's menu type: its container's slots, which its
|
||||
// mod's screen places.
|
||||
_ if mod_type.is_some() => Some(BlockEntity::Mod(Box::new(
|
||||
horizon_client::containers::ModBlockEntity {
|
||||
items: vec![None; mod_type.map_or(0, |t| t.slots)],
|
||||
..Default::default()
|
||||
},
|
||||
))),
|
||||
_ => {
|
||||
// Unsupported menu: tell the server we closed it.
|
||||
self.send(S::ContainerClose(play::ContainerClose {
|
||||
@@ -8673,6 +8700,20 @@ impl Game<'_> {
|
||||
self.world.block_entities.insert(key, entity);
|
||||
self.container = Some(key);
|
||||
self.open_native_screen(key, target, p.container, &menu);
|
||||
// A mod menu type shows through its mod's screen only: without one
|
||||
// (`MenuScreens.create` finds no screen) the window closes again.
|
||||
if self.mod_screen.is_none() && mod_type.is_some() {
|
||||
self.world.block_entities.remove(&key);
|
||||
self.container = None;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.menu = None;
|
||||
r.menu_title = None;
|
||||
}
|
||||
self.send(S::ContainerClose(play::ContainerClose {
|
||||
container: p.container,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.screen = Screen::Inventory;
|
||||
self.capture(false);
|
||||
|
||||
@@ -297,6 +297,7 @@ impl Game<'_> {
|
||||
velocity,
|
||||
age: 0,
|
||||
no_gravity: false,
|
||||
portal: Default::default(),
|
||||
drop_item: false,
|
||||
fall_distance: 0.0,
|
||||
stuck: Vec3::ZERO,
|
||||
|
||||
@@ -44,11 +44,10 @@ impl Screen {
|
||||
}
|
||||
|
||||
/// The pause menu grid's origin: GridLayout of two 98px columns (padding 4),
|
||||
/// aligned at (0.5, 0.25). `row3`: the full-width row at y + 122 that holds
|
||||
/// the `pause_screen_additions` button, or the `minecraft:server_links` one
|
||||
/// when the tag is empty (`PauseScreen.getCustomAdditions`).
|
||||
fn pause_grid(w: f32, h: f32, row3: bool) -> (f32, f32) {
|
||||
let (grid_w, grid_h) = (212.0, 166.0 + if row3 { 24.0 } else { 0.0 });
|
||||
/// aligned at (0.5, 0.25). Extra rows hold a server dialog or links
|
||||
/// (`PauseScreen.getCustomAdditions`), followed by the guest profile settings.
|
||||
fn pause_grid(w: f32, h: f32, extra_rows: u8) -> (f32, f32) {
|
||||
let (grid_w, grid_h) = (212.0, 166.0 + 24.0 * f32::from(extra_rows));
|
||||
(((w - grid_w) * 0.5).floor(), ((h - grid_h) * 0.25).floor())
|
||||
}
|
||||
|
||||
@@ -67,10 +66,11 @@ fn pause_buttons(
|
||||
remote: bool,
|
||||
links: bool,
|
||||
additions: bool,
|
||||
split: bool,
|
||||
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
let row3 = links || additions;
|
||||
let (x0, y0) = pause_grid(w, h, row3);
|
||||
let down = if row3 { 24.0 } else { 0.0 };
|
||||
let (x0, y0) = pause_grid(w, h, u8::from(row3) + u8::from(split));
|
||||
let down = 24.0 * f32::from(u8::from(row3) + u8::from(split));
|
||||
let mut v = vec![
|
||||
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
|
||||
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
|
||||
@@ -84,6 +84,10 @@ fn pause_buttons(
|
||||
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
|
||||
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
|
||||
}
|
||||
if split {
|
||||
let y = y0 + 122.0 + if row3 { 24.0 } else { 0.0 };
|
||||
v.push((x0 + 4.0, y, 204.0, SPLIT_SETTINGS, 30, true));
|
||||
}
|
||||
v.extend([
|
||||
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
|
||||
(
|
||||
@@ -149,8 +153,15 @@ fn pause_icon_focus_ids(friends: bool) -> Vec<u8> {
|
||||
.map(|i| if i == FRIENDS_ICON { 10 } else { 12 + i as u8 })
|
||||
.collect()
|
||||
}
|
||||
fn pause_icons(w: f32, h: f32, links: bool, additions: bool) -> [(f32, f32, &'static str); 4] {
|
||||
let (x0, y0) = pause_grid(w, h, links || additions);
|
||||
const SPLIT_SETTINGS: &str = "horizon.pages.split_settings";
|
||||
fn pause_icons(
|
||||
w: f32,
|
||||
h: f32,
|
||||
links: bool,
|
||||
additions: bool,
|
||||
split: bool,
|
||||
) -> [(f32, f32, &'static str); 4] {
|
||||
let (x0, y0) = pause_grid(w, h, u8::from(links || additions) + u8::from(split));
|
||||
let (x0, y) = (x0 + 60.0, y0 + 98.0);
|
||||
[
|
||||
(x0, y, "gui/sprites/pause_menu/bug"),
|
||||
@@ -240,9 +251,14 @@ impl Game<'_> {
|
||||
let links = !self.server_links().is_empty();
|
||||
let additions = self.pause_screen_additions();
|
||||
let mut tooltip = None;
|
||||
for (x, y, bw, key, id, active) in
|
||||
pause_buttons(w, h, self.external_server(), links, additions.is_some())
|
||||
{
|
||||
for (x, y, bw, key, id, active) in pause_buttons(
|
||||
w,
|
||||
h,
|
||||
self.external_server(),
|
||||
links,
|
||||
additions.is_some(),
|
||||
self.options.split_guest,
|
||||
) {
|
||||
let is_focused = self.pause_focus == Some(id);
|
||||
let hover = active && (ui::hovered(self.cursor, x, y, bw, 20.0) || is_focused);
|
||||
let label = match (id, &additions) {
|
||||
@@ -255,9 +271,10 @@ impl Game<'_> {
|
||||
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
|
||||
}
|
||||
}
|
||||
for (i, (x, y, sprite)) in pause_icons(w, h, links, additions.is_some())
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
for (i, (x, y, sprite)) in
|
||||
pause_icons(w, h, links, additions.is_some(), self.options.split_guest)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let active = icon_active(i, friends);
|
||||
let is_focused = (self.pause_focus == Some(10) && i == FRIENDS_ICON)
|
||||
@@ -344,6 +361,7 @@ impl Game<'_> {
|
||||
// ConfirmLinkScreen.confirmLink(this, CommonLinks...) on the pause screen.
|
||||
12 => self.confirm_trusted_link(BUGS_LINK, Screen::Paused),
|
||||
13 => self.confirm_trusted_link(feedback_link(), Screen::Paused),
|
||||
30 => self.open_split_settings(),
|
||||
// addCustomDialogButtons: DialogConnectionAccess.openDialog of the
|
||||
// additions dialog, from the pause menu.
|
||||
14 => {
|
||||
@@ -370,23 +388,30 @@ impl Game<'_> {
|
||||
};
|
||||
let links = !self.server_links().is_empty();
|
||||
let additions = self.pause_screen_additions();
|
||||
let icons = pause_icons(w, h, links, additions.is_some());
|
||||
let icons = pause_icons(w, h, links, additions.is_some(), self.options.split_guest);
|
||||
let friends = icons[FRIENDS_ICON];
|
||||
let hit = pause_buttons(w, h, self.external_server(), links, additions.is_some())
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4)
|
||||
.or_else(|| {
|
||||
// The report bugs and feedback icons (ids 12 and 13).
|
||||
(0..2)
|
||||
.find(|&i| ui::hovered(self.cursor, icons[i].0, icons[i].1, 20.0, 20.0))
|
||||
.map(|i| 12 + i as u8)
|
||||
})
|
||||
.or_else(|| {
|
||||
(self.friends_view().shown
|
||||
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
|
||||
.then_some(10)
|
||||
});
|
||||
let hit = pause_buttons(
|
||||
w,
|
||||
h,
|
||||
self.external_server(),
|
||||
links,
|
||||
additions.is_some(),
|
||||
self.options.split_guest,
|
||||
)
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4)
|
||||
.or_else(|| {
|
||||
// The report bugs and feedback icons (ids 12 and 13).
|
||||
(0..2)
|
||||
.find(|&i| ui::hovered(self.cursor, icons[i].0, icons[i].1, 20.0, 20.0))
|
||||
.map(|i| 12 + i as u8)
|
||||
})
|
||||
.or_else(|| {
|
||||
(self.friends_view().shown
|
||||
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
|
||||
.then_some(10)
|
||||
});
|
||||
if let Some(id) = hit {
|
||||
self.pause_focus = Some(id);
|
||||
self.audio.play_button_click();
|
||||
@@ -410,6 +435,7 @@ impl Game<'_> {
|
||||
self.external_server(),
|
||||
links,
|
||||
self.pause_screen_additions().is_some(),
|
||||
self.options.split_guest,
|
||||
);
|
||||
let mut active_ids: Vec<u8> = buttons.into_iter().filter(|b| b.5).map(|b| b.4).collect();
|
||||
let friends = self.friends_view().shown;
|
||||
@@ -457,6 +483,7 @@ impl Game<'_> {
|
||||
self.external_server(),
|
||||
links,
|
||||
self.pause_screen_additions().is_some(),
|
||||
self.options.split_guest,
|
||||
);
|
||||
let mut rects: Vec<(u8, f32, f32, f32)> = buttons
|
||||
.into_iter()
|
||||
@@ -464,10 +491,15 @@ impl Game<'_> {
|
||||
.map(|b| (b.4, b.0, b.1, b.2))
|
||||
.collect();
|
||||
let friends = self.friends_view().shown;
|
||||
for (i, (x, y, _)) in
|
||||
pause_icons(w, h, links, self.pause_screen_additions().is_some())
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
for (i, (x, y, _)) in pause_icons(
|
||||
w,
|
||||
h,
|
||||
links,
|
||||
self.pause_screen_additions().is_some(),
|
||||
self.options.split_guest,
|
||||
)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if icon_active(i, friends) {
|
||||
let id = if i == FRIENDS_ICON { 10 } else { 12 + i as u8 };
|
||||
@@ -871,7 +903,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
|
||||
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
|
||||
let buttons = pause_buttons(427.0, 240.0, remote, false, false);
|
||||
let buttons = pause_buttons(427.0, 240.0, remote, false, false, false);
|
||||
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
|
||||
assert_eq!(quit.3, label);
|
||||
assert!(quit.5);
|
||||
@@ -897,14 +929,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn server_links_adds_a_full_width_row_above_options() {
|
||||
let with = pause_buttons(427.0, 240.0, true, true, false);
|
||||
let without = pause_buttons(427.0, 240.0, true, false, false);
|
||||
let with = pause_buttons(427.0, 240.0, true, true, false, false);
|
||||
let without = pause_buttons(427.0, 240.0, true, false, false, false);
|
||||
let links = with.iter().find(|b| b.4 == 11).unwrap();
|
||||
assert_eq!(links.2, 204.0);
|
||||
assert_eq!(links.3, "menu.server_links");
|
||||
assert!(links.5);
|
||||
// The icon row sits above it; Options and Disconnect move down 24.
|
||||
let icons = pause_icons(427.0, 240.0, true, false);
|
||||
let icons = pause_icons(427.0, 240.0, true, false, false);
|
||||
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
|
||||
assert_eq!(icons[0].1, y0_icons);
|
||||
assert!(links.1 > icons[0].1);
|
||||
@@ -920,8 +952,8 @@ mod tests {
|
||||
fn pause_screen_additions_takes_the_server_links_row() {
|
||||
// getCustomAdditions: one additions button in row 3, never next to the
|
||||
// links button, so the grid keeps one extra row either way.
|
||||
let with = pause_buttons(427.0, 240.0, true, true, true);
|
||||
let links = pause_buttons(427.0, 240.0, true, true, false);
|
||||
let with = pause_buttons(427.0, 240.0, true, true, true, false);
|
||||
let links = pause_buttons(427.0, 240.0, true, true, false, false);
|
||||
assert!(with.iter().all(|b| b.4 != 11));
|
||||
let button = with.iter().find(|b| b.4 == 14).unwrap();
|
||||
assert_eq!(
|
||||
@@ -934,8 +966,30 @@ mod tests {
|
||||
assert_eq!(add.1, link.1);
|
||||
}
|
||||
assert_eq!(
|
||||
pause_icons(427.0, 240.0, true, true)[0].1,
|
||||
pause_icons(427.0, 240.0, true, false)[0].1
|
||||
pause_icons(427.0, 240.0, true, true, false)[0].1,
|
||||
pause_icons(427.0, 240.0, true, false, false)[0].1
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn split_settings_preserves_dialog_rows_and_icon_actions() {
|
||||
for (links, additions) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
let buttons = pause_buttons(427.0, 300.0, true, links, additions, true);
|
||||
let split = buttons.iter().find(|b| b.4 == 30).unwrap();
|
||||
let options = buttons.iter().find(|b| b.4 == 1).unwrap();
|
||||
assert_eq!(options.1, split.1 + 24.0);
|
||||
if links || additions {
|
||||
let custom = buttons
|
||||
.iter()
|
||||
.find(|b| b.4 == if additions { 14 } else { 11 })
|
||||
.unwrap();
|
||||
assert_eq!(split.1, custom.1 + 24.0);
|
||||
}
|
||||
assert!(buttons.iter().all(|b| b.4 != 12 && b.4 != 13));
|
||||
assert!(
|
||||
!pause_buttons(427.0, 300.0, true, links, additions, false)
|
||||
.iter()
|
||||
.any(|b| b.4 == 30)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,7 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(registry.resolve(&BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
properties: properties.into(),
|
||||
})?)
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,18 @@ fn active_pack<'a>(picked: &'a str, mods: &horizon_client::mod_list::ModList) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pack `settings` pick (or `HORIZON_SHADER_PACK`) is one of Horizon's own: only
|
||||
/// then do resource loads read the resource packs' LabPBR maps (`sprites::set_labpbr_maps`).
|
||||
pub(super) fn picks_native_pack(settings: &horizon_client::settings::Settings) -> bool {
|
||||
let dev = std::env::var("HORIZON_SHADER_PACK").ok();
|
||||
let picked = active_pack(
|
||||
dev.as_deref().unwrap_or(&settings.horizon.shader_pack),
|
||||
&settings.mods,
|
||||
);
|
||||
!picked.is_empty()
|
||||
&& horizon_shader_api::package::is_pack(&Game::shader_packs_dir().join(picked))
|
||||
}
|
||||
|
||||
/// The ids a pack gives entities and block entities (4.1, 5.3), for the spans of the entity
|
||||
/// geometry.
|
||||
pub(super) struct PackIds {
|
||||
|
||||
@@ -46,8 +46,8 @@ impl Game<'_> {
|
||||
if self.blocks_attacks(stack).is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.shield_cooldown == 0 && self.using.is_none() {
|
||||
self.using = Some((slot, 0));
|
||||
if self.shield_cooldown == 0 {
|
||||
self.start_using_item();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ impl Game<'_> {
|
||||
let Some(kinetic) = Kinetic::of(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
if self.start_using_item() {
|
||||
let actor = self.lan_acting;
|
||||
self.stabbed.retain(|(who, _), _| *who != actor);
|
||||
if let Some(p) = &self.player {
|
||||
|
||||
@@ -222,11 +222,54 @@ pub(super) struct Spill {
|
||||
/// Block entities the behaviour removed, for the game's own follow-ups
|
||||
/// (open screens, playing songs).
|
||||
pub(super) removed: Vec<([i32; 3], BlockEntity)>,
|
||||
/// Runs of `spawns` a player's use popped (a picked berry bush), for the
|
||||
/// mods' `native_loot_drops`.
|
||||
pub(super) used: Vec<UsedDrops>,
|
||||
}
|
||||
|
||||
/// `spawns[start..end]` came from `table` at the block `pos` in `state`.
|
||||
pub(super) struct UsedDrops {
|
||||
start: usize,
|
||||
end: usize,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
table: &'static str,
|
||||
}
|
||||
|
||||
impl Spill {
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.spawns.is_empty() && self.orbs.is_empty() && self.removed.is_empty()
|
||||
self.spawns.is_empty()
|
||||
&& self.orbs.is_empty()
|
||||
&& self.removed.is_empty()
|
||||
&& self.used.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets `say` change each use's run of `spawns`, newest first so the earlier
|
||||
/// ranges hold. A run's stacks keep their spots, and a stack `say` adds takes
|
||||
/// the run's last spot, else the block's top.
|
||||
fn retell_runs(
|
||||
spawns: &mut Vec<Spawn>,
|
||||
used: &[UsedDrops],
|
||||
mut say: impl FnMut(&UsedDrops, [f64; 3], &mut Vec<ItemStack>),
|
||||
) {
|
||||
for used in used.iter().rev() {
|
||||
let run: Vec<Spawn> = spawns.drain(used.start..used.end).collect();
|
||||
let mut drops: Vec<ItemStack> = run.iter().map(|s| s.stack.clone()).collect();
|
||||
let origin = used.pos.map(|c| f64::from(c) + 0.5);
|
||||
say(used, origin, &mut drops);
|
||||
let top = [origin[0], origin[1] + 0.5, origin[2]];
|
||||
let kept: Vec<Spawn> = drops
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, stack)| Spawn {
|
||||
stack,
|
||||
pos: run.get(i).or(run.last()).map_or(top, |s| s.pos),
|
||||
motion: run.get(i).and_then(|s| s.motion),
|
||||
delay: run.get(i).map_or(10, |s| s.delay),
|
||||
})
|
||||
.collect();
|
||||
spawns.splice(used.start..used.start, kept);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +367,12 @@ impl GameLevel<'_> {
|
||||
}
|
||||
/// `Block.dropFromBlockInteractLootTable` with `Block.popResource` of
|
||||
/// every stack.
|
||||
pub(super) fn drop_interact_loot(&mut self, pos: BlockPos, state: BlockStateId, table: &str) {
|
||||
pub(super) fn drop_interact_loot(
|
||||
&mut self,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
table: &'static str,
|
||||
) {
|
||||
let drops = self.items.interact_loot(
|
||||
table,
|
||||
self.registry,
|
||||
@@ -333,11 +381,19 @@ impl GameLevel<'_> {
|
||||
self.rand_sequences,
|
||||
self.seed,
|
||||
);
|
||||
let start = self.spill.spawns.len();
|
||||
for stack in drops {
|
||||
if &*stack.item != "minecraft:air" && stack.count > 0 {
|
||||
self.pop_resource([pos.x, pos.y, pos.z], stack);
|
||||
}
|
||||
}
|
||||
self.spill.used.push(UsedDrops {
|
||||
start,
|
||||
end: self.spill.spawns.len(),
|
||||
pos: [pos.x, pos.y, pos.z],
|
||||
state,
|
||||
table,
|
||||
});
|
||||
}
|
||||
/// `Block.dropResources(state, level, pos, blockEntity)`: the block's loot
|
||||
/// (from its random sequence, else the level random), with what its block
|
||||
@@ -648,7 +704,13 @@ impl Game<'_> {
|
||||
if self.spill.is_empty() {
|
||||
return;
|
||||
}
|
||||
let spill = std::mem::take(&mut self.spill);
|
||||
let mut spill = std::mem::take(&mut self.spill);
|
||||
let used = std::mem::take(&mut spill.used);
|
||||
let tool = self.held().cloned();
|
||||
retell_runs(&mut spill.spawns, &used, |used, origin, drops| {
|
||||
let block = Some((used.pos, used.state));
|
||||
self.native_use_drops(used.table, origin, block, None, tool.as_ref(), drops);
|
||||
});
|
||||
for s in spill.spawns {
|
||||
let motion = s.motion.unwrap_or_else(new_item_motion);
|
||||
self.spawn_item(s.stack, s.pos, motion, s.delay);
|
||||
@@ -676,6 +738,46 @@ mod tests {
|
||||
use horizon_client::{block_entity_item, item_data, mobs::raider, save};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
fn a_used_run_is_retold_in_place() {
|
||||
let spawn = |item: &str, x: f64| Spawn {
|
||||
stack: ItemStack::new(item, 1),
|
||||
pos: [x, 0.0, 0.0],
|
||||
motion: None,
|
||||
delay: 10,
|
||||
};
|
||||
let mut spawns = vec![
|
||||
spawn("minecraft:stone", 0.0),
|
||||
spawn("minecraft:sweet_berries", 1.0),
|
||||
spawn("minecraft:sweet_berries", 2.0),
|
||||
spawn("minecraft:dirt", 3.0),
|
||||
];
|
||||
let used = [UsedDrops {
|
||||
start: 1,
|
||||
end: 3,
|
||||
pos: [4, 64, 4],
|
||||
state: BlockStateId(0),
|
||||
table: "minecraft:harvest/sweet_berry_bush",
|
||||
}];
|
||||
retell_runs(&mut spawns, &used, |used, origin, drops| {
|
||||
assert_eq!(used.table, "minecraft:harvest/sweet_berry_bush");
|
||||
assert_eq!(origin, [4.5, 64.5, 4.5]);
|
||||
assert_eq!(drops.len(), 2);
|
||||
drops.remove(0);
|
||||
drops.push(ItemStack::new("minecraft:diamond", 1));
|
||||
});
|
||||
let items: Vec<(&str, f64)> = spawns.iter().map(|s| (&*s.stack.item, s.pos[0])).collect();
|
||||
assert_eq!(
|
||||
items,
|
||||
[
|
||||
("minecraft:stone", 0.0),
|
||||
("minecraft:sweet_berries", 1.0),
|
||||
("minecraft:diamond", 2.0),
|
||||
("minecraft:dirt", 3.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ominous_banner_survives_placement_save_and_drop() {
|
||||
let original = raider::ominous_banner();
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
//! disconnects leaves its player waiting, its view dimmed, until it comes back or another takes
|
||||
//! over (`pads`).
|
||||
//!
|
||||
//! `HORIZON_SPLIT=N` (testing, the only way in so far): splitscreen watches the controllers, and
|
||||
//! Horizon Settings > Splitscreen... enables joining (off by default).
|
||||
//! `HORIZON_SPLIT=N` (testing) enables it too, and
|
||||
//! N-1 players without one join once P1 is in its world, as P2, P3... (`HORIZON_SPLIT=1`: only
|
||||
//! the controllers). The player in seat N takes `horizon-ctl` on P1's socket path + "N".
|
||||
|
||||
@@ -30,6 +31,7 @@ use super::{
|
||||
pad::{PadId, PadRouter},
|
||||
start_game,
|
||||
};
|
||||
use horizon_client::gamepad::Button;
|
||||
use horizon_client::split_layout::{self as layout, MAX_PLAYERS, Placement, View};
|
||||
use horizon_client::{Result, gfx::compositor::Compositor, widgets};
|
||||
use std::{
|
||||
@@ -77,10 +79,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
const WINDOW_1: &str = "Window 1";
|
||||
const NEW_WINDOW: &str = "New window";
|
||||
|
||||
/// A place offered while picking a profile.
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum Spot {
|
||||
Window(u8),
|
||||
New,
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
struct Guest<'a> {
|
||||
game: Box<Game<'a>>,
|
||||
/// The name it plays as.
|
||||
name: String,
|
||||
/// The profile it plays as; `None` while it picks one.
|
||||
name: Option<String>,
|
||||
/// Its seat (2 to `MAX_PLAYERS`): its number (P2...), its control socket, its sounds.
|
||||
seat: usize,
|
||||
options: PathBuf,
|
||||
@@ -90,15 +103,19 @@ struct Guest<'a> {
|
||||
window: u8,
|
||||
/// Its buttons and focus targets while another player's game runs.
|
||||
widgets: widgets::Saved,
|
||||
/// Its profile got into the world: turned away after that (a kick), it leaves; before, it
|
||||
/// picks again.
|
||||
entered: bool,
|
||||
bumpers: [bool; 2],
|
||||
}
|
||||
|
||||
pub(super) struct Split<'a> {
|
||||
pub(super) host: Game<'a>,
|
||||
guests: Vec<Guest<'a>>,
|
||||
/// `HORIZON_SPLIT`: splitscreen watches the controllers for players joining.
|
||||
watching: bool,
|
||||
/// `HORIZON_SPLIT=N`: players still to add once P1 is in a world.
|
||||
test_players: usize,
|
||||
/// `HORIZON_SPLIT` is set: splitscreen is on for this run, without touching the saved switch.
|
||||
forced: bool,
|
||||
/// While another player is in, the windows players play in, each with its compositor: the
|
||||
/// game's first, then the others (else P1 draws on its window).
|
||||
surfaces: Vec<Surface>,
|
||||
@@ -106,6 +123,8 @@ pub(super) struct Split<'a> {
|
||||
/// monitor, and P1's.
|
||||
windows: Vec<layout::Window>,
|
||||
p1_window: u8,
|
||||
/// Seats in the order their views fill a shared window.
|
||||
order: Vec<usize>,
|
||||
/// Whether Window 1 is shown (hidden while nobody plays in it): set once per change, since
|
||||
/// Wayland can't say whether a window is visible.
|
||||
window1_shown: bool,
|
||||
@@ -150,14 +169,15 @@ impl<'a> Split<'a> {
|
||||
Self {
|
||||
host,
|
||||
guests: Vec::new(),
|
||||
watching: split.is_some(),
|
||||
test_players,
|
||||
forced: split.is_some(),
|
||||
surfaces: Vec::new(),
|
||||
windows: vec![layout::Window {
|
||||
number: 1,
|
||||
placement: Placement::Free(None),
|
||||
}],
|
||||
p1_window: 1,
|
||||
order: Vec::new(),
|
||||
window1_shown: true,
|
||||
window_rects: Default::default(),
|
||||
screens: Vec::new(),
|
||||
@@ -304,7 +324,10 @@ impl<'a> Split<'a> {
|
||||
fn name(&self, i: usize) -> String {
|
||||
match i {
|
||||
0 => "P1".into(),
|
||||
_ => self.guests[i - 1].name.clone(),
|
||||
_ => self.guests[i - 1]
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "New player".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,13 +422,17 @@ impl<'a> Split<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// P1 alone again: it draws on its window again.
|
||||
/// Splitscreen off or P1 alone again: it draws on its window again.
|
||||
#[inline(never)]
|
||||
fn disable(&mut self, event_loop: &ActiveEventLoop) -> Result<()> {
|
||||
fn disable(&mut self, event_loop: &ActiveEventLoop, watching: bool) -> Result<()> {
|
||||
for k in (0..self.guests.len()).rev() {
|
||||
self.remove_guest(k);
|
||||
}
|
||||
self.applied.clear();
|
||||
self.order.clear();
|
||||
if !watching {
|
||||
self.stop_routing();
|
||||
}
|
||||
self.p1_window = 1;
|
||||
self.windows.truncate(1);
|
||||
self.surfaces.truncate(1);
|
||||
@@ -466,13 +493,17 @@ impl<'a> Split<'a> {
|
||||
fn admit(&mut self, _event_loop: &ActiveEventLoop, pad_joins: Vec<PadId>) -> bool {
|
||||
let mut changed = false;
|
||||
for pad in pad_joins {
|
||||
match self.join(Some(pad)) {
|
||||
match self.join(Some(pad), None) {
|
||||
Ok(()) => changed = true,
|
||||
Err(e) => log::warn!("Horizon: splitscreen: a player could not join: {e}"),
|
||||
}
|
||||
}
|
||||
if self.in_world() && self.test_players > 0 {
|
||||
match self.join(None) {
|
||||
// HORIZON_SPLIT_PICK: they pick a profile like a controller's player, else P2...
|
||||
let name = std::env::var_os("HORIZON_SPLIT_PICK")
|
||||
.is_none()
|
||||
.then(|| format!("P{}", self.free_seat().unwrap_or(0)));
|
||||
match self.join(None, name) {
|
||||
Ok(()) => {
|
||||
self.test_players -= 1;
|
||||
changed = true;
|
||||
@@ -652,16 +683,19 @@ impl ApplicationHandler for Split<'_> {
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// The last other player gone: P1 draws on its window again.
|
||||
self.refresh_monitors();
|
||||
let on = self.host.settings.extras.splitscreen || self.forced;
|
||||
self.host.ui.options.split_joined = self.guests.iter().any(|g| g.name.is_some());
|
||||
// Off, or the last other player gone: P1 draws on its window again.
|
||||
if !self.surfaces.is_empty()
|
||||
&& self.guests.is_empty()
|
||||
&& let Err(e) = self.disable(event_loop)
|
||||
&& (!on || self.guests.is_empty())
|
||||
&& let Err(e) = self.disable(event_loop, on)
|
||||
{
|
||||
log::warn!("Horizon: splitscreen could not switch back: {e}");
|
||||
}
|
||||
if self.surfaces.is_empty() {
|
||||
if !self.watching {
|
||||
if !on {
|
||||
self.stop_routing();
|
||||
return self.host.about_to_wait(event_loop);
|
||||
}
|
||||
// Alone, the controllers watched for someone joining; P1's own drives P1, its
|
||||
@@ -684,6 +718,8 @@ impl ApplicationHandler for Split<'_> {
|
||||
self.turn(0);
|
||||
self.host.about_to_wait(event_loop);
|
||||
let mut changed = self.drop_out() | self.admit(event_loop, pad_joins);
|
||||
self.sync_profiles();
|
||||
changed |= self.pick_profiles();
|
||||
changed |= self.sync_surfaces(event_loop);
|
||||
if changed || self.views() != self.applied {
|
||||
self.relayout(event_loop);
|
||||
@@ -691,7 +727,7 @@ impl ApplicationHandler for Split<'_> {
|
||||
// Each player's badge: who it is, shown a moment when something changes for it.
|
||||
self.host.split_hud.badge = Some("P1".into());
|
||||
for g in &mut self.guests {
|
||||
g.game.split_hud.badge = Some(g.name.clone());
|
||||
g.game.split_hud.badge = Some(g.name.clone().unwrap_or_else(|| "New player".into()));
|
||||
}
|
||||
let master = self.host.audio.master;
|
||||
for i in 0..self.guests.len() {
|
||||
@@ -703,6 +739,26 @@ impl ApplicationHandler for Split<'_> {
|
||||
if let Some(e) = &self.guests[i].game.error {
|
||||
log::error!("Horizon: splitscreen: P{}: {e}", self.guests[i].seat);
|
||||
}
|
||||
// Choosing its profile, the bumpers move its view (its pad as its game read it: the
|
||||
// controller, or `horizon-ctl pad`).
|
||||
let place = self.stop_label(i + 1);
|
||||
let game = &mut self.guests[i].game;
|
||||
if game.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
|
||||
&& game.screen == super::Screen::Options
|
||||
{
|
||||
game.ui.options.split_profiles.place = place;
|
||||
let state = game.pad.state;
|
||||
let held = [
|
||||
state.held(Button::LeftBumper),
|
||||
state.held(Button::RightBumper),
|
||||
];
|
||||
let before = std::mem::replace(&mut self.guests[i].bumpers, held);
|
||||
if held[0] && !before[0] {
|
||||
self.move_with_bumpers(i + 1, true);
|
||||
} else if held[1] && !before[1] {
|
||||
self.move_with_bumpers(i + 1, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.turn(0);
|
||||
self.present();
|
||||
|
||||
@@ -77,6 +77,11 @@ pub(super) fn lost_prompt(name: &str) -> Prompt {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Another player's pause menu opens its profiles to switch identity.
|
||||
pub(in crate::game) fn open_split_settings(&mut self) {
|
||||
self.open_options_at(false, horizon_client::options_ui::Page::SplitProfiles);
|
||||
}
|
||||
|
||||
/// Splitscreen over this player's view, above the other toasts (`ToastManager`'s slots).
|
||||
pub(in crate::game) fn draw_split_hud(&mut self, batch: &mut HudBatch, hidden: bool) {
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -151,7 +156,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let max = (w - 24.0).min(260.0);
|
||||
let lines = wrap(¬ice.body, max, width);
|
||||
let lines = horizon_client::split_profiles::wrap(¬ice.body, max, width);
|
||||
let inner = lines
|
||||
.iter()
|
||||
.map(|l| width(l))
|
||||
@@ -176,8 +181,12 @@ impl Game<'_> {
|
||||
impl<'a> Split<'a> {
|
||||
/// The join question on every player's view in the world (a strip at the top), or gone.
|
||||
pub(super) fn prompt_join(&mut self, prompt: Option<Prompt>) {
|
||||
let views = std::iter::once(&mut self.host.split_hud)
|
||||
.chain(self.guests.iter_mut().map(|g| &mut g.game.split_hud));
|
||||
let views = std::iter::once(&mut self.host.split_hud).chain(
|
||||
self.guests
|
||||
.iter_mut()
|
||||
.filter(|g| g.name.is_some())
|
||||
.map(|g| &mut g.game.split_hud),
|
||||
);
|
||||
for hud in views {
|
||||
match &prompt {
|
||||
Some(p) => {
|
||||
@@ -201,25 +210,3 @@ impl<'a> Split<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` in lines at most `max` wide (by `width`), broken between words.
|
||||
fn wrap(text: &str, max: f32, width: impl Fn(&str) -> f32) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut line = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
let longer = if line.is_empty() {
|
||||
word.to_owned()
|
||||
} else {
|
||||
format!("{line} {word}")
|
||||
};
|
||||
if width(&longer) > max && !line.is_empty() {
|
||||
lines.push(std::mem::replace(&mut line, word.to_owned()));
|
||||
} else {
|
||||
line = longer;
|
||||
}
|
||||
}
|
||||
if !line.is_empty() {
|
||||
lines.push(line);
|
||||
}
|
||||
lines
|
||||
}
|
||||
@@ -64,6 +64,19 @@ pub(super) fn claim<Id>(
|
||||
}
|
||||
|
||||
impl Split<'_> {
|
||||
/// Give P1 its normal controller reader again and discard pending joins and handoffs.
|
||||
pub(super) fn stop_routing(&mut self) {
|
||||
if self.host.pad.feed.take().is_some() {
|
||||
self.host.pad.release_hardware();
|
||||
self.host.split_hud = Default::default();
|
||||
self.offers.clear();
|
||||
self.fresh.clear();
|
||||
self.lost.clear();
|
||||
self.p1_pad = None;
|
||||
self.pads = PadRouter::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// The pad's state for its player: nothing while the press that gave it is still held.
|
||||
fn pad_state(&mut self, pad: Option<PadId>) -> PadState {
|
||||
let Some(id) = pad else {
|
||||
@@ -212,12 +225,17 @@ impl Split<'_> {
|
||||
return;
|
||||
};
|
||||
g.pad = None;
|
||||
let Some(name) = g.name.clone() else {
|
||||
// Still picking a profile: as if it pressed Cancel (`pick_profiles`).
|
||||
g.game.screen = crate::game::Screen::Playing;
|
||||
return;
|
||||
};
|
||||
self.lost.push(Lost {
|
||||
seat: g.seat,
|
||||
id,
|
||||
uuid,
|
||||
});
|
||||
g.game.split_hud.prompt = Some(lost_prompt(&g.name));
|
||||
g.game.split_hud.prompt = Some(lost_prompt(&name));
|
||||
}
|
||||
|
||||
/// Player in `seat` plays with controller `id` (back, or another): its question goes.
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
//! Players coming and going: joining P1's world through memory, dropping out.
|
||||
//! Players coming and going: joining P1's world through memory as a profile it picks
|
||||
//! (`split_profiles`), dropping out.
|
||||
use super::*;
|
||||
use horizon_client::gamepad::PadState;
|
||||
use horizon_client::{gamepad::PadState, options_ui::Page};
|
||||
|
||||
/// Whether `game` shows its profile page now.
|
||||
fn on_profiles(game: &Game<'_>) -> bool {
|
||||
game.screen == crate::game::Screen::Options && game.ui.options.page() == Page::SplitProfiles
|
||||
}
|
||||
|
||||
/// Why a player's game left its server, when the server said (its Disconnected or error screen).
|
||||
fn turned_away(game: &Game<'_>) -> Option<String> {
|
||||
use horizon_client::{multiplayer::Screen, title::Mode};
|
||||
match &game.title.as_ref()?.mode {
|
||||
Mode::Multiplayer(Screen::Disconnected { reason, .. }) => Some(reason.clone()),
|
||||
Mode::Error { message, .. } => Some(message.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Game` `start` makes (~67 KB, by value) into a box, in a frame of its own that is gone
|
||||
/// once it is: the copies stay out of the caller's frame, deep in the event loop.
|
||||
@@ -15,29 +31,134 @@ fn boxed(start: impl FnOnce() -> Result<Option<Game<'static>>>) -> Result<Box<Ga
|
||||
|
||||
impl Split<'_> {
|
||||
/// The first seat free (2 to `MAX_PLAYERS`).
|
||||
fn free_seat(&self) -> Option<usize> {
|
||||
pub(super) fn free_seat(&self) -> Option<usize> {
|
||||
(2..=MAX_PLAYERS).find(|n| self.guests.iter().all(|g| g.seat != *n))
|
||||
}
|
||||
|
||||
/// A player joins P1's world as `P<seat>`, with its own options in the game folder's
|
||||
/// `splitscreen/profiles/P<seat>/` (P1's, the first time).
|
||||
pub(super) fn join(&mut self, pad: Option<PadId>) -> Result<()> {
|
||||
/// The splitscreen folder in the game folder: `profiles/<name>/` holds each profile.
|
||||
fn folder() -> Result<PathBuf> {
|
||||
Ok(std::env::current_dir()?.join("splitscreen"))
|
||||
}
|
||||
|
||||
/// Add a player driven by `pad`: it picks a profile first, unless `name` is given (tests).
|
||||
pub(super) fn join(&mut self, pad: Option<PadId>, name: Option<String>) -> Result<()> {
|
||||
let seat = self.free_seat().ok_or("every seat is taken")?;
|
||||
let name = format!("P{seat}");
|
||||
let profile = std::env::current_dir()?.join(format!("splitscreen/profiles/{name}"));
|
||||
// The first other player: P1 moves to the compositor now (back with nobody joined, if
|
||||
// this one can't: `about_to_wait`).
|
||||
if self.surfaces.is_empty() {
|
||||
self.enable()?;
|
||||
}
|
||||
// Until it has a profile, a scratch options file: never P1's.
|
||||
let options = Self::folder()?.join(".joining/options.txt");
|
||||
OPTIONS.set(Some(options.clone()));
|
||||
let game = self.start_guest(seat, &options, self.host.settings.clone());
|
||||
OPTIONS.set(None);
|
||||
let mut game = game?;
|
||||
if let Some(pad) = pad {
|
||||
// The press that joined it doesn't act in its game.
|
||||
self.fresh.push(pad);
|
||||
}
|
||||
// Over its title screen: Cancel leaves (`pick_profiles`).
|
||||
game.open_options_at(true, Page::SplitProfiles);
|
||||
let i = self.guests.len() + 1;
|
||||
self.guests.push(Guest {
|
||||
game,
|
||||
name: None,
|
||||
seat,
|
||||
options,
|
||||
pad,
|
||||
window: 1,
|
||||
widgets: Default::default(),
|
||||
entered: false,
|
||||
bumpers: [false; 2],
|
||||
});
|
||||
self.planned_window(i, seat);
|
||||
if let Some(name) = name {
|
||||
self.enter(i - 1, name)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The names playing now: P1's and the joined players'.
|
||||
fn taken(&self) -> Vec<String> {
|
||||
std::iter::once(self.host.options.player_name())
|
||||
.chain(self.guests.iter().filter_map(|g| g.name.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The profile pages just opened, filled: the list and who plays now.
|
||||
pub(super) fn sync_profiles(&mut self) {
|
||||
let taken = self.taken();
|
||||
for g in self.guests.iter_mut().filter(|g| on_profiles(&g.game)) {
|
||||
g.game.ui.options.split_profiles.taken = taken.clone();
|
||||
}
|
||||
let open = |g: &Guest<'_>| on_profiles(&g.game) && !g.game.ui.options.split_profiles.filled;
|
||||
if !self.guests.iter().any(open) {
|
||||
return;
|
||||
}
|
||||
let names = Self::folder()
|
||||
.and_then(|f| {
|
||||
Ok(horizon_client::split_profiles::store::names(
|
||||
&f.join("profiles"),
|
||||
)?)
|
||||
})
|
||||
.map(|mut names| {
|
||||
names.sort_by_key(|n| n.to_lowercase());
|
||||
names
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: splitscreen: the profiles: {e}");
|
||||
Vec::new()
|
||||
});
|
||||
for g in self.guests.iter_mut().filter(|g| open(g)) {
|
||||
let page = &mut g.game.ui.options.split_profiles;
|
||||
page.filled = true;
|
||||
page.names = names.clone();
|
||||
if let Some(me) = g.name.as_ref().filter(|me| !names.contains(me)) {
|
||||
page.names.insert(0, me.clone());
|
||||
}
|
||||
page.taken = taken.clone();
|
||||
page.current = g.name.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Guest `i`, playing, switches to profile `name` where it plays: out of the world as its
|
||||
/// old profile, in as the new one.
|
||||
fn switch(&mut self, i: usize, name: String) -> Result<()> {
|
||||
self.turn(i + 1);
|
||||
let left = self.guests[i].game.leave_server(None);
|
||||
self.turn(0);
|
||||
let old = self.guests[i].name.take();
|
||||
if let (Some(server), Some(old)) = (&self.host.integrated, &old) {
|
||||
server.leave_split(old);
|
||||
}
|
||||
left?;
|
||||
self.enter(i, name)
|
||||
}
|
||||
|
||||
/// Guest `i` plays as profile `name`: its options, then into P1's world.
|
||||
pub(super) fn enter(&mut self, i: usize, name: String) -> Result<()> {
|
||||
if self.taken().iter().any(|n| n.eq_ignore_ascii_case(&name)) {
|
||||
return Err(format!("{name} is already playing.").into());
|
||||
}
|
||||
// The page's names are player names; a test seat's (P2) isn't one, but no name leaves
|
||||
// the profiles folder.
|
||||
if !folder_name(&name) {
|
||||
return Err(format!("{name:?} can't be a profile.").into());
|
||||
}
|
||||
let profiles = Self::folder()?.join("profiles");
|
||||
if horizon_client::split_profiles::valid_name(&name) {
|
||||
horizon_client::split_profiles::store::restore(&profiles, &name)?;
|
||||
}
|
||||
let profile = profiles.join(&name);
|
||||
let options = profile.join("options.txt");
|
||||
let settings = if options.exists() {
|
||||
let mut settings = if options.exists() {
|
||||
horizon_client::settings::Settings::load(&options)
|
||||
} else {
|
||||
std::fs::create_dir_all(&profile)?;
|
||||
self.host.settings.save(&options);
|
||||
self.host.settings.clone()
|
||||
};
|
||||
// The first other player: P1 moves to the compositor now (back with nobody joined, if
|
||||
// this one can't: `about_to_wait`).
|
||||
if self.surfaces.is_empty() {
|
||||
self.enable()?;
|
||||
}
|
||||
let pipe = self
|
||||
.host
|
||||
.integrated
|
||||
@@ -45,41 +166,99 @@ impl Split<'_> {
|
||||
.ok_or("P1 is not in its world")?
|
||||
.join_split(&name)
|
||||
.ok_or("P1's world does not let players in")?;
|
||||
OPTIONS.set(Some(options.clone()));
|
||||
let game = self.start_guest(seat, &options, settings);
|
||||
OPTIONS.set(None);
|
||||
let joined = game.and_then(|mut game| {
|
||||
game.connect_local(pipe, &name)?;
|
||||
Ok(game)
|
||||
});
|
||||
let game = match joined {
|
||||
Ok(game) => game,
|
||||
Err(e) => {
|
||||
// Let in for nothing: the world pauses again, the name is free.
|
||||
if let Some(server) = &self.host.integrated {
|
||||
server.leave_split(&name);
|
||||
}
|
||||
return Err(e);
|
||||
self.guests[i].options = options;
|
||||
self.turn(i + 1);
|
||||
let guest = &mut self.guests[i];
|
||||
// Its profile page closes as it goes in.
|
||||
guest.game.screen = crate::game::Screen::Playing;
|
||||
settings.extras.agent_control &= guest.game.options.control.is_some();
|
||||
guest.game.settings = settings;
|
||||
guest.game.options.player_name = Some(name.clone());
|
||||
guest.game.apply_settings();
|
||||
let joined = guest.game.connect_local(pipe, &name);
|
||||
self.turn(0);
|
||||
if let Err(e) = joined {
|
||||
// Let in for nothing: the world pauses again, the name is free.
|
||||
if let Some(server) = &self.host.integrated {
|
||||
server.leave_split(&name);
|
||||
}
|
||||
};
|
||||
if let Some(pad) = pad {
|
||||
// The press that joined it doesn't act in its game.
|
||||
self.fresh.push(pad);
|
||||
return Err(e);
|
||||
}
|
||||
let i = self.guests.len() + 1;
|
||||
self.guests.push(Guest {
|
||||
game,
|
||||
name,
|
||||
seat,
|
||||
options,
|
||||
pad,
|
||||
window: 1,
|
||||
widgets: Default::default(),
|
||||
});
|
||||
self.planned_window(i, seat);
|
||||
self.guests[i].name = Some(name);
|
||||
self.guests[i].entered = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Players' profile pages: a pick goes in, one that can't says why there; a forgotten
|
||||
/// profile is hidden; a joining player who cancels (or closes the page) leaves.
|
||||
pub(super) fn pick_profiles(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
let mut i = 0;
|
||||
while i < self.guests.len() {
|
||||
if let Some(name) = self.guests[i]
|
||||
.game
|
||||
.ui
|
||||
.options
|
||||
.split_profiles
|
||||
.take_forgotten()
|
||||
{
|
||||
let hidden = if self.taken().iter().any(|n| n.eq_ignore_ascii_case(&name)) {
|
||||
Err("This profile cannot be forgotten while it is playing.".into())
|
||||
} else {
|
||||
Self::folder().and_then(|f| {
|
||||
Ok(horizon_client::split_profiles::store::hide(
|
||||
&f.join("profiles"),
|
||||
&name,
|
||||
)?)
|
||||
})
|
||||
};
|
||||
match hidden {
|
||||
Ok(()) => {
|
||||
for guest in &mut self.guests {
|
||||
guest.game.ui.options.split_profiles.filled = false;
|
||||
}
|
||||
}
|
||||
Err(e) => self.refuse(i, e.to_string()),
|
||||
}
|
||||
}
|
||||
let joining = self.guests[i].name.is_none();
|
||||
match self.guests[i].game.ui.options.split_profiles.take_choice() {
|
||||
Some(Some(name)) => match (|| {
|
||||
if self.taken().iter().any(|n| n.eq_ignore_ascii_case(&name)) {
|
||||
return Err(format!("{name} is already playing.").into());
|
||||
}
|
||||
if joining {
|
||||
self.enter(i, name)
|
||||
} else {
|
||||
self.switch(i, name)
|
||||
}
|
||||
})() {
|
||||
Ok(()) => changed = true,
|
||||
Err(why) => self.refuse(i, why.to_string()),
|
||||
},
|
||||
_ if joining && !on_profiles(&self.guests[i].game) => {
|
||||
self.remove_guest(i);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Guest `i`'s pick can't play: its profile page says why (opened again if it had left the
|
||||
/// world for it).
|
||||
fn refuse(&mut self, i: usize, why: String) {
|
||||
log::warn!("Horizon: splitscreen: P{}: {why}", self.guests[i].seat);
|
||||
let game = &mut self.guests[i].game;
|
||||
if !on_profiles(game) {
|
||||
game.open_options_at(true, Page::SplitProfiles);
|
||||
}
|
||||
game.ui.options.split_profiles.refused = Some(why);
|
||||
}
|
||||
|
||||
/// A new player's game in its seat. Boxed as it comes out of `start_game`, and never
|
||||
/// inlined: a `Game` is ~67 KB, and copies of it in the event loop's frames overflowed
|
||||
/// Windows' 1 MB main stack.
|
||||
@@ -106,7 +285,6 @@ impl Split<'_> {
|
||||
options.split_guest = true;
|
||||
options.offscreen = true;
|
||||
options.paced_by_split = true;
|
||||
options.player_name = Some(format!("P{seat}"));
|
||||
// P1's socket (`--control` or Agent Control) + the seat: every game in this process
|
||||
// would otherwise take the one default endpoint, P1's.
|
||||
options.control = host.control.as_ref().map(|c| {
|
||||
@@ -152,17 +330,40 @@ impl Split<'_> {
|
||||
guest.apply_settings();
|
||||
// Its pad comes from the split (`route_pads`): it never opens the controllers itself.
|
||||
guest.pad.feed = Some(PadState::default());
|
||||
guest.ui.options.split_guest = true;
|
||||
Ok(guest)
|
||||
}
|
||||
|
||||
/// Players who left their world (Disconnect) or failed drop out; all of them once P1 has
|
||||
/// left its world. Whether any did.
|
||||
/// Players who disconnected drop out; all of them once P1 has left its world. One the
|
||||
/// server turned away as it came in picks again, told why; one it sent away later (a kick)
|
||||
/// leaves, and P1 hears why. Whether any left.
|
||||
pub(super) fn drop_out(&mut self) -> bool {
|
||||
let p1_left = self.host.title.is_some() || self.host.integrated.is_none();
|
||||
let before = self.guests.len();
|
||||
if p1_left {
|
||||
self.host.split_hud.notice = None;
|
||||
}
|
||||
for k in (0..self.guests.len()).rev() {
|
||||
let game = &self.guests[k].game;
|
||||
if p1_left || game.remote.is_none() || game.error.is_some() {
|
||||
let g = &mut self.guests[k];
|
||||
g.entered |= g.game.remote.is_some() && g.game.player.is_some();
|
||||
let g = &self.guests[k];
|
||||
// A player picking a profile has no connection yet.
|
||||
let playing = g.name.is_none() || g.game.remote.is_some();
|
||||
let why = (!p1_left && g.game.error.is_none() && !playing)
|
||||
.then(|| turned_away(&g.game))
|
||||
.flatten();
|
||||
if let (true, Some(name), Some(why)) = (g.entered, &g.name, &why) {
|
||||
self.host.split_hud.notice =
|
||||
Some(Notice::new(format!("{name} could not stay"), why.clone()));
|
||||
} else if let Some(why) = why {
|
||||
let old = self.guests[k].name.take();
|
||||
if let (Some(server), Some(old)) = (&self.host.integrated, &old) {
|
||||
server.leave_split(old);
|
||||
}
|
||||
self.refuse(k, why);
|
||||
continue;
|
||||
}
|
||||
if p1_left || !playing || g.game.error.is_some() {
|
||||
self.remove_guest(k);
|
||||
}
|
||||
}
|
||||
@@ -182,8 +383,26 @@ impl Split<'_> {
|
||||
if self.focus > k + 1 {
|
||||
self.focus -= 1;
|
||||
}
|
||||
if let Some(server) = &self.host.integrated {
|
||||
server.leave_split(&guest.name);
|
||||
if let (Some(server), Some(name)) = (&self.host.integrated, &guest.name) {
|
||||
server.leave_split(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A name that stays one folder inside `profiles`.
|
||||
fn folder_name(name: &str) -> bool {
|
||||
!name.is_empty() && name != "." && !name.contains(['/', '\\']) && !name.contains("..")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::folder_name;
|
||||
|
||||
#[test]
|
||||
fn a_profile_name_never_leaves_the_profiles_folder() {
|
||||
assert!(folder_name("P2") && folder_name("Steve"));
|
||||
for bad in ["", ".", "..", "../x", "a/b", "a\\b"] {
|
||||
assert!(!folder_name(bad), "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,23 @@ impl<'a> Split<'a> {
|
||||
self.main_screen.as_ref()
|
||||
}
|
||||
|
||||
/// How a monitor is called: numbered left to right, with its size (the system's names are
|
||||
/// ids on some).
|
||||
pub(super) fn monitor_label(&self, name: &str) -> Option<String> {
|
||||
let (n, (_, m)) = self
|
||||
.monitors()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, (n, _))| n == name)?;
|
||||
let size = m.size();
|
||||
Some(format!(
|
||||
"Monitor {} ({}x{})",
|
||||
n + 1,
|
||||
size.width,
|
||||
size.height
|
||||
))
|
||||
}
|
||||
|
||||
/// The window player `i` plays in now: Window 1 for a window that fills a monitor that's
|
||||
/// gone, or the one Window 1 is on.
|
||||
pub(super) fn window_of(&self, i: usize) -> u8 {
|
||||
@@ -161,6 +178,94 @@ impl<'a> Split<'a> {
|
||||
if shown { number } else { 1 }
|
||||
}
|
||||
|
||||
/// Where player `i` can play, as the screens say it: Window 1 (the game's), the other windows
|
||||
/// open, a new window, and each monitor no window fills but Window 1's. The page's button
|
||||
/// steps forward through them (buttons take the left click only), a joining player's bumpers
|
||||
/// both ways. A player alone in its window is at "New window" or its monitor's choice (its
|
||||
/// window is that), so the list stays the same as it steps.
|
||||
pub(super) fn choices(&self, i: usize) -> Vec<(String, Spot)> {
|
||||
let alone = self.alone(i);
|
||||
let mine = self.window_of(i);
|
||||
let mut open: Vec<u8> = (0..self.players())
|
||||
.map(|j| self.window_of(j))
|
||||
.filter(|&n| !(alone && n == mine))
|
||||
.collect();
|
||||
open.sort_unstable();
|
||||
open.dedup();
|
||||
let filled: Vec<&String> = open
|
||||
.iter()
|
||||
.filter_map(|&n| match self.placement(n) {
|
||||
Some(Placement::Fill(name)) => Some(name),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let main = self.main_monitor();
|
||||
let monitors = self.monitors().iter().filter_map(|(name, _)| {
|
||||
if Some(name) == main || filled.contains(&name) {
|
||||
return None;
|
||||
}
|
||||
Some((self.monitor_label(name)?, Spot::Fill(name.clone())))
|
||||
});
|
||||
std::iter::once((WINDOW_1.to_owned(), Spot::Window(1)))
|
||||
.chain(
|
||||
open.into_iter()
|
||||
.filter(|&n| n != 1)
|
||||
.map(|n| (self.window_label(n), Spot::Window(n))),
|
||||
)
|
||||
.chain(std::iter::once((NEW_WINDOW.to_owned(), Spot::New)))
|
||||
.chain(monitors)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Player `i` plays alone in a window of its own (not Window 1).
|
||||
pub(super) fn alone(&self, i: usize) -> bool {
|
||||
let mine = self.window_of(i);
|
||||
mine != 1 && (0..self.players()).all(|j| j == i || self.window_of(j) != mine)
|
||||
}
|
||||
|
||||
/// Player `i`'s place among its choices: its window, or alone, what its window is.
|
||||
pub(super) fn current(&self, i: usize) -> Spot {
|
||||
let mine = self.window_of(i);
|
||||
match self.placement(mine) {
|
||||
Some(Placement::Free(_)) if self.alone(i) => Spot::New,
|
||||
Some(Placement::Fill(name)) if self.alone(i) => Spot::Fill(name.clone()),
|
||||
_ => Spot::Window(mine),
|
||||
}
|
||||
}
|
||||
|
||||
/// How window `number` is shown: "Window 2", and the monitor it fills.
|
||||
pub(super) fn window_label(&self, number: u8) -> String {
|
||||
match self.placement(number) {
|
||||
Some(Placement::Fill(name)) if number != 1 => match self.monitor_label(name) {
|
||||
Some(monitor) => format!("Window {number}, {monitor}"),
|
||||
None => format!("Window {number}"),
|
||||
},
|
||||
_ => format!("Window {number}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where player `i` plays, as the screens say it.
|
||||
pub(super) fn label(&self, i: usize) -> String {
|
||||
self.window_label(self.window_of(i))
|
||||
}
|
||||
|
||||
/// Player `i` into a window: one there is, or a new one with the first number free.
|
||||
pub(super) fn choose(&mut self, i: usize, choice: Spot) {
|
||||
let placement = match choice {
|
||||
Spot::Window(n) => return self.set_window(i, n),
|
||||
Spot::New => Placement::Free(None),
|
||||
Spot::Fill(name) => Placement::Fill(name),
|
||||
};
|
||||
// Out of its window first: alone there, its number is free again.
|
||||
self.set_window(i, 1);
|
||||
self.forget_empty_windows();
|
||||
let number = (2..=u8::MAX)
|
||||
.find(|n| self.windows.iter().all(|w| w.number != *n))
|
||||
.unwrap_or(u8::MAX);
|
||||
self.windows.push(layout::Window { number, placement });
|
||||
self.set_window(i, number);
|
||||
}
|
||||
|
||||
/// Windows nobody plays in close (Window 1 stays).
|
||||
pub(super) fn forget_empty_windows(&mut self) {
|
||||
let used: Vec<u8> = (0..self.players()).map(|i| self.window_number(i)).collect();
|
||||
@@ -180,7 +285,21 @@ impl<'a> Split<'a> {
|
||||
})
|
||||
.collect();
|
||||
let sizes: Vec<_> = self.surfaces.iter().map(|s| s.size).collect();
|
||||
layout::place(&windows, &sizes, 0, 0)
|
||||
layout::place_ordered(&windows, &self.player_order(), &sizes, 0, 0)
|
||||
}
|
||||
|
||||
/// The players (indices) in the order they fill a window they share (`order`, by seat;
|
||||
/// players it doesn't name last).
|
||||
pub(super) fn player_order(&self) -> Vec<usize> {
|
||||
let seats: Vec<usize> = std::iter::once(1)
|
||||
.chain(self.guests.iter().map(|g| g.seat))
|
||||
.collect();
|
||||
let named: Vec<usize> = self
|
||||
.order
|
||||
.iter()
|
||||
.filter_map(|seat| seats.iter().position(|s| s == seat))
|
||||
.collect();
|
||||
layout::ordered(&named, seats.len())
|
||||
}
|
||||
|
||||
/// Every player into its view: its window, then its size. Keyboard and mouse follow their
|
||||
@@ -212,6 +331,113 @@ impl<'a> Split<'a> {
|
||||
self.turn(0);
|
||||
}
|
||||
|
||||
/// The places a joining player steps through with its bumpers: each window it could play in,
|
||||
/// at each place among the players there ("Window 1 (1 of 2)": first is left or on top),
|
||||
/// then a new window and each monitor (`choices`).
|
||||
pub(super) fn joining_stops(&self, i: usize) -> Vec<(String, Spot, usize)> {
|
||||
let mut stops = Vec::new();
|
||||
for (label, spot) in self.choices(i) {
|
||||
let others = match spot {
|
||||
Spot::Window(n) => self.sharing(i, n).len(),
|
||||
_ => 0,
|
||||
};
|
||||
if others == 0 {
|
||||
stops.push((label, spot, 0));
|
||||
continue;
|
||||
}
|
||||
for k in 0..=others {
|
||||
let label = format!("{label} ({} of {})", k + 1, others + 1);
|
||||
stops.push((label, spot.clone(), k));
|
||||
}
|
||||
}
|
||||
stops
|
||||
}
|
||||
|
||||
/// The players but `i` in window `number`, in their order.
|
||||
fn sharing(&self, i: usize, number: u8) -> Vec<usize> {
|
||||
self.player_order()
|
||||
.into_iter()
|
||||
.filter(|&j| j != i && self.window_of(j) == number)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn seat_of(&self, i: usize) -> usize {
|
||||
if i == 0 { 1 } else { self.guests[i - 1].seat }
|
||||
}
|
||||
|
||||
/// Player `i` to place `k` among the others in its window (0: before them all).
|
||||
pub(super) fn put_at(&mut self, i: usize, k: usize) {
|
||||
let others: Vec<usize> = self
|
||||
.sharing(i, self.window_of(i))
|
||||
.into_iter()
|
||||
.map(|j| self.seat_of(j))
|
||||
.collect();
|
||||
let me = self.seat_of(i);
|
||||
let mut order: Vec<usize> = self
|
||||
.player_order()
|
||||
.into_iter()
|
||||
.map(|j| self.seat_of(j))
|
||||
.filter(|&s| s != me)
|
||||
.collect();
|
||||
let at = match others.get(k) {
|
||||
Some(seat) => order.iter().position(|s| s == seat).unwrap_or(order.len()),
|
||||
// After the last of them.
|
||||
None => others
|
||||
.last()
|
||||
.and_then(|seat| order.iter().position(|s| s == seat))
|
||||
.map_or(order.len(), |p| p + 1),
|
||||
};
|
||||
order.insert(at, me);
|
||||
self.order = order;
|
||||
}
|
||||
|
||||
/// A bumper on the profile page (joining or switching): player `i`'s view moves to the next
|
||||
/// place it could play (`back`: the one before), at once, so it sees where it will be.
|
||||
pub(super) fn move_with_bumpers(&mut self, i: usize, back: bool) {
|
||||
let stops = self.joining_stops(i);
|
||||
let n = stops.len();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let (spot, before) = (self.current(i), self.sharing_before(i));
|
||||
let at = stops
|
||||
.iter()
|
||||
.position(|(_, s, k)| *s == spot && *k == before)
|
||||
.unwrap_or(0);
|
||||
let (shown, next, k) = stops[if back { (at + n - 1) % n } else { (at + 1) % n }].clone();
|
||||
self.choose(i, next);
|
||||
self.put_at(i, k);
|
||||
if let Some(game) = self.game_mut(i) {
|
||||
game.split_hud.place = Some((
|
||||
format!("LB < {shown} > RB"),
|
||||
Instant::now() + Duration::from_secs(3),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where player `i` plays now, as its bumpers' stops name it ("Window 1 (2 of 2)"); its own
|
||||
/// window by its name ("Window 2").
|
||||
pub(super) fn stop_label(&self, i: usize) -> Option<String> {
|
||||
let (spot, before) = (self.current(i), self.sharing_before(i));
|
||||
if spot != Spot::Window(self.window_of(i)) {
|
||||
return Some(self.label(i));
|
||||
}
|
||||
self.joining_stops(i)
|
||||
.into_iter()
|
||||
.find(|(_, s, k)| *s == spot && *k == before)
|
||||
.map(|(label, ..)| label)
|
||||
}
|
||||
|
||||
/// How many players come before `i` in its window.
|
||||
fn sharing_before(&self, i: usize) -> usize {
|
||||
let mine = self.window_of(i);
|
||||
self.player_order()
|
||||
.into_iter()
|
||||
.take_while(|&j| j != i)
|
||||
.filter(|&j| self.window_of(j) == mine)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// What a window's title says: its name and who plays in it.
|
||||
pub(super) fn title(&self, k: usize) -> String {
|
||||
let views = &self.applied;
|
||||
|
||||
@@ -186,9 +186,7 @@ impl Game<'_> {
|
||||
if stack.damage + 1 >= max || (riptide && !self.in_water_or_rain()) {
|
||||
return true;
|
||||
}
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
self.start_using_item();
|
||||
true
|
||||
}
|
||||
fn in_water_or_rain(&mut self) -> bool {
|
||||
|
||||
@@ -574,6 +574,58 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// An unbreakable damaged stack must keep both components through inventory
|
||||
/// sync, including the integrated server's window (#5721).
|
||||
#[test]
|
||||
fn unbreakable_survives_inventory_sync() {
|
||||
use horizon_protocol::buf::{Codec, Reader, Writer};
|
||||
use horizon_protocol::item::Slot;
|
||||
let mut components = vec![String::new(); component::UNBREAKABLE as usize + 1];
|
||||
components[component::DAMAGE as usize] = "minecraft:damage".into();
|
||||
components[component::UNBREAKABLE as usize] = "minecraft:unbreakable".into();
|
||||
let builtin = Builtin {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:item".into(),
|
||||
Ids::from_names(vec!["minecraft:air".into(), "minecraft:diamond_hoe".into()]),
|
||||
),
|
||||
(
|
||||
"minecraft:data_component_type".into(),
|
||||
Ids::from_names(components),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
for unbreakable in [false, true] {
|
||||
let mut hoe = ItemStack::new("minecraft:diamond_hoe", 1);
|
||||
hoe.damage = 58;
|
||||
let marker = Tag::Compound(Default::default());
|
||||
if unbreakable {
|
||||
hoe.data_mut()
|
||||
.set_other("minecraft:unbreakable", Some(marker.clone()));
|
||||
}
|
||||
let sent = crate::lan::guest::stack_to_network(|_| 1, |_, _| None, &Some(hoe));
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&sent, &mut w);
|
||||
let mut r = Reader::new(&w.data);
|
||||
let received = Slot::read(&mut r).unwrap().unwrap();
|
||||
assert_eq!(r.remaining(), 0);
|
||||
assert_eq!(
|
||||
received.component(component::UNBREAKABLE),
|
||||
unbreakable.then_some(&Component::Unit)
|
||||
);
|
||||
let back = from_network(&received, &builtin, &Dynamic::default());
|
||||
assert_eq!(
|
||||
(&*back.item, back.count, back.damage),
|
||||
("minecraft:diamond_hoe", 1, 58)
|
||||
);
|
||||
assert_eq!(
|
||||
back.data().and_then(|d| d.other("minecraft:unbreakable")),
|
||||
unbreakable.then_some(&marker)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An ominous bottle's `ominous_bottle_amplifier` reaches the client
|
||||
/// through the packet's bytes, so its tooltip names the Bad Omen level.
|
||||
#[test]
|
||||
|
||||
@@ -39,6 +39,13 @@ const KEEP_ALIVE_EVERY: Duration = Duration::from_secs(15);
|
||||
/// ReadTimeoutHandler(30) in ServerConnectionListener: a connection that sends
|
||||
/// nothing for 30 s.
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long the handshake, a status request and the login may wait for the client's next packet,
|
||||
/// where vanilla waits 30 s: a client answers these at once, and each connection holds a thread
|
||||
/// and one of the places the server keeps for connections, which a crowd of silent or slow
|
||||
/// sockets would otherwise take for 30 s each. Configuration and play keep [`READ_TIMEOUT`] (a
|
||||
/// resource pack downloads in configuration).
|
||||
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Packets queued for one client beyond this many bytes disconnect it.
|
||||
const MAX_PENDING: usize = 96 << 20;
|
||||
/// Vanilla's server-side disconnect during login is a JSON component.
|
||||
@@ -457,9 +464,16 @@ pub fn serve(
|
||||
{
|
||||
return serve_legacy(stream, &shared);
|
||||
}
|
||||
// A peer that stops reading (a frozen game, a dropped network) would otherwise hold
|
||||
// its writer in a full send buffer for good, or until TCP gives up minutes later, and
|
||||
// its thread and its place among the server's connections with it.
|
||||
let _ = stream.set_write_timeout(Some(WRITE_TIMEOUT));
|
||||
serve_connection(Connection::from_stream(stream).map_err(e)?, address, shared)
|
||||
}
|
||||
|
||||
/// How long one write may wait on a peer that reads nothing: as long as a read may wait.
|
||||
const WRITE_TIMEOUT: Duration = READ_TIMEOUT;
|
||||
|
||||
/// The address a memory connection logs in from: no network peer has port 0.
|
||||
pub const MEMORY_ADDRESS: std::net::SocketAddr = std::net::SocketAddr::V4(
|
||||
std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 0),
|
||||
@@ -480,9 +494,7 @@ fn serve_connection(
|
||||
shared: Arc<Shared>,
|
||||
) -> Result<(), String> {
|
||||
let e = |e: horizon_protocol::Error| e.to_string();
|
||||
c.reader
|
||||
.set_read_timeout(Some(Duration::from_secs(30)))
|
||||
.map_err(e)?;
|
||||
c.reader.set_read_timeout(Some(LOGIN_TIMEOUT)).map_err(e)?;
|
||||
let (id, payload) = c.recv().map_err(e)?;
|
||||
let intention = match handshake::Serverbound::decode(id, &payload).map_err(e)? {
|
||||
handshake::Serverbound::Intention(i) => i,
|
||||
@@ -881,6 +893,7 @@ fn login_and_configure(
|
||||
_ => return Err("unexpected packet during login".into()),
|
||||
}
|
||||
}
|
||||
c.reader.set_read_timeout(Some(READ_TIMEOUT)).map_err(e)?;
|
||||
// Configuration.
|
||||
let sync_data = shared.sync();
|
||||
let sync: &SyncData = &sync_data;
|
||||
@@ -1187,6 +1200,7 @@ fn wait_alive<T>(
|
||||
}
|
||||
|
||||
fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::SocketAddr) {
|
||||
shared.entered_play();
|
||||
let conn_id = shared.next_id();
|
||||
let events = shared.events.clone();
|
||||
let _ = c.reader.set_read_timeout(Some(READ_TIMEOUT));
|
||||
@@ -1258,9 +1272,11 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
};
|
||||
let _ = tx.send(Out::Close);
|
||||
let _ = events.send(NetEvent::Left(conn_id, reason));
|
||||
shared.closing.fetch_add(1, Ordering::AcqRel);
|
||||
if let Some(t) = writer_thread {
|
||||
let _ = t.join();
|
||||
}
|
||||
shared.closing.fetch_sub(1, Ordering::AcqRel);
|
||||
shared.release(parts.uuid);
|
||||
}
|
||||
|
||||
|
||||
@@ -252,6 +252,7 @@ impl Hub {
|
||||
menu: kind,
|
||||
title,
|
||||
}));
|
||||
let mod_type = crate::containers::mod_menu_type(menu_kind);
|
||||
g.menu = Some(Menu {
|
||||
window,
|
||||
kind,
|
||||
@@ -263,6 +264,8 @@ impl Hub {
|
||||
drag: None,
|
||||
sent_data: Vec::new(),
|
||||
hidden: Vec::new(),
|
||||
results: mod_type.map_or_else(Vec::new, |t| t.results.clone()),
|
||||
data_slots: mod_type.map(|t| t.data_slots),
|
||||
});
|
||||
self.send_menu_content(i, items);
|
||||
}
|
||||
@@ -321,6 +324,8 @@ impl Hub {
|
||||
drag: None,
|
||||
sent_data: Vec::new(),
|
||||
hidden: Vec::new(),
|
||||
results: Vec::new(),
|
||||
data_slots: None,
|
||||
});
|
||||
self.send_menu_content(i, items);
|
||||
Some(window)
|
||||
@@ -557,6 +562,11 @@ impl Hub {
|
||||
.as_ref()
|
||||
.filter(|_| in_menu)
|
||||
.map_or_else(Vec::new, |m| m.hidden.clone());
|
||||
let results = self.guests[i]
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|_| in_menu)
|
||||
.map_or_else(Vec::new, |m| m.results.clone());
|
||||
let guest = &mut self.guests[i];
|
||||
let mut items = source.and_then(|s| s.read(world, guest));
|
||||
if in_menu && items.is_none() {
|
||||
@@ -567,6 +577,7 @@ impl Hub {
|
||||
container: items.as_deref_mut(),
|
||||
shulker,
|
||||
hidden: &hidden,
|
||||
results: &results,
|
||||
};
|
||||
let (drops, changed) = menu::click(
|
||||
&mut view,
|
||||
@@ -791,23 +802,31 @@ impl Hub {
|
||||
.chain([&c.triggered])
|
||||
.map(|&d| i32::from(d))
|
||||
.collect(),
|
||||
// A mod's menu: the data slots its tick set.
|
||||
Some(BlockEntity::Mod(m)) if !m.data.is_empty() => m.data.clone(),
|
||||
// A mod's menu: the data slots its tick set. A mod menu type's
|
||||
// `ContainerData` has as many as it declares, all sent on open
|
||||
// (`sendInitialData`), 0 where the tick set none.
|
||||
Some(BlockEntity::Mod(e)) if m.data_slots.is_some() || !e.data.is_empty() => {
|
||||
let mut data = e.data.clone();
|
||||
if let Some(n) = m.data_slots {
|
||||
data.resize(n, 0);
|
||||
}
|
||||
data
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
.into_iter()
|
||||
.map(|v| v as i16)
|
||||
.collect::<Vec<i16>>();
|
||||
m.sent_data.resize(data.len(), i16::MIN);
|
||||
m.sent_data.resize(data.len(), None);
|
||||
let window = m.window;
|
||||
let changed: Vec<(usize, i16)> = data
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(k, v)| m.sent_data[k] != *v)
|
||||
.filter(|&(k, v)| m.sent_data[k] != Some(*v))
|
||||
.map(|(k, &v)| (k, v))
|
||||
.collect();
|
||||
for &(k, value) in &changed {
|
||||
m.sent_data[k] = value;
|
||||
m.sent_data[k] = Some(value);
|
||||
}
|
||||
for (k, value) in changed {
|
||||
g.send(&C::ContainerSetData(play::ContainerSetData {
|
||||
|
||||
@@ -173,6 +173,10 @@ pub struct Guest {
|
||||
pub moved: bool,
|
||||
/// The client finished loading terrain (ServerboundPlayerLoadedPacket).
|
||||
pub loaded: bool,
|
||||
/// Since it joined or respawned, the client hasn't said it loaded the level
|
||||
/// (ServerboundPlayerLoadedPacket): it may still be on its loading screen, where it doesn't
|
||||
/// move, even once the server stops waiting for it (`load_timeout`).
|
||||
pub loading: bool,
|
||||
/// Ticks left before the server stops waiting for that packet
|
||||
/// (ServerGamePacketListenerImpl.clientLoadedTimeoutTimer): 60 at join and
|
||||
/// after a respawn, 0 once it comes.
|
||||
@@ -274,6 +278,9 @@ pub struct Guest {
|
||||
pub death_time: u32,
|
||||
/// The join packets were sent (PlayerList.placeNewPlayer ran).
|
||||
pub placed: bool,
|
||||
/// `Abilities.flyingSpeed`: vanilla's 0.05, unless a server mod set
|
||||
/// another (`native_set_flying_speed`). Saved with the player.
|
||||
pub flying_speed: f32,
|
||||
/// The level the player is in (`ServerPlayer.level().dimension()`): the
|
||||
/// hub's level passes only reach the players in theirs.
|
||||
pub dimension: String,
|
||||
@@ -370,6 +377,7 @@ impl Guest {
|
||||
changes: Default::default(),
|
||||
moved: false,
|
||||
loaded: false,
|
||||
loading: true,
|
||||
load_timeout: CLIENT_LOADED_TIMEOUT,
|
||||
center: None,
|
||||
sent_chunks: Default::default(),
|
||||
@@ -422,6 +430,7 @@ impl Guest {
|
||||
dead: false,
|
||||
death_time: 0,
|
||||
placed: false,
|
||||
flying_speed: 0.05,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
menu: None,
|
||||
next_window: 0,
|
||||
@@ -439,6 +448,14 @@ impl Guest {
|
||||
recipes_unlocked: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.onUpdateAbilities`: the guest's abilities, flying speed included.
|
||||
pub fn abilities(&self) -> play::Clientbound {
|
||||
play::Clientbound::PlayerAbilities(play::PlayerAbilities {
|
||||
flags: self.ability_flags(),
|
||||
flying_speed: self.flying_speed,
|
||||
walking_speed: 0.1,
|
||||
})
|
||||
}
|
||||
pub fn send(&self, packet: &play::Clientbound) {
|
||||
if self.anticheat.record.is_some() {
|
||||
self.note_change(packet);
|
||||
@@ -472,6 +489,13 @@ impl Guest {
|
||||
.map(|a| a.modifiers.iter().any(|m| m.id == "minecraft:sprinting"));
|
||||
Change::Attributes { sprint }
|
||||
}
|
||||
// Entity.DATA_SHARED_FLAGS_ID: its sprinting bit (0x08).
|
||||
C::SetEntityData(p) if own(p.id) => match p.values.iter().find(|v| v.0 == 0) {
|
||||
Some(&(_, _, horizon_protocol::entity_data::Value::Byte(flags))) => {
|
||||
Change::Sprinting(flags & 0x08 != 0)
|
||||
}
|
||||
_ => return,
|
||||
},
|
||||
C::UpdateMobEffect(p) if own(p.id) => Change::Effects,
|
||||
C::RemoveMobEffect(p) if own(p.id) => Change::Effects,
|
||||
C::PlayerAbilities(_) => Change::Abilities,
|
||||
@@ -736,6 +760,14 @@ fn network_stack(
|
||||
));
|
||||
}
|
||||
if let Some(d) = s.data() {
|
||||
// Unbreakable is a unit component; clients need its presence for the
|
||||
// durability bar, tooltip and item model, including in single player.
|
||||
if d.other("minecraft:unbreakable").is_some() {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::UNBREAKABLE,
|
||||
horizon_protocol::item::Component::Unit,
|
||||
));
|
||||
}
|
||||
for (name, value) in &d.other {
|
||||
let (removed, kind) = match name.strip_prefix('!') {
|
||||
Some(kind) => (true, kind),
|
||||
|
||||
@@ -933,11 +933,7 @@ impl Hub {
|
||||
difficulty: ctx.difficulty,
|
||||
locked: ctx.difficulty_locked,
|
||||
}));
|
||||
g.send(&C::PlayerAbilities(play::PlayerAbilities {
|
||||
flags: g.ability_flags(),
|
||||
flying_speed: 0.05,
|
||||
walking_speed: 0.1,
|
||||
}));
|
||||
g.send(&g.abilities());
|
||||
g.send(&C::SetHeldSlot(play::SetHeldSlot {
|
||||
slot: g.selected as i32,
|
||||
}));
|
||||
@@ -2410,9 +2406,16 @@ impl Hub {
|
||||
// track_entities). On the first tick vanilla's ran before
|
||||
// ServerPlayer.tick, whose updatePlayerAttributes (and doTick's
|
||||
// removeFrost) created the instances the guest's tick just made: they
|
||||
// go out on the next tick, after the first chunk batch (#5560).
|
||||
// go out on the next tick, after the first chunk batch (#5560). This
|
||||
// server's first batch waits for the player's chunks to be ready, so
|
||||
// they wait for it too: a slow load must not put them before it.
|
||||
let g = &mut self.guests[i];
|
||||
if let Some(m) = g.motion.as_mut().filter(|_| !first_tick) {
|
||||
let after_first_batch = g.unacknowledged > 0 || g.max_unacknowledged > 1;
|
||||
if let Some(m) = g
|
||||
.motion
|
||||
.as_mut()
|
||||
.filter(|_| !first_tick && after_first_batch)
|
||||
{
|
||||
let values = super::attributes::take_dirty(&mut m.attributes);
|
||||
if !values.is_empty() {
|
||||
g.send(&C::UpdateAttributes(play::UpdateAttributes {
|
||||
@@ -2756,6 +2759,7 @@ impl Hub {
|
||||
S::PlayerLoaded(_) => {
|
||||
let g = &mut self.guests[i];
|
||||
g.loaded = true;
|
||||
g.loading = false;
|
||||
g.load_timeout = 0;
|
||||
}
|
||||
// PlayerChunkSender.onChunkBatchReceivedByClient.
|
||||
@@ -3256,11 +3260,7 @@ impl Hub {
|
||||
event: game_event::CHANGE_GAME_MODE,
|
||||
param: mode as f32,
|
||||
}));
|
||||
g.send(&C::PlayerAbilities(play::PlayerAbilities {
|
||||
flags: g.ability_flags(),
|
||||
flying_speed: 0.05,
|
||||
walking_speed: 0.1,
|
||||
}));
|
||||
g.send(&g.abilities());
|
||||
self.broadcast(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
|
||||
actions: 4,
|
||||
entries: vec![play::PlayerInfo {
|
||||
@@ -3289,6 +3289,21 @@ impl Hub {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set a guest's flying speed and tell it (`Abilities.setFlyingSpeed`, then
|
||||
/// `onUpdateAbilities`). The abilities packets after carry it too, so a
|
||||
/// respawn or a game mode change keeps it.
|
||||
pub fn set_flying_speed(&mut self, conn: u32, speed: f32) {
|
||||
let Some(g) = self.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
g.flying_speed = speed;
|
||||
// The server's copy of the body, which the movement replay reads.
|
||||
if let Some(motion) = g.motion.as_mut() {
|
||||
motion.flying_speed = speed;
|
||||
}
|
||||
g.send(&g.abilities());
|
||||
}
|
||||
|
||||
/// The host's game mode changed (PlayerInfo UPDATE_GAME_MODE for everyone).
|
||||
pub fn set_host_game_mode(&self, uuid: u128, mode: i32) {
|
||||
self.broadcast(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
|
||||
@@ -3366,6 +3381,7 @@ impl Hub {
|
||||
// restartClientLoadTimerAfterRespawn: the client loads a whole new level before it
|
||||
// reports it loaded.
|
||||
g.load_timeout = super::guest::CLIENT_LOADED_TIMEOUT;
|
||||
g.loading = true;
|
||||
// ServerLevel.removePlayerImmediately: the dead player's
|
||||
// Entity.setRemoved (KILLED) stops its riding; the new player rides
|
||||
// nothing.
|
||||
@@ -3385,11 +3401,7 @@ impl Hub {
|
||||
self.respawned(id);
|
||||
let g = &mut self.guests[i];
|
||||
// ServerPlayer.restoreFrom: the game mode again (onUpdateAbilities).
|
||||
g.send(&C::PlayerAbilities(play::PlayerAbilities {
|
||||
flags: g.ability_flags(),
|
||||
flying_speed: 0.05,
|
||||
walking_speed: 0.1,
|
||||
}));
|
||||
g.send(&g.abilities());
|
||||
// The new player faces the level's respawn direction (snapTo).
|
||||
g.yaw = 0.0;
|
||||
g.pitch = 0.0;
|
||||
@@ -3525,11 +3537,7 @@ impl Hub {
|
||||
self.resend_teleport(i, None);
|
||||
}
|
||||
let g = &mut self.guests[i];
|
||||
g.send(&C::PlayerAbilities(play::PlayerAbilities {
|
||||
flags: g.ability_flags(),
|
||||
flying_speed: 0.05,
|
||||
walking_speed: 0.1,
|
||||
}));
|
||||
g.send(&g.abilities());
|
||||
self.send_level_info(i, ctx, !portal);
|
||||
// PlayerList.sendAllPlayerInfo: the inventory and the held slot.
|
||||
self.send_inventory(i);
|
||||
|
||||
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user