mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'upstream/main' into feat/native-shader-params
# Conflicts: # docs/native-mods.md
This commit is contained in:
300 files changed
+21721
-2258
No files matched your search
Generated
+10
@@ -1334,6 +1334,9 @@ dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
@@ -1506,6 +1509,13 @@ dependencies = [
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-port-pack"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -30,6 +30,7 @@ members = [
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"xtask",
|
||||
|
||||
@@ -8,12 +8,16 @@
|
||||
//! check, off. A check that fails *flags* the player: its violation level (VL) rises by the flag's
|
||||
//! weight and falls back by `decay` a second. From `setback` on, the move or action is cancelled (the
|
||||
//! player is sent back where the server last had them); from `alert` on, online operators and the
|
||||
//! console are told; from `kick` on, the player is disconnected. Nothing bans. Any threshold at 0 turns
|
||||
//! that action off.
|
||||
//! console are told; from `kick` on, the player is disconnected. A check's kicks are counted per player
|
||||
//! across sessions ([`Strikes`]), and the kick that reaches its `ban_after` bans the player instead
|
||||
//! (`banned-players.json`, reason "Horizon anticheat: <check>"). Any threshold at 0 turns that action
|
||||
//! off.
|
||||
//!
|
||||
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
|
||||
//! do, never from another anticheat's code.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod combat;
|
||||
@@ -28,12 +32,14 @@ pub struct Actions {
|
||||
pub kick: f32,
|
||||
/// Violation level lost each second.
|
||||
pub decay: f32,
|
||||
/// The kick, counting this check's earlier ones for the player, that bans instead; 0: never.
|
||||
pub ban_after: u32,
|
||||
}
|
||||
|
||||
macro_rules! checks {
|
||||
($(
|
||||
$(#[$doc:meta])*
|
||||
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr] {
|
||||
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr, $ban_after:expr] {
|
||||
$( $(#[$xdoc:meta])* $extra:ident: $ty:ty = $default:expr, )*
|
||||
}
|
||||
)*) => {
|
||||
@@ -59,6 +65,7 @@ macro_rules! checks {
|
||||
pub alert: f32,
|
||||
pub kick: f32,
|
||||
pub decay: f32,
|
||||
pub ban_after: u32,
|
||||
$( $(#[$xdoc])* pub $extra: $ty, )*
|
||||
}
|
||||
|
||||
@@ -70,6 +77,7 @@ macro_rules! checks {
|
||||
alert: $alert,
|
||||
kick: $kick,
|
||||
decay: $decay,
|
||||
ban_after: $ban_after,
|
||||
$($extra: $default,)*
|
||||
}
|
||||
}
|
||||
@@ -87,6 +95,9 @@ macro_rules! checks {
|
||||
pub alert_level: u8,
|
||||
/// Every flag, one line each, relative to the server's folder; empty: no file.
|
||||
pub log: String,
|
||||
/// The players' kicks by check ([`Strikes`]), relative to the server's folder; empty: kept
|
||||
/// only while the server runs.
|
||||
pub strikes: String,
|
||||
$(pub $field: $Config,)*
|
||||
}
|
||||
|
||||
@@ -97,6 +108,7 @@ macro_rules! checks {
|
||||
alerts: true,
|
||||
alert_level: 2,
|
||||
log: "logs/anticheat.log".into(),
|
||||
strikes: "anticheat-strikes.json".into(),
|
||||
$($field: $Config::default(),)*
|
||||
}
|
||||
}
|
||||
@@ -112,7 +124,13 @@ macro_rules! checks {
|
||||
match check {
|
||||
$(Check::$Check => {
|
||||
let c = &self.$field;
|
||||
Actions { setback: c.setback, alert: c.alert, kick: c.kick, decay: c.decay }
|
||||
Actions {
|
||||
setback: c.setback,
|
||||
alert: c.alert,
|
||||
kick: c.kick,
|
||||
decay: c.decay,
|
||||
ban_after: c.ban_after,
|
||||
}
|
||||
})*
|
||||
}
|
||||
}
|
||||
@@ -125,7 +143,7 @@ checks! {
|
||||
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
|
||||
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
|
||||
/// It logs only by default; once trusted: `setback = 2`, `alert = 5`, `kick = 20`.
|
||||
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02] {
|
||||
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02, 0] {
|
||||
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
|
||||
tolerance_ms: f64 = 300.0,
|
||||
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
|
||||
@@ -135,9 +153,12 @@ checks! {
|
||||
|
||||
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
|
||||
/// the ground to skip fall damage) and walking on water. The claim is dropped, so vanilla's
|
||||
/// fall damage follows; from `setback` on the player is also sent back. Logging only (`setback = 0`,
|
||||
/// the default), the claim stands. Once trusted: `setback = 5`, `alert = 15`, `kick = 60`.
|
||||
ground: Ground => GroundConfig [0.0, 0.0, 0.0, 2.0] {}
|
||||
/// fall damage follows (the player is never sent back: `setback = 0` makes the check log only), and
|
||||
/// a player that keeps claiming is kicked, then banned at the second kick (`ban_after = 2`). A claim standing still isn't
|
||||
/// judged (no fall to skip). On the official server's first two hours (67 players), every player who
|
||||
/// wasn't flying or walking on water peaked at level 7 (4 claims standing still in one second); the
|
||||
/// ones who were, at 113 to 3749.
|
||||
ground: Ground => GroundConfig [10.0, 15.0, 30.0, 2.0, 2] {}
|
||||
|
||||
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
|
||||
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
|
||||
@@ -149,7 +170,7 @@ checks! {
|
||||
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
|
||||
/// until the moves agree again. It logs only by default; once trusted: `setback = 5`, `alert = 15`,
|
||||
/// `kick = 60`.
|
||||
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0] {
|
||||
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0, 0] {
|
||||
/// How far, in blocks, a move may land from where the replay puts it.
|
||||
tolerance: f64 = 0.03,
|
||||
}
|
||||
@@ -157,8 +178,9 @@ checks! {
|
||||
/// Combat: attacks vanilla's client can't send (`combat`): one with no swing after it (a kill aura
|
||||
/// that hides its swing), or a second entity attacked in the same client tick (vanilla attacks the
|
||||
/// one entity under the crosshair, picked once a tick). Each flags once; from `setback` on the
|
||||
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default.
|
||||
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5] {}
|
||||
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default: the first
|
||||
/// kick warns, the second bans (`ban_after = 2`).
|
||||
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5, 2] {}
|
||||
}
|
||||
|
||||
/// How many checks there are.
|
||||
@@ -207,6 +229,33 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many times each check kicked each player, kept across sessions (`strikes`, a JSON object of
|
||||
/// players' UUIDs to check names to counts): the kick that reaches a check's `ban_after` is a ban.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Strikes(pub BTreeMap<String, BTreeMap<String, u32>>);
|
||||
|
||||
impl Strikes {
|
||||
/// Player `uuid` was kicked by `check`: its kicks by it so far, this one included, and whether this
|
||||
/// one bans. A ban starts the count again, so a player an operator pardons is kicked before the
|
||||
/// next ban.
|
||||
pub fn kick(&mut self, uuid: &str, check: Check, ban_after: u32) -> (u32, bool) {
|
||||
let count = self
|
||||
.0
|
||||
.entry(uuid.to_owned())
|
||||
.or_default()
|
||||
.entry(check.name().to_owned())
|
||||
.or_default();
|
||||
*count = count.saturating_add(1);
|
||||
let kicks = *count;
|
||||
let ban = ban_after > 0 && kicks >= ban_after;
|
||||
if ban {
|
||||
*count = 0;
|
||||
}
|
||||
(kicks, ban)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a flag leads to.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Verdict {
|
||||
@@ -277,14 +326,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_by_default_but_only_combat_acts() {
|
||||
fn on_by_default_but_only_combat_and_ground_act() {
|
||||
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
for &check in Check::ALL {
|
||||
assert!(c.runs(check), "{}", check.name());
|
||||
let a = c.actions(check);
|
||||
let acts = [a.setback, a.alert, a.kick] != [0.0; 3];
|
||||
assert_eq!(acts, check == Check::Combat, "{}", check.name());
|
||||
assert_eq!(
|
||||
acts,
|
||||
matches!(check, Check::Combat | Check::Ground),
|
||||
"{}",
|
||||
check.name()
|
||||
);
|
||||
}
|
||||
let off = Config::from_server_toml("[anticheat]\nenabled = false\n").unwrap();
|
||||
assert!(Check::ALL.iter().all(|&check| !off.runs(check)));
|
||||
@@ -336,6 +390,25 @@ mod tests {
|
||||
assert!(v.kick && v.alert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kick_that_reaches_ban_after_bans() {
|
||||
let c = Config::default();
|
||||
assert_eq!(c.actions(Check::Combat).ban_after, 2);
|
||||
assert_eq!(c.actions(Check::Prediction).ban_after, 0);
|
||||
let mut s = Strikes::default();
|
||||
assert_eq!(s.kick("a", Check::Combat, 2), (1, false));
|
||||
// Another player, another check: their own counts.
|
||||
assert_eq!(s.kick("b", Check::Combat, 2), (1, false));
|
||||
assert_eq!(s.kick("a", Check::Timer, 2), (1, false));
|
||||
assert_eq!(s.kick("a", Check::Combat, 2), (2, true));
|
||||
// Pardoned, the player is kicked once more before the next ban.
|
||||
assert_eq!(s.kick("a", Check::Combat, 2), (1, false));
|
||||
// 0 never bans; 1 bans at the first kick.
|
||||
assert_eq!(s.kick("c", Check::Timer, 0), (1, false));
|
||||
assert_eq!(s.kick("c", Check::Timer, 0), (2, false));
|
||||
assert_eq!(s.kick("d", Check::Ground, 1), (1, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_turns_an_action_off() {
|
||||
let mut c = on();
|
||||
|
||||
@@ -81,6 +81,19 @@ impl InteractionInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sentence of `Minecraft.handleKeybinds` that follows a key which opened
|
||||
/// a screen: `Gui.setScreen` has already run `KeyMapping.releaseAll()`, so
|
||||
/// `key.use` reads up and the same call releases an active use
|
||||
/// (`player.isUsingItem() && !keyUse.isDown()` → `MultiPlayerGameMode.releaseUsingItem`).
|
||||
/// Only the screens opened from the keybinds do this (the inventory, chat and
|
||||
/// advancements); the pause menu, server containers, mount inventories and
|
||||
/// dialogs release on the first tick after closing instead, as vanilla does.
|
||||
pub fn release_on_screen_open(world: &mut impl InteractionWorld) {
|
||||
if world.using_item() {
|
||||
world.release_using();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -885,25 +885,26 @@ impl BlockEntityRenderer {
|
||||
let mut renderer = Self::new(models, registry);
|
||||
renderer.visibility = Some(Mutex::new(visibility));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
|
||||
let items: Value = serde_json::from_slice(&bytes)?;
|
||||
for (item, components) in items.as_object().into_iter().flatten() {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
|
||||
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
|
||||
let Ok(def) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let def: Value = serde_json::from_slice(&def)?;
|
||||
if let Some(asset) = def["asset_id"].as_str() {
|
||||
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
|
||||
renderer
|
||||
.pot_patterns
|
||||
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
|
||||
}
|
||||
let items = match horizon_world::item_defaults::read(root) {
|
||||
Ok(items) => items,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
for (item, components) in &items {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
|
||||
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
|
||||
let Ok(def) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let def: Value = serde_json::from_slice(&def)?;
|
||||
if let Some(asset) = def["asset_id"].as_str() {
|
||||
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
|
||||
renderer
|
||||
.pot_patterns
|
||||
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
|
||||
}
|
||||
}
|
||||
Ok(renderer)
|
||||
|
||||
@@ -291,11 +291,19 @@ pub struct Beacon {
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Jukebox {
|
||||
pub record: Option<ItemStack>,
|
||||
/// `JukeboxBlockEntity`'s one slot (`ContainerSingleItem`), so a hopper or
|
||||
/// a dropper reaches it as it reaches any container.
|
||||
pub items: [Option<ItemStack>; 1],
|
||||
/// `ticks_since_song_started`, while a song plays.
|
||||
pub ticks: i64,
|
||||
pub playing: bool,
|
||||
}
|
||||
impl Jukebox {
|
||||
/// `JukeboxBlockEntity.getTheItem`: the disc it holds.
|
||||
pub fn record(&self) -> Option<&ItemStack> {
|
||||
self.items[0].as_ref()
|
||||
}
|
||||
}
|
||||
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BrewingStand {
|
||||
|
||||
@@ -421,7 +421,7 @@ impl BlockEntity {
|
||||
Self::Furnace(f) => f.items.iter().flatten().cloned().collect(),
|
||||
Self::Brewing(b) => b.items.iter().flatten().cloned().collect(),
|
||||
Self::Sign(_) => Vec::new(),
|
||||
Self::Jukebox(j) => j.record.iter().cloned().collect(),
|
||||
Self::Jukebox(j) => j.items.iter().flatten().cloned().collect(),
|
||||
Self::Beacon(_)
|
||||
| Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
@@ -454,7 +454,6 @@ impl BlockEntity {
|
||||
Self::Furnace(f) => &f.items,
|
||||
Self::Brewing(b) => &b.items,
|
||||
Self::Sign(_)
|
||||
| Self::Jukebox(_)
|
||||
| Self::Beacon(_)
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
@@ -468,6 +467,7 @@ impl BlockEntity {
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Jukebox(j) => &j.items,
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::ChiseledBookshelf(b) => &b.items,
|
||||
Self::Shelf(b) => &b.items,
|
||||
@@ -488,7 +488,6 @@ impl BlockEntity {
|
||||
Self::Furnace(f) => &mut f.items,
|
||||
Self::Brewing(b) => &mut b.items,
|
||||
Self::Sign(_)
|
||||
| Self::Jukebox(_)
|
||||
| Self::Beacon(_)
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
@@ -502,6 +501,7 @@ impl BlockEntity {
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Jukebox(j) => &mut j.items,
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::ChiseledBookshelf(b) => &mut b.items,
|
||||
Self::Shelf(b) => &mut b.items,
|
||||
@@ -645,6 +645,26 @@ mod tests {
|
||||
assert_eq!(fifth.count, 1);
|
||||
}
|
||||
|
||||
/// `JukeboxBlockEntity`'s single slot is a `Container` slot, so a hopper
|
||||
/// (`HopperBlockEntity.getContainerAt`) can reach the disc in it.
|
||||
#[test]
|
||||
fn a_jukebox_offers_its_record_as_its_one_container_slot() {
|
||||
let mut entity = BlockEntity::Jukebox(Jukebox::default());
|
||||
assert_eq!(entity.slots().len(), 1);
|
||||
assert!(entity.slots()[0].is_none(), "empty until a disc lands");
|
||||
let disc = ItemStack::new("minecraft:cat", 1);
|
||||
entity.slots_mut()[0] = Some(disc.clone());
|
||||
let slots = entity.slots();
|
||||
assert_eq!(slots[0].as_ref().map(|s| &*s.item), Some("minecraft:cat"));
|
||||
match &entity {
|
||||
BlockEntity::Jukebox(j) => {
|
||||
assert_eq!(j.record().map(|s| &*s.item), Some("minecraft:cat"));
|
||||
}
|
||||
_ => panic!("a jukebox"),
|
||||
}
|
||||
assert_eq!(entity.contents().len(), 1);
|
||||
}
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
|
||||
@@ -331,6 +331,11 @@ pub struct Frame<'a> {
|
||||
/// (`crate::camera::spinning_effect_matrix`), not the hand's.
|
||||
pub spin: Mat4,
|
||||
pub sky: Sky,
|
||||
/// Block edits and chunk membership that can change the shader pack's shadow map.
|
||||
pub shadow_scene_revision: (u64, u64),
|
||||
/// Opt-in reuse of the shader pack's last shadow map when the key still matches
|
||||
/// (`horizon-graphics.txt` `shadowCache`; off by default).
|
||||
pub shadow_cache: bool,
|
||||
/// Render distance in blocks (fog end).
|
||||
pub distance: f32,
|
||||
/// Full-detail terrain distance in blocks; distant LODs bypass the cave graph.
|
||||
@@ -475,6 +480,9 @@ pub struct ScreenshotRequest {
|
||||
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
|
||||
/// and average it down to this size. `None` saves the frame as drawn.
|
||||
pub size: Option<[u32; 2]>,
|
||||
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
|
||||
/// `Renderer::screenshot_quality`, not the window's.
|
||||
pub scaled: bool,
|
||||
}
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
@@ -489,6 +497,9 @@ pub struct Renderer {
|
||||
offscreen: Option<wgpu::Texture>,
|
||||
/// Offscreen submissions still on the GPU: at most two frames run ahead.
|
||||
in_flight: std::collections::VecDeque<wgpu::SubmissionIndex>,
|
||||
/// Splitscreen: the caller waits for the GPU once every player has submitted
|
||||
/// (`wait_in_flight`), so one player's work overlaps the GPU drawing another's.
|
||||
pub paced_elsewhere: bool,
|
||||
/// Which steps of the first frame on this device still go to the log
|
||||
/// (see `startup_step`).
|
||||
first_frame: FirstFrame,
|
||||
@@ -511,6 +522,9 @@ pub struct Renderer {
|
||||
window: Arc<Window>,
|
||||
pub device: wgpu::Device,
|
||||
pub queue: wgpu::Queue,
|
||||
/// Set by the device's lost callback. wgpu reports nothing else when an
|
||||
/// operation fails on a lost device (no error scope sees it).
|
||||
device_lost: Arc<std::sync::atomic::AtomicBool>,
|
||||
pub config: wgpu::SurfaceConfiguration,
|
||||
solid: wgpu::RenderPipeline,
|
||||
translucent: wgpu::RenderPipeline,
|
||||
@@ -593,9 +607,11 @@ pub struct Renderer {
|
||||
group: wgpu::BindGroup,
|
||||
globals: wgpu::Buffer,
|
||||
lightmap: wgpu::Texture,
|
||||
/// The block sprites, [`SPRITE_SPLIT`] layers per array (the first is
|
||||
/// The block sprites, `sprite_split` layers per array (the first is
|
||||
/// `sprites` in terrain.wgsl, the others `sprites_1`...).
|
||||
sprite_textures: Vec<wgpu::Texture>,
|
||||
/// Block sprite layers per array on this device ([`layer_split`]).
|
||||
sprite_split: u32,
|
||||
/// A 1-layer array bound where a pack needs fewer arrays.
|
||||
sprite_unused: wgpu::TextureView,
|
||||
mip_levels: u32,
|
||||
@@ -728,6 +744,10 @@ pub struct Renderer {
|
||||
/// first gets the swapchain a fresh configuration; later ones reattach
|
||||
/// the window, as a lost surface does.
|
||||
acquire_validation_failures: u32,
|
||||
/// A surface from `recreate_surface` whose configuration failed. wgpu
|
||||
/// panics when such a surface is acquired from (it never had one that
|
||||
/// worked), so frames configure it again first and skip until it takes.
|
||||
surface_unconfigured: bool,
|
||||
pub drawn_sections: usize,
|
||||
/// Last frame's section draws (face ranges and translucent sections),
|
||||
/// and the draw calls the backend issued for them.
|
||||
@@ -739,6 +759,12 @@ pub struct Renderer {
|
||||
pub screenshot: Option<ScreenshotRequest>,
|
||||
/// Outcome of the last screenshot: frame size, or the error.
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
|
||||
/// times the game's resolution (1, 2, 4 or 8).
|
||||
pub screenshot_quality: u32,
|
||||
/// How many times the pending scaled screenshot ran out of video memory: each
|
||||
/// halves its size, down to the window's.
|
||||
screenshot_halvings: u32,
|
||||
/// Brightness option (lightmap gamma), 0..1.
|
||||
pub gamma: f32,
|
||||
/// Optional full-light lightmap (Horizon Extras).
|
||||
@@ -753,18 +779,45 @@ pub struct Renderer {
|
||||
}
|
||||
/// Every layer's mip levels, layer major (`first` is the first layer's
|
||||
/// index into `settings`), made by `MipmapGenerator`.
|
||||
/// Block sprite layers per texture array: the smallest
|
||||
/// `max_texture_array_layers` of the GPUs Horizon runs on. Mods add sprites
|
||||
/// past it into more arrays (terrain.wgsl's `sprite`).
|
||||
/// Block sprite layers per texture array at most: the
|
||||
/// `max_texture_array_layers` of most GPUs. Mods add sprites past it into more
|
||||
/// arrays (terrain.wgsl's `sprite`); a GPU with fewer layers per array gets a
|
||||
/// smaller split ([`layer_split`]).
|
||||
pub(crate) const SPRITE_SPLIT: u32 = 2048;
|
||||
/// [`SPRITE_SPLIT`], or a smaller split from `HORIZON_SPRITE_SPLIT` (dev
|
||||
/// only: vanilla's sprites drawn from several arrays must look the same).
|
||||
fn sprite_split() -> u32 {
|
||||
/// A smaller split from `HORIZON_SPRITE_SPLIT` (dev only: vanilla's sprites
|
||||
/// drawn from several arrays must look the same).
|
||||
fn requested_split() -> Option<u32> {
|
||||
std::env::var("HORIZON_SPRITE_SPLIT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
|
||||
.unwrap_or(SPRITE_SPLIT)
|
||||
}
|
||||
/// The split the CPU mip chains are cut by before the adapter is known.
|
||||
fn sprite_split() -> u32 {
|
||||
requested_split().unwrap_or(SPRITE_SPLIT)
|
||||
}
|
||||
/// The block sprite layers per texture array on an adapter that holds
|
||||
/// `max_layers` layers per array: [`SPRITE_SPLIT`], `requested`
|
||||
/// (`HORIZON_SPRITE_SPLIT`) or `max_layers`, whichever is smallest, with a full
|
||||
/// array still fitting once [`array_layers`] pads it for OpenGL. Errors when
|
||||
/// `layers` sprites need more than the [`SPRITE_ARRAYS`] arrays terrain.wgsl binds.
|
||||
fn layer_split(
|
||||
max_layers: u32,
|
||||
gl: bool,
|
||||
requested: Option<u32>,
|
||||
layers: u32,
|
||||
) -> std::result::Result<u32, String> {
|
||||
let mut split = requested.unwrap_or(SPRITE_SPLIT).min(max_layers).max(1);
|
||||
if split > 1 && array_layers(split, gl) > max_layers {
|
||||
split -= 1;
|
||||
}
|
||||
if layers > split * SPRITE_ARRAYS {
|
||||
return Err(format!(
|
||||
"GPU supports {max_layers} texture layers per array; {layers} block textures need \
|
||||
more than the {SPRITE_ARRAYS} arrays of {split} Horizon draws from"
|
||||
));
|
||||
}
|
||||
Ok(split)
|
||||
}
|
||||
/// Sprite arrays terrain.wgsl binds (`sprites`, `sprites_1`...).
|
||||
const SPRITE_ARRAYS: u32 = 8;
|
||||
@@ -867,6 +920,8 @@ fn terrain_group(
|
||||
/// construction so the temporary mip chains are released after GPU upload.
|
||||
pub struct PreparedTextures {
|
||||
sprites: Vec<(Vec<u8>, u32)>,
|
||||
/// The sprite layers per array `sprites` is cut by.
|
||||
split: u32,
|
||||
forcefield: (Vec<u8>, u32),
|
||||
}
|
||||
impl PreparedTextures {
|
||||
@@ -898,9 +953,33 @@ impl PreparedTextures {
|
||||
mip_chain(&pixels[start..], size, settings, first, count)
|
||||
})
|
||||
.collect(),
|
||||
split,
|
||||
forcefield: mip_chain(forcefield.0, forcefield.1, &[], 0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if self.split == split || layers <= split.min(self.split) {
|
||||
self.split = split;
|
||||
return self;
|
||||
}
|
||||
let levels = self.sprites[0].1;
|
||||
let all: Vec<u8> = self
|
||||
.sprites
|
||||
.drain(..)
|
||||
.flat_map(|(bytes, _)| bytes)
|
||||
.collect();
|
||||
// Every layer's mip levels are contiguous (layer major).
|
||||
let layer_bytes = all.len() / layers as usize;
|
||||
self.sprites = all
|
||||
.chunks(layer_bytes * split as usize)
|
||||
.map(|chunk| (chunk.to_vec(), levels))
|
||||
.collect();
|
||||
self.split = split;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn mip_chain(
|
||||
@@ -1039,6 +1118,7 @@ struct Opened {
|
||||
adapter: wgpu::Adapter,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
device_lost: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
/// Logs the adapter and records it for telemetry and crash reports.
|
||||
fn describe_adapter(adapter: &wgpu::Adapter) {
|
||||
@@ -1068,16 +1148,13 @@ fn describe_adapter(adapter: &wgpu::Adapter) {
|
||||
}
|
||||
/// Whether an adapter's texture arrays hold the block textures Horizon puts
|
||||
/// in one array.
|
||||
fn check_layers(limits: &wgpu::Limits, layers: u32) -> Result<()> {
|
||||
let needed = layers.min(sprite_split());
|
||||
if limits.max_texture_array_layers < needed {
|
||||
return Err(format!(
|
||||
"GPU supports {} texture layers; {needed} required",
|
||||
limits.max_texture_array_layers
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
fn check_layers(adapter: &wgpu::Adapter, layers: u32) -> Result<u32> {
|
||||
Ok(layer_split(
|
||||
adapter.limits().max_texture_array_layers,
|
||||
adapter.get_info().backend == wgpu::Backend::Gl,
|
||||
requested_split(),
|
||||
layers,
|
||||
)?)
|
||||
}
|
||||
/// Whether the swapchain can take a window of `width` x `height`: each side from 1 to the
|
||||
/// device's `max_texture_dimension_2d` (`max`), else `Surface::configure` panics. 0 is a
|
||||
@@ -1248,7 +1325,7 @@ async fn open_backend(
|
||||
describe_adapter(&adapter);
|
||||
let info = adapter.get_info();
|
||||
let limits = adapter.limits();
|
||||
if let Err(e) = check_layers(&limits, layers) {
|
||||
if let Err(e) = check_layers(&adapter, layers) {
|
||||
return Err(NoDevice::Failed(format!("{name}: {e}")));
|
||||
}
|
||||
// Mappable primary buffers: section pages that no longer fit in
|
||||
@@ -1300,13 +1377,28 @@ async fn open_backend(
|
||||
None => adapter.request_device(&desc).await,
|
||||
};
|
||||
match opened {
|
||||
Ok((device, queue)) => Ok(Opened {
|
||||
instance,
|
||||
surface,
|
||||
adapter,
|
||||
device,
|
||||
queue,
|
||||
}),
|
||||
Ok((device, queue)) => {
|
||||
let device_lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let lost = device_lost.clone();
|
||||
device.set_device_lost_callback(move |reason, message| {
|
||||
// A device destroyed on purpose was not lost.
|
||||
if reason == wgpu::DeviceLostReason::Destroyed {
|
||||
return;
|
||||
}
|
||||
log::warn!(
|
||||
"Horizon renderer: the graphics device was lost ({reason:?}: {message})"
|
||||
);
|
||||
lost.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
Ok(Opened {
|
||||
instance,
|
||||
surface,
|
||||
adapter,
|
||||
device,
|
||||
queue,
|
||||
device_lost,
|
||||
})
|
||||
}
|
||||
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
|
||||
}
|
||||
}
|
||||
@@ -1397,27 +1489,20 @@ impl Renderer {
|
||||
previous: Option<&Self>,
|
||||
api: GraphicsApi,
|
||||
) -> Result<Self> {
|
||||
let split = sprite_split();
|
||||
if layers > split * SPRITE_ARRAYS {
|
||||
return Err(format!(
|
||||
"{layers} block textures; Horizon draws at most {}",
|
||||
split * SPRITE_ARRAYS
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let Opened {
|
||||
instance,
|
||||
surface,
|
||||
adapter,
|
||||
device,
|
||||
queue,
|
||||
device_lost,
|
||||
} = match previous {
|
||||
// Reload resources on the same device; replacing live Vulkan
|
||||
// devices can invalidate the driver's swapchain state on the same
|
||||
// window.
|
||||
Some(renderer) => {
|
||||
describe_adapter(&renderer.adapter);
|
||||
check_layers(&renderer.adapter.limits(), layers)?;
|
||||
check_layers(&renderer.adapter, layers)?;
|
||||
Opened {
|
||||
surface: if offscreen {
|
||||
None
|
||||
@@ -1436,10 +1521,17 @@ impl Renderer {
|
||||
adapter: renderer.adapter.clone(),
|
||||
device: renderer.device.clone(),
|
||||
queue: renderer.queue.clone(),
|
||||
device_lost: renderer.device_lost.clone(),
|
||||
}
|
||||
}
|
||||
None => open_device(&window, offscreen, api, layers).await?,
|
||||
};
|
||||
// Computed once: the sprite arrays, the shaders' constants and the
|
||||
// shader pack atlas all cut the sprites by it.
|
||||
let split = check_layers(&adapter, layers)?;
|
||||
if split < sprite_split() {
|
||||
log::info!("Horizon: {split} block textures per array (the GPU's limit)");
|
||||
}
|
||||
let info = adapter.get_info();
|
||||
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
|
||||
let size = window.inner_size();
|
||||
@@ -1817,9 +1909,17 @@ impl Renderer {
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let prepared = prepared.unwrap_or_else(|| {
|
||||
PreparedTextures::new(sprite_pixels, sprite_size, layers, sprite_mips, forcefield)
|
||||
});
|
||||
let prepared = match prepared {
|
||||
Some(prepared) => prepared.resplit(layers, split),
|
||||
None => PreparedTextures::with_split(
|
||||
sprite_pixels,
|
||||
sprite_size,
|
||||
layers,
|
||||
sprite_mips,
|
||||
forcefield,
|
||||
split,
|
||||
),
|
||||
};
|
||||
let mut mip_levels = 1;
|
||||
let gl = adapter.get_info().backend == wgpu::Backend::Gl;
|
||||
let sprite_textures: Vec<wgpu::Texture> = prepared
|
||||
@@ -2399,10 +2499,13 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
true,
|
||||
);
|
||||
// RenderPipelines.TEXT_SEE_THROUGH: translucent, no depth test or writes.
|
||||
// RenderPipelines.TEXT_SEE_THROUGH: translucent, no depth test or
|
||||
// writes, culled like every world text pipeline (TEXT_SNIPPET keeps
|
||||
// RenderPipeline.Builder's default): a name tag or text display hides
|
||||
// its back.
|
||||
let see_through_pipeline = entity_pipeline(
|
||||
"See-through text",
|
||||
None,
|
||||
Some(wgpu::Face::Back),
|
||||
false,
|
||||
wgpu::CompareFunction::Always,
|
||||
Default::default(),
|
||||
@@ -2410,10 +2513,11 @@ impl Renderer {
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.TEXT_POLYGON_OFFSET: a text display's text and
|
||||
// background win against the surface they lie on (bias 1, 10).
|
||||
// background win against the surface they lie on (bias 1, 10), from
|
||||
// the front only.
|
||||
let text_polygon_offset_pipeline = entity_pipeline(
|
||||
"Polygon offset text",
|
||||
None,
|
||||
Some(wgpu::Face::Back),
|
||||
true,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
wgpu::DepthBiasState {
|
||||
@@ -2984,6 +3088,7 @@ impl Renderer {
|
||||
spare_surface: std::sync::Mutex::new(None),
|
||||
offscreen,
|
||||
in_flight: std::collections::VecDeque::new(),
|
||||
paced_elsewhere: false,
|
||||
first_frame: if previous.is_none() {
|
||||
FirstFrame::Acquire
|
||||
} else {
|
||||
@@ -2997,6 +3102,7 @@ impl Renderer {
|
||||
window,
|
||||
device,
|
||||
queue,
|
||||
device_lost,
|
||||
config,
|
||||
solid,
|
||||
translucent,
|
||||
@@ -3067,6 +3173,7 @@ impl Renderer {
|
||||
mipmaps: mip_levels - 1,
|
||||
terrain_layout: bindings,
|
||||
terrain_sprites: sprite_constants.map(|(_, v)| v),
|
||||
sprite_split: split,
|
||||
sprite_sampler,
|
||||
texture_filtering: 0,
|
||||
max_anisotropy: 1,
|
||||
@@ -3138,6 +3245,7 @@ impl Renderer {
|
||||
info,
|
||||
suspended: !drawable_size(size.width, size.height, max_side),
|
||||
acquire_validation_failures: 0,
|
||||
surface_unconfigured: false,
|
||||
drawn_sections: 0,
|
||||
section_draws: 0,
|
||||
section_draw_calls: 0,
|
||||
@@ -3145,6 +3253,8 @@ impl Renderer {
|
||||
started: Instant::now(),
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
screenshot_quality: 1,
|
||||
screenshot_halvings: 0,
|
||||
gamma: 0.5,
|
||||
improved_transparency: false,
|
||||
oit: None,
|
||||
@@ -3223,12 +3333,19 @@ impl Renderer {
|
||||
Some(surface) => surface,
|
||||
None => self.instance.create_surface(self.window.clone())?,
|
||||
};
|
||||
Self::configure(
|
||||
let configured = Self::configure(
|
||||
&self.device,
|
||||
&surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
&self.device_lost,
|
||||
);
|
||||
if let Err(e) = &configured {
|
||||
log::warn!(
|
||||
"Horizon renderer: the new window surface did not configure ({e}); retrying"
|
||||
);
|
||||
}
|
||||
self.surface_unconfigured = configured.is_err();
|
||||
self.surface = Some(surface);
|
||||
Ok(())
|
||||
}
|
||||
@@ -3283,16 +3400,9 @@ impl Renderer {
|
||||
Some(PresentMode::Immediate) => wgpu::PresentMode::Immediate,
|
||||
_ => wgpu::PresentMode::Fifo,
|
||||
};
|
||||
if self.config.present_mode != mode
|
||||
&& let Some(surface) = &self.surface
|
||||
{
|
||||
if self.config.present_mode != mode && self.surface.is_some() {
|
||||
self.config.present_mode = mode;
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
self.configure_surface();
|
||||
}
|
||||
}
|
||||
/// Follows the window to `width` x `height`. A size the swapchain can't take
|
||||
@@ -3305,13 +3415,8 @@ impl Renderer {
|
||||
}
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
if let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
if self.surface.is_some() {
|
||||
self.configure_surface();
|
||||
} else {
|
||||
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
|
||||
}
|
||||
@@ -3568,6 +3673,64 @@ impl Renderer {
|
||||
self.arena
|
||||
.release(s.page, s.start, u64::from(s.solid + s.translucent) * 4);
|
||||
}
|
||||
/// Video memory the arena keeps because fragmentation holds its pages
|
||||
/// partly used (a render distance that went down, far tiles that were
|
||||
/// replaced): moves the meshes off the sparsest page, a few MiB a frame,
|
||||
/// and `Arena::trim` returns the page once it is empty. Frames with the
|
||||
/// arena in system memory (video memory full) are slow, so this is what
|
||||
/// keeps the terrain in video memory.
|
||||
fn compact_arena(&mut self) {
|
||||
// Copied per frame: a page empties over a few frames, with no hitch.
|
||||
const BUDGET: u64 = 32 << 20;
|
||||
if self.arena.closing().is_none()
|
||||
&& let Some(page) = self.arena.sparsest()
|
||||
{
|
||||
self.arena.close(page);
|
||||
}
|
||||
let Some(page) = self.arena.closing() else {
|
||||
return;
|
||||
};
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let mut budget = BUDGET;
|
||||
let encoder = self.section_copies.get_or_insert_with(|| {
|
||||
self.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("Section uploads"),
|
||||
})
|
||||
});
|
||||
let meshes = self
|
||||
.sections
|
||||
.values_mut()
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
|
||||
for s in meshes.filter(|s| s.page == page) {
|
||||
let vertices = u64::from(s.solid + s.translucent) * 4;
|
||||
if vertices * stride > budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = self.arena.alloc_moved(vertices) else {
|
||||
// The other pages have no room after all.
|
||||
self.arena.reopen();
|
||||
return;
|
||||
};
|
||||
self.arena.copy(encoder, (s.page, s.start), to, vertices);
|
||||
self.arena.release(s.page, s.start, vertices);
|
||||
if let (Some(flat), Some(id)) = (&mut self.flat, s.flat_id) {
|
||||
flat.remove(id);
|
||||
s.flat_id = Some(flat.add(
|
||||
&self.device,
|
||||
encoder,
|
||||
&mut self.section_uploads,
|
||||
to.0,
|
||||
to.1,
|
||||
vertices,
|
||||
));
|
||||
}
|
||||
(s.page, s.start) = to;
|
||||
budget -= vertices * stride;
|
||||
}
|
||||
// With every mesh moved off, the page stays closed so this frame's
|
||||
// uploads can't refill it: `trim` returns it and clears `closing`.
|
||||
}
|
||||
/// Block-entity blocks of every meshed section (see `SectionMesh::block_entities`).
|
||||
pub fn block_entities(&self) -> impl Iterator<Item = &([i32; 3], u32)> {
|
||||
self.block_entities.values().flatten()
|
||||
@@ -3745,14 +3908,26 @@ impl Renderer {
|
||||
}
|
||||
/// Horizon's resolution scale (world pixels per window pixel) and FXAA.
|
||||
/// Configures `surface` and, while HDR output is on, sends its HDR metadata
|
||||
/// again: a new swapchain starts without it.
|
||||
/// again: a new swapchain starts without it. Errs when `surface` was left
|
||||
/// unconfigured (a validation error, or a lost device, which wgpu reports
|
||||
/// to no error scope).
|
||||
fn configure(
|
||||
device: &wgpu::Device,
|
||||
surface: &wgpu::Surface<'_>,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
hdr: Option<&hdr::Hdr>,
|
||||
) {
|
||||
device_lost: &std::sync::atomic::AtomicBool,
|
||||
) -> std::result::Result<(), String> {
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
surface.configure(device, config);
|
||||
let memory = pollster::block_on(memory.pop());
|
||||
if let Some(e) = memory.or(pollster::block_on(validation.pop())) {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
if device_lost.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Err("the graphics device was lost".into());
|
||||
}
|
||||
if let Some(hdr) = hdr {
|
||||
horizon_hdr::set_metadata(
|
||||
device,
|
||||
@@ -3762,15 +3937,21 @@ impl Renderer {
|
||||
hdr.black,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Configures the window surface again. One that fails is still the one
|
||||
/// wgpu configured before, which acquisition reports as a validation error.
|
||||
fn configure_surface(&self) {
|
||||
if let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
if let Some(surface) = &self.surface
|
||||
&& let Err(e) = Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
&self.device_lost,
|
||||
)
|
||||
{
|
||||
log::warn!("Horizon renderer: the window surface did not configure ({e})");
|
||||
}
|
||||
}
|
||||
/// HDR calibration's test picture (`crate::hdr_calibration`) while it runs, else None.
|
||||
@@ -4057,7 +4238,7 @@ impl Renderer {
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&self.sprite_textures,
|
||||
sprite_split(),
|
||||
self.sprite_split,
|
||||
layers,
|
||||
self.sprite_textures[0].width(),
|
||||
);
|
||||
@@ -4280,7 +4461,11 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
|
||||
let rendered = self.draw_frame(frame);
|
||||
let scaled = self.screenshot_size();
|
||||
let rendered = match scaled {
|
||||
Some(size) => self.draw_scaled_frame(frame, size),
|
||||
None => self.draw_frame(frame, None),
|
||||
};
|
||||
// A frame that was not drawn still submits the section copies and
|
||||
// atlas patches (glyphs, skins, maps), which the caller already took.
|
||||
if !matches!(rendered, Ok(true)) && !frame.hud.patches.is_empty() {
|
||||
@@ -4303,10 +4488,76 @@ impl Renderer {
|
||||
} else if let Some(copies) = self.take_section_copies() {
|
||||
self.queue.submit([copies]);
|
||||
}
|
||||
rendered
|
||||
// A scaled screenshot's frame never reached the window.
|
||||
rendered.map(|drawn| drawn && scaled.is_none())
|
||||
}
|
||||
fn draw_frame(&mut self, frame: &Frame) -> Result<bool> {
|
||||
/// The frame size of the pending `scaled` screenshot when it is larger than the
|
||||
/// window: the window's times the Resolution Scale (when above 100%) and
|
||||
/// `screenshot_quality`, cut down to what one texture and one readback buffer hold,
|
||||
/// and halved for each time it ran out of video memory.
|
||||
fn screenshot_size(&self) -> Option<[u32; 2]> {
|
||||
let request = self.screenshot.as_ref()?;
|
||||
if !request.scaled || request.size.is_some() || self.suspended {
|
||||
return None;
|
||||
}
|
||||
let window = [self.config.width, self.config.height];
|
||||
let limits = self.device.limits();
|
||||
let factor = scaled_screenshot_factor(
|
||||
window,
|
||||
self.world_post.scale,
|
||||
self.screenshot_quality,
|
||||
limits.max_texture_dimension_2d,
|
||||
limits.max_buffer_size,
|
||||
) / 2f32.powi(self.screenshot_halvings as i32);
|
||||
(factor > 1.0).then(|| window.map(|side| (side as f32 * factor) as u32))
|
||||
}
|
||||
/// Draws one frame at `size` into a texture of its own, for the pending screenshot:
|
||||
/// every pass sizes itself by `config`, which says `size` for this frame, the HUD
|
||||
/// keeps its layout at a larger scale, and the world is drawn at `size` (a Resolution
|
||||
/// Scale below 100% stays as low). The window keeps its last frame.
|
||||
fn draw_scaled_frame(&mut self, frame: &Frame, size: [u32; 2]) -> Result<bool> {
|
||||
let window = [self.config.width, self.config.height];
|
||||
let mut config = self.config.clone();
|
||||
[config.width, config.height] = size;
|
||||
config.usage |= wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
|
||||
// Out of video memory (an invalid texture's view is a validation error):
|
||||
// tried again at half the size next frame; the window keeps its last one.
|
||||
let checks = GpuChecks::push(&self.device);
|
||||
let target = Self::offscreen_target(&self.device, &config);
|
||||
let depth = Self::depth(&self.device, &config);
|
||||
if let Some(e) = checks.pop() {
|
||||
log::warn!(
|
||||
"Horizon renderer: no memory for a {}x{} screenshot ({e}); trying half the size",
|
||||
size[0],
|
||||
size[1]
|
||||
);
|
||||
self.screenshot_halvings += 1;
|
||||
return Ok(false);
|
||||
}
|
||||
let scale = self.world_post.scale;
|
||||
self.world_post.scale = scale / scale.max(1.0);
|
||||
self.hud.zoom = size[0] as f32 / window[0] as f32;
|
||||
[self.config.width, self.config.height] = size;
|
||||
let surface = self.surface.take();
|
||||
// HDR presents to the swapchain; this frame goes to a file.
|
||||
let hdr = self.hdr.take();
|
||||
let offscreen = self.offscreen.replace(target);
|
||||
let window_depth = std::mem::replace(&mut self.depth, depth);
|
||||
// The frame's own targets at `size` can run out of memory too.
|
||||
let drawn = self.draw_frame(frame, Some(GpuChecks::push(&self.device)));
|
||||
self.depth = window_depth;
|
||||
self.offscreen = offscreen;
|
||||
self.hdr = hdr;
|
||||
self.surface = surface;
|
||||
[self.config.width, self.config.height] = window;
|
||||
self.hud.zoom = 1.0;
|
||||
self.world_post.scale = scale;
|
||||
drawn
|
||||
}
|
||||
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
|
||||
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
|
||||
self.arena.trim();
|
||||
self.compact_arena();
|
||||
self.sorted_indices.trim();
|
||||
if let Some(flat) = &mut self.flat {
|
||||
flat.trim(|page| self.arena.has_page(page));
|
||||
@@ -4320,6 +4571,27 @@ impl Renderer {
|
||||
startup_step(format_args!("acquiring the first frame"));
|
||||
self.first_frame = FirstFrame::Submit;
|
||||
}
|
||||
if self.surface_unconfigured
|
||||
&& let Some(surface) = &self.surface
|
||||
{
|
||||
// Acquiring from it would panic: configure it again, or skip the
|
||||
// frame. A lost device never configures it.
|
||||
if self.device_lost.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Err("the graphics device was lost".into());
|
||||
}
|
||||
if Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
&self.device_lost,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
self.surface_unconfigured = false;
|
||||
}
|
||||
let acquire = Instant::now();
|
||||
let acquired = self.surface.as_ref().map(|s| s.get_current_texture());
|
||||
self.last_wait = acquire.elapsed();
|
||||
@@ -4331,14 +4603,7 @@ impl Renderer {
|
||||
return Ok(false);
|
||||
}
|
||||
Some(wgpu::CurrentSurfaceTexture::Outdated) => {
|
||||
if let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
}
|
||||
self.configure_surface();
|
||||
return Ok(false);
|
||||
}
|
||||
Some(wgpu::CurrentSurfaceTexture::Lost) => {
|
||||
@@ -4353,14 +4618,7 @@ impl Renderer {
|
||||
// window like a Lost one — a fullscreen loading screen then
|
||||
// skips frames instead of failing every one of them.
|
||||
if self.acquire_validation_failures == 0 {
|
||||
if let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
}
|
||||
self.configure_surface();
|
||||
} else {
|
||||
self.recreate_surface()?;
|
||||
}
|
||||
@@ -5944,10 +6202,6 @@ impl Renderer {
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !pack_draws_weather && !oit_on {
|
||||
self.weather.draw(&mut pass, &self.entity_group);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
if crumbling_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -6297,19 +6551,6 @@ impl Renderer {
|
||||
if outline_edges > 0 && frame.outline_after_terrain {
|
||||
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
// LevelRenderer: the world border after translucent terrain and weather.
|
||||
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title && !oit_on) {
|
||||
pass.set_pipeline(&self.border_pipeline);
|
||||
pass.set_bind_group(0, &self.border_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.border_vertices.slice(..));
|
||||
for &side in &b.sides {
|
||||
let first = side as u32 * 6;
|
||||
pass.draw_indexed(first..first + 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_crumbling_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -6327,6 +6568,25 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.cloud_buffer.slice(..));
|
||||
pass.draw_indexed(0..cloud_quads * 6, 0, 0..1);
|
||||
}
|
||||
// LevelRenderer.executeClassicTransparency: weather after translucent
|
||||
// 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);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// LevelRenderer: the world border after translucent terrain and weather.
|
||||
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title && !oit_on) {
|
||||
pass.set_pipeline(&self.border_pipeline);
|
||||
pass.set_bind_group(0, &self.border_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.border_vertices.slice(..));
|
||||
for &side in &b.sides {
|
||||
let first = side as u32 * 6;
|
||||
pass.draw_indexed(first..first + 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// LevelRenderer.executeSeeThrough, after the rest of the world.
|
||||
if see_through_quads > 0 {
|
||||
pass.set_pipeline(&self.see_through_pipeline);
|
||||
@@ -6715,21 +6975,34 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
let failed = checks.and_then(GpuChecks::pop);
|
||||
if let Some((request, buffer)) = readback {
|
||||
if let Some(hdr_buffer) = hdr_readback {
|
||||
let hdr_path = hdr_screenshot_path(&request.path);
|
||||
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
|
||||
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
// Out of memory on the way: the PNG would be black; taken again at half the size.
|
||||
if let Some(e) = failed {
|
||||
log::warn!(
|
||||
"Horizon renderer: a {}x{} screenshot failed ({e}); trying half the size",
|
||||
self.config.width,
|
||||
self.config.height
|
||||
);
|
||||
self.screenshot_halvings += 1;
|
||||
self.screenshot = Some(request);
|
||||
} else {
|
||||
self.screenshot_halvings = 0;
|
||||
if let Some(hdr_buffer) = hdr_readback {
|
||||
let hdr_path = hdr_screenshot_path(&request.path);
|
||||
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
|
||||
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
let present = Instant::now();
|
||||
if let Some(surface_frame) = surface_frame {
|
||||
@@ -6749,23 +7022,13 @@ impl Renderer {
|
||||
let _ = std::fs::remove_file(marker);
|
||||
log::info!("Horizon renderer: HDR output presented its first frame");
|
||||
}
|
||||
if reconfigure && let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
if reconfigure {
|
||||
self.configure_surface();
|
||||
}
|
||||
} else {
|
||||
// No swapchain paces offscreen frames: keep two on the GPU at most.
|
||||
self.in_flight.push_back(submission);
|
||||
while self.in_flight.len() > 2 {
|
||||
let index = self.in_flight.pop_front();
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
submission_index: index,
|
||||
timeout: Some(std::time::Duration::from_secs(5)),
|
||||
});
|
||||
if !self.paced_elsewhere {
|
||||
self.wait_in_flight();
|
||||
}
|
||||
}
|
||||
self.last_wait += present.elapsed();
|
||||
@@ -6777,6 +7040,41 @@ impl Renderer {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
/// Out-of-memory and validation error scopes, popped together: the first error either caught.
|
||||
struct GpuChecks {
|
||||
memory: wgpu::ErrorScopeGuard,
|
||||
validation: wgpu::ErrorScopeGuard,
|
||||
}
|
||||
impl GpuChecks {
|
||||
fn push(device: &wgpu::Device) -> Self {
|
||||
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
Self { memory, validation }
|
||||
}
|
||||
fn pop(self) -> Option<wgpu::Error> {
|
||||
let validation = pollster::block_on(self.validation.pop());
|
||||
let memory = pollster::block_on(self.memory.pop());
|
||||
memory.or(validation)
|
||||
}
|
||||
}
|
||||
/// How many times the window's size a scaled screenshot is: the Resolution Scale
|
||||
/// `world_scale` when above 1, times `quality`, as far as a side fits in
|
||||
/// `max_side` pixels and the readback in `max_buffer` bytes.
|
||||
fn scaled_screenshot_factor(
|
||||
[width, height]: [u32; 2],
|
||||
world_scale: f32,
|
||||
quality: u32,
|
||||
max_side: u32,
|
||||
max_buffer: u64,
|
||||
) -> f32 {
|
||||
let wanted = world_scale.max(1.0) * quality.max(1) as f32;
|
||||
let (w, h) = (f64::from(width.max(1)), f64::from(height.max(1)));
|
||||
let side = f64::from(max_side) / w.max(h);
|
||||
// padded_row(w * f) * h * f <= (4 * w * f + 256) * h * f <= max_buffer.
|
||||
let (a, b) = (4.0 * w * h, 256.0 * h);
|
||||
let buffer = ((b * b + 4.0 * a * max_buffer as f64).sqrt() - b) / (2.0 * a);
|
||||
wanted.min(side.min(buffer) as f32)
|
||||
}
|
||||
/// Bytes per row of a `width`-pixel frame read back at 4 bytes a pixel.
|
||||
fn padded_row(width: u32) -> u32 {
|
||||
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
@@ -6813,6 +7111,16 @@ pub(crate) fn copy_frame(
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
/// No swapchain paces offscreen frames: keep two on the GPU at most.
|
||||
pub fn wait_in_flight(&mut self) {
|
||||
while self.in_flight.len() > 2 {
|
||||
let index = self.in_flight.pop_front();
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
submission_index: index,
|
||||
timeout: Some(std::time::Duration::from_secs(5)),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Take a screenshot request: queue the frame's copy, or report why the
|
||||
/// frame cannot be read back.
|
||||
fn take_readback(
|
||||
@@ -7185,6 +7493,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn scaled_screenshots_follow_resolution_scale_and_quality_within_limits() {
|
||||
use super::scaled_screenshot_factor as factor;
|
||||
let big = u64::MAX / 2;
|
||||
// The window's size at 100% and 1x; Resolution Scale below 100% stays the window's.
|
||||
assert_eq!(factor([1920, 1080], 1.0, 1, 16384, big), 1.0);
|
||||
assert_eq!(factor([1920, 1080], 0.5, 1, 16384, big), 1.0);
|
||||
// The game's resolution, times the quality.
|
||||
assert_eq!(factor([1920, 1080], 2.0, 1, 16384, big), 2.0);
|
||||
assert_eq!(factor([1920, 1080], 1.5, 4, 16384, big), 6.0);
|
||||
assert_eq!(factor([1920, 1080], 0.5, 8, 16384, big), 8.0);
|
||||
// Cut to the largest texture side: 3840 * 4.266 = 16384.
|
||||
let f = factor([3840, 2160], 1.0, 8, 16384, big);
|
||||
assert!(
|
||||
(3840.0 * f) as u32 <= 16384 && (3840.0 * f) as u32 > 16300,
|
||||
"{f}"
|
||||
);
|
||||
// Cut to the largest buffer: 256 MiB holds about 3.8x of 1080p's readback.
|
||||
let max = 256 << 20;
|
||||
let f = factor([1920, 1080], 1.0, 8, 16384, max);
|
||||
let [w, h] = [1920, 1080].map(|s| (s as f32 * f) as u32);
|
||||
assert!(u64::from(super::padded_row(w)) * u64::from(h) <= max, "{f}");
|
||||
assert!(f > 3.7, "{f}");
|
||||
}
|
||||
#[test]
|
||||
fn drawable_size_skips_minimized_and_oversized_windows() {
|
||||
use super::drawable_size;
|
||||
assert!(drawable_size(1280, 720, 8192));
|
||||
@@ -7793,9 +8125,52 @@ mod sprite_upload_tests {
|
||||
.collect();
|
||||
assert_eq!(actual, expected.0);
|
||||
assert_eq!(prepared.forcefield, mip_chain(&border, size, &[], 0, 1));
|
||||
// Chains cut before the adapter was known, cut again for it.
|
||||
for to in [1, 2, 3, 5, 8] {
|
||||
let direct = PreparedTextures::with_split(
|
||||
&pixels,
|
||||
size,
|
||||
layers,
|
||||
&settings,
|
||||
(&border, size),
|
||||
to,
|
||||
);
|
||||
let resplit = PreparedTextures::with_split(
|
||||
&pixels,
|
||||
size,
|
||||
layers,
|
||||
&settings,
|
||||
(&border, size),
|
||||
split,
|
||||
)
|
||||
.resplit(layers, to);
|
||||
assert_eq!(resplit.split, to);
|
||||
assert_eq!(resplit.sprites, direct.sprites, "{split} -> {to}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_split_follows_the_adapters_layer_limit() {
|
||||
// The Discord report: 512 layers per array, 1291 block textures (3 arrays).
|
||||
assert_eq!(layer_split(512, false, None, 1291), Ok(512));
|
||||
assert_eq!(layer_split(512, true, None, 1291), Ok(512));
|
||||
assert_eq!(layer_split(2048, false, None, 1291), Ok(SPRITE_SPLIT));
|
||||
assert_eq!(layer_split(8192, false, None, 1291), Ok(SPRITE_SPLIT));
|
||||
// HORIZON_SPRITE_SPLIT only lowers it.
|
||||
assert_eq!(layer_split(2048, false, Some(512), 1291), Ok(512));
|
||||
assert_eq!(layer_split(256, false, Some(512), 1291), Ok(256));
|
||||
// OpenGL pads a multiple of 6 by a layer: a full array must still fit.
|
||||
assert_eq!(layer_split(600, true, None, 1291), Ok(599));
|
||||
assert_eq!(layer_split(600, false, None, 1291), Ok(600));
|
||||
assert_eq!(array_layers(599, true), 599);
|
||||
// Past the arrays terrain.wgsl binds.
|
||||
assert_eq!(layer_split(256, false, None, 256 * SPRITE_ARRAYS), Ok(256));
|
||||
let err = layer_split(128, false, None, 1291).unwrap_err();
|
||||
assert!(err.contains("128 texture layers per array"), "{err}");
|
||||
assert!(layer_split(512, false, None, 512 * SPRITE_ARRAYS + 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mip_chain_allocates_exactly_its_size() {
|
||||
for size in [16, 64] {
|
||||
|
||||
@@ -76,8 +76,22 @@ struct Page {
|
||||
/// Capacity in vertices.
|
||||
capacity: u64,
|
||||
free: FreeList,
|
||||
/// The buffer went to system memory (`Arena::create`'s fallback): meshes
|
||||
/// are never moved into it, and it is the first to empty.
|
||||
host: bool,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
/// Vertices in the page's free ranges.
|
||||
fn free_vertices(&self) -> u64 {
|
||||
self.free.0.values().sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// An arena with this many bytes in free ranges, at least half of its pages,
|
||||
/// has a page worth emptying (`Arena::sparsest`).
|
||||
const COMPACT_MIN_FREE: u64 = 128 << 20;
|
||||
|
||||
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
|
||||
/// buffer sizes): a section's vertices sit in one page.
|
||||
pub struct Arena {
|
||||
@@ -86,6 +100,8 @@ pub struct Arena {
|
||||
page_limit: u64,
|
||||
initial_capacity: u64,
|
||||
usage: wgpu::BufferUsages,
|
||||
/// A page being emptied (`Arena::close`): nothing is placed in it.
|
||||
closing: Option<u32>,
|
||||
/// A page had to go to system memory (logged once).
|
||||
spilled: bool,
|
||||
/// Tests: GPU-local pages fail as if video memory were full.
|
||||
@@ -129,6 +145,7 @@ impl Arena {
|
||||
page_limit,
|
||||
initial_capacity: capacity,
|
||||
usage,
|
||||
closing: None,
|
||||
spilled: false,
|
||||
#[cfg(test)]
|
||||
video_memory_full: false,
|
||||
@@ -138,7 +155,7 @@ impl Arena {
|
||||
/// and the device allows it, in system memory: Vulkan drivers fail an
|
||||
/// allocation rather than page like OpenGL ones, and wgpu's default error
|
||||
/// handler panics. `None` when neither has room.
|
||||
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
|
||||
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<(wgpu::Buffer, bool)> {
|
||||
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
|
||||
#[cfg(test)]
|
||||
let local = (!self.video_memory_full)
|
||||
@@ -146,6 +163,7 @@ impl Arena {
|
||||
.flatten();
|
||||
#[cfg(not(test))]
|
||||
let local = try_create(device, capacity * self.stride, usage);
|
||||
let local = local.map(|buffer| (buffer, false));
|
||||
local.or_else(|| {
|
||||
// Mappable buffers are placed in host-visible memory, which the
|
||||
// allocator takes from system memory once video memory is full.
|
||||
@@ -166,7 +184,7 @@ impl Arena {
|
||||
(lower the render distance if frames slow down)"
|
||||
);
|
||||
}
|
||||
Some(buffer)
|
||||
Some((buffer, true))
|
||||
})
|
||||
}
|
||||
/// The rounded length a request of `vertices` occupies.
|
||||
@@ -207,16 +225,13 @@ impl Arena {
|
||||
vertices: u64,
|
||||
) -> Option<(u32, u64)> {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
for (i, page) in self.pages.iter_mut().enumerate() {
|
||||
if let Some(page) = page
|
||||
&& let Some(start) = page.free.take(len)
|
||||
{
|
||||
return Some((i as u32, start));
|
||||
}
|
||||
if let Some(found) = self.alloc_existing(len) {
|
||||
return Some(found);
|
||||
}
|
||||
let last = self.pages.len().saturating_sub(1);
|
||||
if let Some(page) = self.pages.last().and_then(Option::as_ref)
|
||||
&& page.capacity < self.page_limit
|
||||
&& self.closing != Some(last as u32)
|
||||
{
|
||||
let current = page.capacity;
|
||||
let grown = (current * 2).max(current + len).min(self.page_limit);
|
||||
@@ -235,7 +250,7 @@ impl Arena {
|
||||
.max(len)
|
||||
.min(self.page_limit);
|
||||
// A page just large enough when memory is short.
|
||||
let (buffer, capacity) = match self.create(device, capacity) {
|
||||
let ((buffer, host), capacity) = match self.create(device, capacity) {
|
||||
Some(buffer) => (buffer, capacity),
|
||||
None if capacity > len => (self.create(device, len)?, len),
|
||||
None => return None,
|
||||
@@ -246,6 +261,7 @@ impl Arena {
|
||||
buffer,
|
||||
capacity,
|
||||
free,
|
||||
host,
|
||||
};
|
||||
let index = self
|
||||
.pages
|
||||
@@ -258,6 +274,96 @@ impl Arena {
|
||||
self.pages[index] = Some(page);
|
||||
Some((index as u32, start))
|
||||
}
|
||||
/// A range of `vertices` in a page that exists already and is not being
|
||||
/// emptied: never grows the arena.
|
||||
pub fn alloc_existing(&mut self, vertices: u64) -> Option<(u32, u64)> {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
let closing = self.closing;
|
||||
self.pages
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| closing != Some(*i as u32))
|
||||
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
|
||||
}
|
||||
/// `alloc_existing` for a mesh moved off the closing page: only pages in
|
||||
/// video memory, so compaction never pushes terrain into system memory.
|
||||
pub fn alloc_moved(&mut self, vertices: u64) -> Option<(u32, u64)> {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
let closing = self.closing;
|
||||
self.pages
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.filter(|(i, p)| closing != Some(*i as u32) && p.as_ref().is_some_and(|p| !p.host))
|
||||
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
|
||||
}
|
||||
/// The page to empty when much of the arena is free ranges (video memory
|
||||
/// that fragmentation keeps: a page goes back only once all of it is free):
|
||||
/// the one with the fewest vertices in use that the other pages' free
|
||||
/// ranges can take. `None` when the arena is dense enough or already
|
||||
/// emptying a page.
|
||||
pub fn sparsest(&self) -> Option<u32> {
|
||||
self.sparsest_from(COMPACT_MIN_FREE)
|
||||
}
|
||||
/// `sparsest` for an arena with at least `min_free` bytes in free ranges.
|
||||
fn sparsest_from(&self, min_free: u64) -> Option<u32> {
|
||||
if self.closing.is_some() {
|
||||
return None;
|
||||
}
|
||||
let pages = || {
|
||||
self.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, p)| Some((i, p.as_ref()?)))
|
||||
};
|
||||
let free: u64 = pages().map(|(_, p)| p.free_vertices()).sum();
|
||||
let capacity: u64 = pages().map(|(_, p)| p.capacity).sum();
|
||||
if free * self.stride < min_free || free * 2 < capacity {
|
||||
return None;
|
||||
}
|
||||
// Moves only go to video-memory pages (`alloc_moved`).
|
||||
let local_free: u64 = pages()
|
||||
.filter(|(_, p)| !p.host)
|
||||
.map(|(_, p)| p.free_vertices())
|
||||
.sum();
|
||||
pages()
|
||||
.filter_map(|(i, p)| {
|
||||
let used = p.capacity - p.free_vertices();
|
||||
let own = if p.host { 0 } else { p.free_vertices() };
|
||||
// The room elsewhere, less a quarter for first fit's gaps.
|
||||
let elsewhere = (local_free - own) / 4 * 3;
|
||||
// A page in system memory is emptied first.
|
||||
(used > 0 && used <= elsewhere).then_some((!p.host, used, i as u32))
|
||||
})
|
||||
.min()
|
||||
.map(|(_, _, i)| i)
|
||||
}
|
||||
/// Stops placing vertices in `page` until `reopen`.
|
||||
pub fn close(&mut self, page: u32) {
|
||||
self.closing = Some(page);
|
||||
}
|
||||
pub fn reopen(&mut self) {
|
||||
self.closing = None;
|
||||
}
|
||||
pub fn closing(&self) -> Option<u32> {
|
||||
self.closing
|
||||
}
|
||||
/// Records the copy of `vertices` from one range to another of other pages.
|
||||
pub fn copy(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
from: (u32, u64),
|
||||
to: (u32, u64),
|
||||
vertices: u64,
|
||||
) {
|
||||
debug_assert_ne!(from.0, to.0);
|
||||
encoder.copy_buffer_to_buffer(
|
||||
self.buffer(from.0),
|
||||
from.1 * self.stride,
|
||||
self.buffer(to.0),
|
||||
to.1 * self.stride,
|
||||
vertices * self.stride,
|
||||
);
|
||||
}
|
||||
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
self.pages[page as usize]
|
||||
@@ -296,7 +402,7 @@ impl Arena {
|
||||
capacity: u64,
|
||||
) -> bool {
|
||||
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
|
||||
let Some(buffer) = self.create(device, capacity) else {
|
||||
let Some((buffer, host)) = self.create(device, capacity) else {
|
||||
return false;
|
||||
};
|
||||
let old = self.pages[page].as_mut().expect("live arena page");
|
||||
@@ -304,6 +410,7 @@ impl Arena {
|
||||
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
|
||||
let previous = old.capacity;
|
||||
old.buffer = buffer;
|
||||
old.host = host;
|
||||
old.capacity = capacity;
|
||||
old.free.give(previous, capacity - previous);
|
||||
true
|
||||
@@ -318,6 +425,9 @@ impl Arena {
|
||||
*page = None;
|
||||
}
|
||||
}
|
||||
if self.closing.is_some_and(|page| !self.has_page(page)) {
|
||||
self.closing = None;
|
||||
}
|
||||
// Keep live page indices stable; later allocations reuse interior holes.
|
||||
while self.pages.last().is_some_and(Option::is_none) {
|
||||
self.pages.pop();
|
||||
@@ -325,6 +435,7 @@ impl Arena {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
self.closing = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +518,76 @@ mod tests {
|
||||
assert_eq!(read(&device, &queue, arena.buffer(page), next * 4), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sparse_page_empties_into_the_others_and_is_reclaimed() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut arena = Arena::new(&device, 4, GRAIN, false);
|
||||
arena.page_limit = 4 * GRAIN;
|
||||
// Eight ranges fill two pages, each holding its own number.
|
||||
let ranges: Vec<(u32, u64)> = (0..8)
|
||||
.map(|i| {
|
||||
let range = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
queue.write_buffer(
|
||||
arena.buffer(range.0),
|
||||
range.1 * 4,
|
||||
&(100 + i as u32).to_le_bytes(),
|
||||
);
|
||||
range
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(arena.pages.iter().flatten().count(), 2);
|
||||
let (first, second) = (ranges[0].0, ranges[7].0);
|
||||
assert_ne!(first, second);
|
||||
// Fragmented: one range live in the first page, two in the second.
|
||||
for (i, &(page, start)) in ranges.iter().enumerate() {
|
||||
if ![0, 6, 7].contains(&i) {
|
||||
arena.release(page, start, GRAIN);
|
||||
}
|
||||
}
|
||||
// Dense enough arenas, and ones without room elsewhere, are left alone.
|
||||
assert_eq!(arena.sparsest_from(u64::MAX), None);
|
||||
assert_eq!(arena.sparsest_from(0), Some(first));
|
||||
arena.close(first);
|
||||
assert_eq!(
|
||||
arena.sparsest_from(0),
|
||||
None,
|
||||
"one page is emptied at a time"
|
||||
);
|
||||
// New ranges avoid the closing page though it has room.
|
||||
let (page, extra) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
assert_eq!(page, second);
|
||||
arena.release(page, extra, GRAIN);
|
||||
// Its live range moves, data included, and the page goes back.
|
||||
let (page, start) = ranges[0];
|
||||
let to = arena.alloc_existing(GRAIN).unwrap();
|
||||
assert_eq!(to.0, second);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
arena.copy(&mut encoder, (page, start), to, GRAIN);
|
||||
queue.submit([encoder.finish()]);
|
||||
arena.release(page, start, GRAIN);
|
||||
assert_eq!(read(&device, &queue, arena.buffer(to.0), to.1 * 4), 100);
|
||||
// Emptied but not trimmed yet: it stays closed, so uploads can't refill it.
|
||||
assert_eq!(arena.closing(), Some(page));
|
||||
let refill = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
|
||||
assert_ne!(refill.0, page);
|
||||
arena.release(refill.0, refill.1, GRAIN);
|
||||
let before = arena.bytes();
|
||||
arena.trim();
|
||||
assert_eq!(arena.bytes(), before - 4 * GRAIN * 4);
|
||||
assert_eq!(arena.closing(), None);
|
||||
// The others kept their ranges.
|
||||
for i in [6, 7] {
|
||||
let (page, start) = ranges[i];
|
||||
assert_eq!(
|
||||
read(&device, &queue, arena.buffer(page), start * 4),
|
||||
100 + i as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
|
||||
let Some((device, queue)) = device() else {
|
||||
@@ -572,6 +753,9 @@ mod tests {
|
||||
queue.write_buffer(arena.buffer(grown), next * 4, &9u32.to_le_bytes());
|
||||
assert_eq!(read(&device, &queue, arena.buffer(page), start * 4), 7);
|
||||
assert_eq!(read(&device, &queue, arena.buffer(grown), next * 4), 9);
|
||||
// Compaction never moves terrain into a page in system memory.
|
||||
assert!(arena.pages[page as usize].as_ref().unwrap().host);
|
||||
assert_eq!(arena.alloc_moved(GRAIN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -152,6 +152,10 @@ impl Tile {
|
||||
None => &mut self.mesh,
|
||||
}
|
||||
}
|
||||
/// Both meshes it holds in the arena: its own and the stand-in, if any.
|
||||
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
|
||||
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
|
||||
}
|
||||
pub fn visible(
|
||||
&self,
|
||||
key: TileKey,
|
||||
|
||||
@@ -176,7 +176,7 @@ impl Features {
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("text", "entity", None, offset),
|
||||
kind("text", "entity", Some(wgpu::Face::Back), offset),
|
||||
),
|
||||
crumbling: set(
|
||||
&entity_module,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use glam::{DMat4, DVec3};
|
||||
use horizon_shaders::{Format, Group, Kind, Post, Val};
|
||||
|
||||
use super::pack_draw::DrawUniforms;
|
||||
@@ -434,9 +435,33 @@ pub(super) struct Weather {
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// Shadow cache: the key of the shadow map the shadow targets hold. Only the
|
||||
/// shadow passes write those targets, so while the key matches they still hold
|
||||
/// it and the pass is skipped.
|
||||
#[derive(Default)]
|
||||
struct ShadowCache {
|
||||
key: Option<ShadowCacheKey>,
|
||||
}
|
||||
|
||||
/// Key for validating the shadow cache.
|
||||
/// Must match the cache state exactly to be considered valid.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct ShadowCacheKey {
|
||||
sun_angle: f32,
|
||||
moon_angle: f32,
|
||||
snapped_camera: Option<DVec3>,
|
||||
shadow_settings_hash: u64,
|
||||
shadow_scene_revision: (u64, u64),
|
||||
ticks: u64,
|
||||
day_time: i64,
|
||||
shadow_uniforms_hash: u64,
|
||||
}
|
||||
|
||||
pub struct PackPost {
|
||||
pub notes: Vec<String>,
|
||||
passes: Vec<Pass>,
|
||||
/// Shadow cache for temporal reprojection.
|
||||
shadow_cache: ShadowCache,
|
||||
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`.
|
||||
terrain: Option<Pass>,
|
||||
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
|
||||
@@ -2330,6 +2355,7 @@ impl PackPost {
|
||||
u.shadow = shadow_config;
|
||||
u
|
||||
},
|
||||
shadow_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3322,6 +3348,45 @@ impl PackPost {
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
frame: &super::Frame,
|
||||
) {
|
||||
// Opt-in only (`horizon-graphics.txt` shadowCache). Off: always redraw, drop any cache.
|
||||
if !frame.shadow_cache {
|
||||
self.shadow_cache = ShadowCache::default();
|
||||
self.draw_shadow_pass(device, queue, encoder, terrain);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the current frame's cache key before borrowing shadow_targets mutably.
|
||||
let cache_key = self.compute_shadow_cache_key(frame);
|
||||
|
||||
// Entity vertices are interpolated each frame, so their shadows cannot be reused.
|
||||
let has_shadow_entities = self
|
||||
.entities
|
||||
.as_ref()
|
||||
.is_some_and(|entities| entities.geometry.draws.iter().any(|draw| draw.shadow));
|
||||
// Custom images are cleared each frame (`pack_images::clear`) before the
|
||||
// shadow pass writes them, so a pack with any redraws it too.
|
||||
let cache_hit = !has_shadow_entities
|
||||
&& self.images.is_empty()
|
||||
&& self.shadow_cache.key == Some(cache_key)
|
||||
&& self.shadow_targets.is_some();
|
||||
if cache_hit {
|
||||
// The targets still hold this map (and `main`, the A/B side
|
||||
// `shadowcomp` left current).
|
||||
return;
|
||||
}
|
||||
self.draw_shadow_pass(device, queue, encoder, terrain);
|
||||
self.shadow_cache.key = self.shadow_targets.is_some().then_some(cache_key);
|
||||
}
|
||||
|
||||
/// The shadow pass body: terrain, entities, clears, and `shadowcomp`.
|
||||
fn draw_shadow_pass(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
let Some(t) = &mut self.shadow_targets else {
|
||||
return;
|
||||
@@ -3397,6 +3462,106 @@ impl PackPost {
|
||||
self.shadow_comp = comp;
|
||||
}
|
||||
|
||||
/// Computes the cache key for the current frame.
|
||||
fn compute_shadow_cache_key(&self, frame: &super::Frame) -> ShadowCacheKey {
|
||||
let shadow = &self.uniforms.shadow;
|
||||
// Use the same logic as shadow_camera to compute the light direction and snapped camera.
|
||||
// Use the public shadow_light function which returns the normalized light direction.
|
||||
let light = super::pack_uniforms::shadow_light(frame, shadow);
|
||||
let light = light.as_dvec3();
|
||||
let up = if light.z.abs() > 0.99 {
|
||||
DVec3::Y
|
||||
} else {
|
||||
DVec3::Z
|
||||
};
|
||||
let rotation = DMat4::look_to_rh(DVec3::ZERO, -light, up);
|
||||
let at = rotation.transform_vector3(frame.camera);
|
||||
let step = f64::from(shadow.interval);
|
||||
let snapped_camera = if step > 0.0 {
|
||||
Some((at / step).floor() * step)
|
||||
} else {
|
||||
Some(at)
|
||||
};
|
||||
|
||||
// Hash the shadow settings that affect the shadow map.
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
shadow.resolution.hash(&mut hasher);
|
||||
shadow.distance.to_bits().hash(&mut hasher);
|
||||
shadow.render_mul.to_bits().hash(&mut hasher);
|
||||
shadow.interval.to_bits().hash(&mut hasher);
|
||||
shadow.sun_path_rotation.to_bits().hash(&mut hasher);
|
||||
shadow.fov.map(|f| f.to_bits()).hash(&mut hasher);
|
||||
shadow.near.to_bits().hash(&mut hasher);
|
||||
shadow.far.to_bits().hash(&mut hasher);
|
||||
for hf in &shadow.hardware_filtering {
|
||||
hf.hash(&mut hasher);
|
||||
}
|
||||
for color in &shadow.colors {
|
||||
format!("{:?}", color.format).hash(&mut hasher);
|
||||
color.clear.hash(&mut hasher);
|
||||
if let Some(cc) = color.clear_color {
|
||||
for c in cc {
|
||||
c.to_bits().hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
color.mipmap.hash(&mut hasher);
|
||||
}
|
||||
let shadow_settings_hash = hasher.finish();
|
||||
let shadow_uniforms_hash = self.shadow_uniforms_hash();
|
||||
|
||||
ShadowCacheKey {
|
||||
sun_angle: frame.sky.sun_angle,
|
||||
moon_angle: frame.sky.moon_angle,
|
||||
snapped_camera,
|
||||
shadow_settings_hash,
|
||||
shadow_scene_revision: frame.shadow_scene_revision,
|
||||
ticks: frame.ticks,
|
||||
day_time: frame.pack.day_time,
|
||||
shadow_uniforms_hash,
|
||||
}
|
||||
}
|
||||
|
||||
fn shadow_uniforms_hash(&self) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
let passes = self
|
||||
.shadow_terrain
|
||||
.iter()
|
||||
.chain(self.shadow_water.iter())
|
||||
.chain(self.shadow_comp.iter())
|
||||
.chain(
|
||||
self.entities
|
||||
.iter()
|
||||
.flat_map(|entities| entities.shadow.iter().flatten()),
|
||||
);
|
||||
for pass in passes {
|
||||
pass.name.hash(&mut hasher);
|
||||
for (index, ty, offset) in &pass.uniforms {
|
||||
index.hash(&mut hasher);
|
||||
ty.hash(&mut hasher);
|
||||
offset.hash(&mut hasher);
|
||||
let Some(value) = index.and_then(|index| self.uniforms.values.get(index)) else {
|
||||
continue;
|
||||
};
|
||||
match value {
|
||||
pack_uniforms::Value::F(values) => {
|
||||
0u8.hash(&mut hasher);
|
||||
for value in values {
|
||||
value.to_bits().hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
pack_uniforms::Value::I(values) => {
|
||||
1u8.hash(&mut hasher);
|
||||
values.hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Runs the pack over the world drawn into `world` (colour) and `world_depth`, then draws
|
||||
/// the result onto `screen`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -3464,7 +3629,7 @@ impl PackPost {
|
||||
self.gpu.clear,
|
||||
&mut self.zeros,
|
||||
);
|
||||
self.run_shadow(device, queue, encoder, terrain);
|
||||
self.run_shadow(device, queue, encoder, terrain, frame);
|
||||
let Some(targets) = &self.targets else { return };
|
||||
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
@@ -3720,6 +3885,36 @@ mod tests {
|
||||
use super::*;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
fn shadow_cache_key_tracks_world_and_time_changes() {
|
||||
let key = ShadowCacheKey {
|
||||
sun_angle: 0.0,
|
||||
moon_angle: 180.0,
|
||||
snapped_camera: Some(DVec3::ZERO),
|
||||
shadow_settings_hash: 1,
|
||||
shadow_scene_revision: (2, 3),
|
||||
ticks: 4,
|
||||
day_time: 5,
|
||||
shadow_uniforms_hash: 6,
|
||||
};
|
||||
|
||||
assert_ne!(
|
||||
key,
|
||||
ShadowCacheKey {
|
||||
shadow_scene_revision: (8, 3),
|
||||
..key
|
||||
}
|
||||
);
|
||||
assert_ne!(key, ShadowCacheKey { day_time: 9, ..key });
|
||||
assert_ne!(
|
||||
key,
|
||||
ShadowCacheKey {
|
||||
shadow_uniforms_hash: 10,
|
||||
..key
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Two textures with a sampler each become two textures sharing one sampler variable.
|
||||
#[test]
|
||||
fn samplers_of_a_kind_share_one_variable() {
|
||||
|
||||
@@ -61,6 +61,9 @@ impl<T> Resident<T> {
|
||||
pub fn values(&self) -> impl Iterator<Item = &T> {
|
||||
self.items.iter().map(|(_, v)| v)
|
||||
}
|
||||
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
|
||||
self.items.iter_mut().map(|(_, v)| v)
|
||||
}
|
||||
/// `key`'s value, when it is resident.
|
||||
pub fn find(&self, key: SectionKey) -> Option<&T> {
|
||||
self.slots.get(&key).map(|&slot| self.get(slot))
|
||||
|
||||
@@ -53,13 +53,7 @@ impl Weather {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
depth_stencil: Some(depth_stencil()),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
@@ -157,11 +151,33 @@ impl Weather {
|
||||
}
|
||||
}
|
||||
|
||||
/// RenderPipelines.WEATHER: depth-tested, but no depth write, so the water
|
||||
/// and glass drawn behind the rain still show through it.
|
||||
fn depth_stencil() -> wgpu::DepthStencilState {
|
||||
wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::weather::{Parameters, Time};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
#[test]
|
||||
fn weather_writes_no_depth() {
|
||||
let depth = super::depth_stencil();
|
||||
assert_eq!(depth.depth_write_enabled, Some(false));
|
||||
assert_eq!(
|
||||
depth.depth_compare,
|
||||
Some(wgpu::CompareFunction::GreaterEqual)
|
||||
);
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Case {
|
||||
|
||||
@@ -313,6 +313,8 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
|
||||
/// The name the options screen waits on to bind `Graphics::hdr_key`.
|
||||
pub const HDR_KEY: &str = "horizon.hdrSettings";
|
||||
/// The Screenshot Quality choices, times the game's resolution.
|
||||
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
|
||||
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
|
||||
/// opens its surface; the settings open HDR's screen only then.
|
||||
@@ -379,6 +381,8 @@ pub struct Graphics {
|
||||
pub taa: bool,
|
||||
/// TAA upscaling; anything but `Native` comes with `taa` on.
|
||||
pub upscaling: Upscaling,
|
||||
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
|
||||
pub screenshot_quality: u32,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
@@ -399,6 +403,10 @@ pub struct Graphics {
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
pub battery: bool,
|
||||
/// Reuse the shader pack's last shadow map when sun, view, scene revision and
|
||||
/// pack uniforms still match. Off by default: vanilla loaders redraw every frame
|
||||
/// (or every `shadowInterval`), and a hit with a stale key shows old shadows.
|
||||
pub shadow_cache: bool,
|
||||
/// The graphics API (Windows only; see `GraphicsApi`).
|
||||
pub api: GraphicsApi,
|
||||
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
|
||||
@@ -428,6 +436,7 @@ impl Default for Graphics {
|
||||
fxaa: false,
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
screenshot_quality: 1,
|
||||
hdr: false,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
@@ -435,6 +444,7 @@ impl Default for Graphics {
|
||||
hdr_ui_brightness: 100,
|
||||
hdr_key: "key.keyboard.unknown".into(),
|
||||
battery: false,
|
||||
shadow_cache: false,
|
||||
api: GraphicsApi::Auto,
|
||||
shader_pack: String::new(),
|
||||
}
|
||||
@@ -497,6 +507,11 @@ impl Graphics {
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"taa" => g.taa = flag(g.taa),
|
||||
"upscaling" => g.upscaling = Upscaling::parse(value),
|
||||
"screenshotQuality" => {
|
||||
g.screenshot_quality = Some(number(0))
|
||||
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
@@ -516,6 +531,7 @@ impl Graphics {
|
||||
}
|
||||
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
"overlay" => {
|
||||
@@ -532,7 +548,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\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(),
|
||||
@@ -559,6 +575,7 @@ impl Graphics {
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.taa),
|
||||
self.upscaling.name(),
|
||||
self.screenshot_quality,
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
@@ -566,6 +583,7 @@ impl Graphics {
|
||||
self.hdr_ui_brightness,
|
||||
self.hdr_key,
|
||||
bool_text(self.battery),
|
||||
bool_text(self.shadow_cache),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
)
|
||||
@@ -703,6 +721,7 @@ mod tests {
|
||||
fxaa: true,
|
||||
taa: true,
|
||||
upscaling: Upscaling::Balanced,
|
||||
screenshot_quality: 4,
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
@@ -710,6 +729,7 @@ mod tests {
|
||||
hdr_ui_brightness: 60,
|
||||
hdr_key: "key.keyboard.h".into(),
|
||||
battery: true,
|
||||
shadow_cache: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
@@ -720,6 +740,19 @@ mod tests {
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
|
||||
// Only the button's choices.
|
||||
assert_eq!(
|
||||
Graphics::parse("screenshotQuality:8\n").screenshot_quality,
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("screenshotQuality:3\n").screenshot_quality,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("screenshotQuality:16\n").screenshot_quality,
|
||||
1
|
||||
);
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
|
||||
@@ -40,6 +40,7 @@ pub enum H {
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
ScreenshotQuality,
|
||||
Hdr,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
@@ -52,6 +53,7 @@ pub enum H {
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
BatteryMode,
|
||||
ShadowCache,
|
||||
GraphicsApi,
|
||||
}
|
||||
|
||||
@@ -278,6 +280,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
High,
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
),
|
||||
h(
|
||||
H::ScreenshotQuality,
|
||||
None,
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
|
||||
),
|
||||
h(
|
||||
H::HdrSettings,
|
||||
None,
|
||||
@@ -313,6 +320,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.",
|
||||
),
|
||||
h(
|
||||
H::ShadowCache,
|
||||
Saves,
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.",
|
||||
),
|
||||
h(
|
||||
H::Workers,
|
||||
None,
|
||||
@@ -538,6 +550,9 @@ impl H {
|
||||
},
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::ScreenshotQuality => {
|
||||
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
|
||||
}
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
|
||||
@@ -571,6 +586,7 @@ impl H {
|
||||
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
|
||||
}
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::Overlay => labelled(
|
||||
"Performance Overlay",
|
||||
@@ -647,8 +663,16 @@ impl H {
|
||||
(g.fxaa, g.taa) = (false, true);
|
||||
}
|
||||
}
|
||||
H::ScreenshotQuality => {
|
||||
g.screenshot_quality = step(
|
||||
&crate::graphics::SCREENSHOT_QUALITIES,
|
||||
g.screenshot_quality,
|
||||
back,
|
||||
)
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
|
||||
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
|
||||
H::Pacing => {
|
||||
g.pacing = match g.pacing {
|
||||
@@ -711,6 +735,7 @@ impl Item {
|
||||
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Horizon(H::ShadowCache) => g.shadow_cache,
|
||||
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
|
||||
Item::Vanilla(Opt::Vsync) => s.vsync,
|
||||
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
|
||||
@@ -766,10 +791,12 @@ impl Item {
|
||||
Item::Horizon(
|
||||
H::Preset
|
||||
| H::Overlay
|
||||
| H::ScreenshotQuality
|
||||
| H::Pacing
|
||||
| H::PresentMode
|
||||
| H::Workers
|
||||
| H::BatteryMode
|
||||
| H::ShadowCache
|
||||
| H::GraphicsApi
|
||||
) | Item::Vanilla(
|
||||
Opt::GuiScale
|
||||
@@ -1733,6 +1760,21 @@ mod tests {
|
||||
assert_eq!(s.horizon.preset, Preset::Ultra);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_quality_cycles_1_2_4_8_and_keeps_the_preset() {
|
||||
let mut s = Settings::default();
|
||||
Item::Horizon(H::Preset).cycle(&mut s, false, [854, 480]);
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..4 {
|
||||
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, false, [854, 480]);
|
||||
seen.push(s.horizon.screenshot_quality);
|
||||
}
|
||||
assert_eq!(seen, [2, 4, 8, 1]);
|
||||
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(s.horizon.screenshot_quality, 8);
|
||||
assert_eq!(s.horizon.preset, Preset::Performance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_budget_slider_steps() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -528,14 +528,16 @@ impl HudAtlas {
|
||||
for folder in folders {
|
||||
walk(&textures.join(folder), &mut files);
|
||||
}
|
||||
// Content mods' equipment textures, which their equipment assets name as
|
||||
// `<ns>:<path>`, and their mob effects' icons, `<ns>:mob_effect/<path>`.
|
||||
// Content mods' entity textures: their equipment assets name them as
|
||||
// `<ns>:<path>`, and native mods draw their block entities with them
|
||||
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
|
||||
// icons, `<ns>:mob_effect/<path>`.
|
||||
let namespaces = horizon_items::content_namespaces::namespaces(root);
|
||||
for (folder, textures) in [("entity", "entity/equipment"), ("mob_effect", "mob_effect")] {
|
||||
for folder in ["entity", "mob_effect"] {
|
||||
if folders.contains(&folder) {
|
||||
for namespace in &namespaces {
|
||||
walk(
|
||||
&root.join(format!("assets/{namespace}/textures/{textures}")),
|
||||
&root.join(format!("assets/{namespace}/textures/{folder}")),
|
||||
&mut files,
|
||||
);
|
||||
}
|
||||
@@ -1604,6 +1606,9 @@ pub struct HudRenderer {
|
||||
atlas_view: wgpu::TextureView,
|
||||
/// The resource pack's text shader, when it has one that works.
|
||||
pack_text: Option<pack_text::PackText>,
|
||||
/// Target pixels per window pixel: above 1 for a screenshot drawn larger than the
|
||||
/// window (`Renderer::screenshot_quality`).
|
||||
pub zoom: f32,
|
||||
}
|
||||
impl HudRenderer {
|
||||
pub fn new(
|
||||
@@ -1833,6 +1838,7 @@ impl HudRenderer {
|
||||
format,
|
||||
atlas_view: view.clone(),
|
||||
pack_text: None,
|
||||
zoom: 1.0,
|
||||
})
|
||||
}
|
||||
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
|
||||
@@ -1968,14 +1974,14 @@ impl HudRenderer {
|
||||
[
|
||||
size[0] as f32,
|
||||
size[1] as f32,
|
||||
batch.scale,
|
||||
batch.scale * self.zoom,
|
||||
batch.glint_alpha,
|
||||
],
|
||||
batch.end_portal,
|
||||
)),
|
||||
);
|
||||
if let Some(pack_text) = &self.pack_text {
|
||||
pack_text.prepare(uploads, encoder, batch, size);
|
||||
pack_text.prepare(uploads, encoder, batch, size, batch.scale * self.zoom);
|
||||
}
|
||||
}
|
||||
/// Draw the batch `prepare` staged.
|
||||
@@ -2048,7 +2054,8 @@ impl HudRenderer {
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.buffer.slice(..));
|
||||
if let Some([x, y, w, h]) = clip {
|
||||
if let Some(clip) = clip {
|
||||
let [x, y, w, h] = clip.map(|v| (v as f32 * self.zoom) as u32);
|
||||
let (x, y) = (x.min(size[0]), y.min(size[1]));
|
||||
let (w, h) = (w.min(size[0] - x), h.min(size[1] - y));
|
||||
if w == 0 || h == 0 {
|
||||
@@ -2186,7 +2193,7 @@ mod tests {
|
||||
atlas.pixels[at..at + 4].try_into().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn content_mod_equipment_textures_pack_under_their_namespace() {
|
||||
fn content_mod_entity_textures_pack_under_their_namespace() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
|
||||
@@ -2206,10 +2213,17 @@ mod tests {
|
||||
br#"["gems"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A block entity's texture, for a native mod's renderer.
|
||||
let machine = root.join("assets/gems/textures/entity/machine");
|
||||
fs::create_dir_all(&machine).unwrap();
|
||||
write_strip(&machine.join("cog.png"), 32, 32, &[[0, 0, 200, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
|
||||
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
|
||||
let rect = atlas.sprites["gems:entity/machine/cog"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (32, 32));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
|
||||
// Only entity textures: the mods' others reach the atlas through atlas definitions.
|
||||
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
@@ -216,8 +216,9 @@ impl PackText {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
batch: &HudBatch,
|
||||
size: [u32; 2],
|
||||
scale: f32,
|
||||
) {
|
||||
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size));
|
||||
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size, scale));
|
||||
}
|
||||
|
||||
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
|
||||
@@ -233,7 +234,8 @@ impl PackText {
|
||||
/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI
|
||||
/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view
|
||||
/// moving 11000 back, colour modulator white) and an empty `Fog`.
|
||||
fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
|
||||
/// `scale`: target pixels per GUI pixel (`batch.scale`, times `HudRenderer::zoom`).
|
||||
fn uniforms(batch: &HudBatch, size: [u32; 2], scale: f32) -> Vec<u8> {
|
||||
let mut data = vec![0f32; 256];
|
||||
let globals = 0;
|
||||
data[globals + 3] = batch.glint_alpha;
|
||||
@@ -241,7 +243,7 @@ fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
|
||||
data[globals + 8] = size[0] as f32;
|
||||
data[globals + 9] = size[1] as f32;
|
||||
// Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns.
|
||||
let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale);
|
||||
let (w, h) = (size[0] as f32 / scale, size[1] as f32 / scale);
|
||||
let (near, far) = (1000.0f32, 11000.0f32);
|
||||
let projection = 64;
|
||||
data[projection] = 2.0 / w;
|
||||
@@ -287,7 +289,8 @@ mod tests {
|
||||
fn the_gui_projection_maps_gui_pixels_to_the_screen() {
|
||||
let mut batch = HudBatch::new([1920, 1080]);
|
||||
batch.scale = 4.0;
|
||||
let data: Vec<f32> = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec();
|
||||
let data: Vec<f32> =
|
||||
bytemuck::cast_slice(&uniforms(&batch, [1920, 1080], batch.scale)).to_vec();
|
||||
let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4];
|
||||
let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] {
|
||||
std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum())
|
||||
|
||||
@@ -385,9 +385,40 @@ pub fn draw(
|
||||
}
|
||||
}
|
||||
|
||||
/// `LoadingOverlay.MOJANG_STUDIOS_LOGO_LOCATION`, from the player's own resources.
|
||||
pub const MOJANG_STUDIOS_LOGO: &str = "gui/title/mojangstudios";
|
||||
|
||||
/// `LoadingOverlay.extractRenderState`'s logo on a GUI `w` x `h`: the left
|
||||
/// half's corner, then each half's width and height.
|
||||
pub fn logo_layout(w: f32, h: f32) -> (i32, i32, i32, i32) {
|
||||
let (w, h) = (f64::from(w), f64::from(h));
|
||||
let (content_x, logo_y) = ((w * 0.5) as i32, (h * 0.5) as i32);
|
||||
let logo_height = (w * 0.75).min(h) * 0.25;
|
||||
let half_height = (logo_height * 0.5) as i32;
|
||||
let half_width = (logo_height * 4.0 * 0.5) as i32;
|
||||
(
|
||||
content_x - half_width,
|
||||
logo_y - half_height,
|
||||
half_width,
|
||||
logo_height as i32,
|
||||
)
|
||||
}
|
||||
|
||||
/// `GuiGraphicsExtractor.fill(x0, y0, x1, y1, color)`: the corners in either order.
|
||||
fn fill_corners(batch: &mut HudBatch, atlas: &HudAtlas, [x0, y0, x1, y1]: [i32; 4], color: u32) {
|
||||
let (left, right) = (x0.min(x1), x0.max(x1));
|
||||
let (top, bottom) = (y0.min(y1), y0.max(y1));
|
||||
let (w, h) = ((right - left) as f32, (bottom - top) as f32);
|
||||
batch.fill(atlas, left as f32, top as f32, w, h, color);
|
||||
}
|
||||
|
||||
/// `LoadingOverlay.extractRenderState`: fills the frame with `BRAND_BACKGROUND`
|
||||
/// (Mojang red, or black when `darkMojangStudiosBackground` is set) and draws the
|
||||
/// progress line and bar.
|
||||
/// (Mojang red, or black when `darkMojangStudiosBackground` is set), draws the
|
||||
/// Mojang Studios logo as its two halves side by side, and the progress bar
|
||||
/// at 83.25% of the height (`extractProgressBar`, unfaded), with the stage's
|
||||
/// line above it. The logo's `MOJANG_LOGO` pipeline adds it (`SRC_ALPHA, ONE`):
|
||||
/// its texels are white, which alpha blending matches but for the red channel
|
||||
/// of the logo's soft edges, a 16/255 difference at most.
|
||||
pub fn draw_loading_overlay(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
@@ -395,15 +426,58 @@ pub fn draw_loading_overlay(
|
||||
progress: f32,
|
||||
dark: bool,
|
||||
) {
|
||||
batch.fill(
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, brand_background(dark));
|
||||
let (x, y, half_width, logo_height) = logo_layout(w, h);
|
||||
if let Some(r) = atlas.sprites.get(MOJANG_STUDIOS_LOGO).copied() {
|
||||
// blit(.., u, v, width, height, 120, 60, 120, 120): the left half
|
||||
// samples the texture's top half from u = -0.0625, the right half its
|
||||
// bottom half from u = 0.0625 (clamped, as the logo's sampler is).
|
||||
let at = |u: f32, v: f32| {
|
||||
let (u, v) = ((u / 120.0).clamp(0.0, 1.0), v / 120.0);
|
||||
(r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v)
|
||||
};
|
||||
for (i, (u, v)) in [(-0.0625, 0.0), (0.0625, 60.0)].into_iter().enumerate() {
|
||||
let (u0, v0) = at(u, v);
|
||||
let (u1, v1) = at(u + 120.0, v + 60.0);
|
||||
let half = crate::hud::Rect {
|
||||
u0,
|
||||
v0,
|
||||
u1,
|
||||
v1,
|
||||
..r
|
||||
};
|
||||
let left = x + i as i32 * half_width;
|
||||
let (lw, lh) = (half_width as f32, logo_height as f32);
|
||||
batch.rect_uv(left as f32, y as f32, lw, lh, half, u32::MAX);
|
||||
}
|
||||
}
|
||||
let bar_y = (f64::from(h) * 0.8325) as i32;
|
||||
let x_center = (w as i32) / 2;
|
||||
let [x0, y0, x1, y1] = [
|
||||
x_center - half_width,
|
||||
bar_y - 5,
|
||||
x_center + half_width,
|
||||
bar_y + 5,
|
||||
];
|
||||
let filled = (((x1 - x0) - 2) as f32 * progress.clamp(0.0, 1.0)).ceil() as i32;
|
||||
let white = u32::MAX;
|
||||
fill_corners(batch, atlas, [x0 + 2, y0 + 2, x0 + filled, y1 - 2], white);
|
||||
fill_corners(batch, atlas, [x0 + 1, y0, x1 - 1, y0 + 1], white);
|
||||
fill_corners(batch, atlas, [x0 + 1, y1, x1 - 1, y1 - 1], white);
|
||||
fill_corners(batch, atlas, [x0, y0, x0 + 1, y1], white);
|
||||
fill_corners(batch, atlas, [x1, y0, x1 - 1, y1], white);
|
||||
let text = Text::literal(line);
|
||||
let tw = atlas.font.width_text(&text) as i32;
|
||||
let text_top = (y0 - 9 - 3) as f32;
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
batch.width,
|
||||
batch.height,
|
||||
brand_background(dark),
|
||||
&text,
|
||||
(x_center - tw / 2) as f32,
|
||||
text_top,
|
||||
white,
|
||||
true,
|
||||
);
|
||||
draw_progress(batch, atlas, line, progress);
|
||||
}
|
||||
|
||||
/// The startup loading screen: the stage's line and a progress bar, centred
|
||||
@@ -572,6 +646,14 @@ pub fn draw_file_fixer(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_mojang_logo_is_a_quarter_of_the_shorter_side_and_four_times_as_wide() {
|
||||
// 320x240: min(240, 240) / 4 = 60 high, two halves of 120.
|
||||
assert_eq!(logo_layout(320.0, 240.0), (40, 90, 120, 60));
|
||||
// A tall frame sizes the logo by three quarters of its width.
|
||||
assert_eq!(logo_layout(400.0, 500.0), (50, 213, 150, 75));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_weighs_the_prepared_server_and_the_player_chunks() {
|
||||
assert_eq!(progress(0), 10.0 / 59.0);
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
//!
|
||||
//! The buckets lie one after another in the mesh, in an order chosen so that
|
||||
//! the buckets an eye can see are usually one or two runs of quads: one draw
|
||||
//! each. Inside a bucket quads keep their model order, so coplanar quads
|
||||
//! facing one way (an overlay on its base) still draw in that order.
|
||||
use super::TerrainVertex;
|
||||
//! each. Coplanar quads facing one way keep their model order, so an overlay
|
||||
//! on its base still draws in that order. Solid cutout quads are otherwise
|
||||
//! ordered from the outside toward the section interior to reduce overdraw.
|
||||
use super::{OPAQUE_LAYER, TerrainVertex};
|
||||
|
||||
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
|
||||
/// above (+Y, the commonest case), each pair of horizontal sides with a
|
||||
@@ -138,11 +139,14 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
|
||||
OTHER
|
||||
}
|
||||
|
||||
/// Reorders `solid`'s quads by bucket, keeping their order inside each.
|
||||
/// Reorders `solid`'s quads by bucket. Solid cutout quads are also ordered
|
||||
/// front to back within each axis-facing bucket; stable sorting preserves the
|
||||
/// mesher's order for coplanar overlays.
|
||||
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
let (quads, rest) = solid.as_chunks::<4>();
|
||||
debug_assert!(rest.is_empty(), "solid vertices come in quads");
|
||||
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
|
||||
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
|
||||
let mut counts = [0u32; 7];
|
||||
let mut planes = [0f32; 7];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
@@ -163,7 +167,7 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
total += count;
|
||||
*end = total;
|
||||
}
|
||||
if counts.iter().filter(|&&c| c > 0).count() > 1 {
|
||||
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
|
||||
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
|
||||
let mut sorted = vec![quads[0]; quads.len()];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
@@ -171,6 +175,25 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
sorted[*slot as usize] = *quad;
|
||||
*slot += 1;
|
||||
}
|
||||
if has_cutout {
|
||||
let mut start = 0;
|
||||
for (i, bucket) in BUCKETS.iter().enumerate() {
|
||||
let end = ends[i] as usize;
|
||||
let slice = &mut sorted[start..end];
|
||||
if let Bucket::Side(axis, positive) = *bucket {
|
||||
slice.sort_by(|a, b| {
|
||||
let a = a[0].pos[axis];
|
||||
let b = b[0].pos[axis];
|
||||
if positive {
|
||||
b.total_cmp(&a)
|
||||
} else {
|
||||
a.total_cmp(&b)
|
||||
}
|
||||
});
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
*solid = sorted.into_flattened();
|
||||
}
|
||||
Sides { ends, planes }
|
||||
@@ -368,4 +391,46 @@ mod tests {
|
||||
before.iter().map(|v| v.layer).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_to_back_sort_orders_positive_faces_and_keeps_ties_stable() {
|
||||
let mut solid: Vec<_> = [(1.0, 10), (3.0, 30), (3.0, 31)]
|
||||
.into_iter()
|
||||
.flat_map(|(x, layer)| {
|
||||
quad(
|
||||
[[x, 0.0, 0.0], [x, 1.0, 0.0], [x, 1.0, 1.0], [x, 0.0, 1.0]],
|
||||
layer,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
sort(&mut solid);
|
||||
let layers: Vec<u16> = solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| q[0].layer)
|
||||
.collect();
|
||||
assert_eq!(layers, [30, 31, 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_to_back_sort_orders_negative_faces_toward_the_eye() {
|
||||
let mut solid: Vec<_> = [(3.0, 30), (1.0, 10), (2.0, 20)]
|
||||
.into_iter()
|
||||
.flat_map(|(x, layer)| {
|
||||
quad(
|
||||
[[x, 0.0, 0.0], [x, 0.0, 1.0], [x, 1.0, 1.0], [x, 1.0, 0.0]],
|
||||
layer,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
sort(&mut solid);
|
||||
let layers: Vec<u16> = solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| q[0].layer)
|
||||
.collect();
|
||||
assert_eq!(layers, [10, 20, 30]);
|
||||
}
|
||||
}
|
||||
@@ -1236,6 +1236,8 @@ 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 picked `shaders/` pack's options (`shader_options`), one of its screens at a time.
|
||||
ShaderOptions,
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
@@ -1282,6 +1284,12 @@ enum Act {
|
||||
OpenShaderPackFolder,
|
||||
/// Open the picked pack's Shader Options page.
|
||||
ShaderOptions,
|
||||
/// Open the options page of the Horizon pack `s.shader_packs[.0]`.
|
||||
NativePackOptions(usize),
|
||||
/// Option `.1` of that pack: its next value.
|
||||
NativePackOption(usize, usize),
|
||||
/// Every option of that pack back to its default.
|
||||
NativePackReset(usize),
|
||||
/// Shader Options: the next value of an option, a screen of the pack, the next profile,
|
||||
/// every option back to its default.
|
||||
ShaderOption(String),
|
||||
@@ -1411,6 +1419,9 @@ pub enum Event {
|
||||
OpenShaderOptions,
|
||||
/// The Shader Options page closed with changes: save them and load the pack again.
|
||||
ShaderOptions,
|
||||
/// The options of `shader_packs[.0]`, a Horizon pack, changed: save them (the pack reloads
|
||||
/// with them).
|
||||
NativePackOptions(usize),
|
||||
/// Native mod `.0`'s page opened: the game asks the mod for its labels
|
||||
/// again (they were made with the list, maybe in another language).
|
||||
NativeSettings(usize),
|
||||
@@ -1627,6 +1638,27 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
}
|
||||
/// 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> {
|
||||
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 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()));
|
||||
}
|
||||
rows
|
||||
}
|
||||
fn shader_option_rows(s: &Settings) -> Vec<Row> {
|
||||
let Some(options) = &s.shader_options else {
|
||||
return vec![Row::Header("horizon.shader_options.loading")];
|
||||
@@ -1712,6 +1744,14 @@ 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 WKind::Button { act, .. } = &wd.kind else {
|
||||
let WKind::Slider { opt, .. } = &wd.kind else {
|
||||
return None;
|
||||
@@ -2073,7 +2113,7 @@ impl OptionsUi {
|
||||
Page::Mods => "horizon.pages.mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::ShaderOptions => "horizon.pages.shader_options",
|
||||
Page::ShaderOptions | Page::NativePackOptions(_) => "horizon.pages.shader_options",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
|
||||
"options.videoTitle"
|
||||
}
|
||||
@@ -2502,13 +2542,19 @@ impl OptionsUi {
|
||||
if s.shader_packs.is_empty() {
|
||||
return vec![Row::Header("horizon.shader_packs.empty")];
|
||||
}
|
||||
let options = s.shader_packs.iter().any(|p| {
|
||||
p.file == s.horizon.shader_pack
|
||||
&& p.kind == crate::shader_pack_list::Kind::Classic
|
||||
});
|
||||
let options = options.then(|| {
|
||||
let picked = s
|
||||
.shader_packs
|
||||
.iter()
|
||||
.position(|p| p.file == s.horizon.shader_pack);
|
||||
let options = picked.and_then(|i| {
|
||||
let pack = &s.shader_packs[i];
|
||||
let act = match pack.kind {
|
||||
crate::shader_pack_list::Kind::Classic => Act::ShaderOptions,
|
||||
_ if pack.options.is_empty() => return None,
|
||||
crate::shader_pack_list::Kind::Horizon => Act::NativePackOptions(i),
|
||||
};
|
||||
let label = tr("horizon.shader_options.open");
|
||||
Row::Buttons(button(0.0, 0.0, 310.0, label, Act::ShaderOptions), None)
|
||||
Some(Row::Buttons(button(0.0, 0.0, 310.0, label, act), None))
|
||||
});
|
||||
options
|
||||
.into_iter()
|
||||
@@ -2526,6 +2572,7 @@ impl OptionsUi {
|
||||
.collect()
|
||||
}
|
||||
Page::ShaderOptions => shader_option_rows(s),
|
||||
Page::NativePackOptions(i) => native_pack_option_rows(s, i),
|
||||
_ => {
|
||||
let _ = s;
|
||||
Vec::new()
|
||||
@@ -3062,6 +3109,7 @@ impl OptionsUi {
|
||||
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
|
||||
Page::ShaderOptions => Some(("controls.reset", Act::ShaderReset)),
|
||||
Page::NativePackOptions(i) => Some(("controls.reset", Act::NativePackReset(i))),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((label, act)) = folder {
|
||||
@@ -3722,6 +3770,25 @@ impl OptionsUi {
|
||||
options.cycle(&name, true);
|
||||
events.push(Event::ButtonClick);
|
||||
}
|
||||
let native = widgets.iter().find_map(|wd| match wd.kind {
|
||||
WKind::Button {
|
||||
active: true,
|
||||
act: Act::NativePackOption(pack, option),
|
||||
..
|
||||
} if ui::hovered((x, y), wd.x, wd.y, wd.w, wd.h)
|
||||
&& !(wd.list && (y < top || y >= bottom)) =>
|
||||
{
|
||||
Some((pack, option))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some((pack, option)) = native
|
||||
&& let Some(entry) = s.shader_packs.get_mut(pack)
|
||||
&& entry.step_option(option, true, false)
|
||||
{
|
||||
events.push(Event::ButtonClick);
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
if ui::scrollbar_thumb(top, bottom, content, self.scroll).is_some()
|
||||
@@ -3838,6 +3905,22 @@ impl OptionsUi {
|
||||
options.reset();
|
||||
}
|
||||
}
|
||||
Act::NativePackOptions(i) => {
|
||||
self.push(Page::NativePackOptions(i));
|
||||
}
|
||||
Act::NativePackOption(pack, option) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack)
|
||||
&& entry.step_option(option, false, self.shift)
|
||||
{
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
}
|
||||
Act::NativePackReset(pack) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack) {
|
||||
entry.reset_options();
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::None => {}
|
||||
@@ -4745,6 +4828,22 @@ impl OptionsUi {
|
||||
options.reset();
|
||||
}
|
||||
}
|
||||
Act::NativePackOptions(i) => {
|
||||
self.push(Page::NativePackOptions(i));
|
||||
}
|
||||
Act::NativePackOption(pack, option) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack)
|
||||
&& entry.step_option(option, false, shift)
|
||||
{
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
}
|
||||
Act::NativePackReset(pack) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack) {
|
||||
entry.reset_options();
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::None => {}
|
||||
@@ -5032,6 +5131,54 @@ mod tests {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_horizon_packs_options_step_both_ways_from_the_picked_packs_button() {
|
||||
let atlas = atlas();
|
||||
let size = (960.0, 540.0);
|
||||
let mut s = Settings::default();
|
||||
s.horizon.shader_pack = "p".into();
|
||||
s.shader_packs = vec![crate::shader_pack_list::Entry {
|
||||
file: "p".into(),
|
||||
title: "P".into(),
|
||||
kind: crate::shader_pack_list::Kind::Horizon,
|
||||
options: vec![horizon_shader_api::PackOption {
|
||||
id: "a".into(),
|
||||
name: "A".into(),
|
||||
description: String::new(),
|
||||
kind: horizon_shader_api::OptionKind::Float {
|
||||
default: 1.0,
|
||||
min: 0.0,
|
||||
max: 2.0,
|
||||
step: 0.5,
|
||||
},
|
||||
}],
|
||||
values: vec![1.0],
|
||||
}];
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::ShaderPacks);
|
||||
let find = |ui: &mut OptionsUi, s: &Settings, want: &Act| {
|
||||
let (widgets, ..) = ui.layout(s, size.0, size.1);
|
||||
let w = widgets
|
||||
.into_iter()
|
||||
.find(|w| matches!(&w.kind, WKind::Button { act, .. } if act == want))
|
||||
.expect("button");
|
||||
(w.x + w.w / 2.0, w.y + w.h / 2.0)
|
||||
};
|
||||
let (x, y) = find(&mut ui, &s, &Act::NativePackOptions(0));
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
assert_eq!(ui.page(), Page::NativePackOptions(0));
|
||||
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
|
||||
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
assert!(events.contains(&Event::NativePackOptions(0)));
|
||||
assert_eq!(s.shader_packs[0].values, [1.5]);
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
|
||||
assert_eq!(s.shader_packs[0].values, [0.5]);
|
||||
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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_reload_error_blocks_options_until_dismissed() {
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! Settings > Shader Packs lists them: Horizon's own (docs/shaders.md) and those in the
|
||||
//! `shaders/` format, side by side.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use horizon_shader_api::{OptionKind, PackOption};
|
||||
|
||||
pub const DIR: &str = "shaderpacks";
|
||||
|
||||
@@ -14,13 +16,46 @@ pub enum Kind {
|
||||
Classic,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Entry {
|
||||
/// The folder's or file's name in `shaderpacks`: what the pick saves.
|
||||
pub file: String,
|
||||
/// The pack's own name for Horizon's packs, else `file`.
|
||||
pub title: String,
|
||||
pub kind: Kind,
|
||||
/// A Horizon pack's `[[options]]`, and the value of each the player set (`values_path`).
|
||||
pub options: Vec<PackOption>,
|
||||
pub values: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// Saves the player's option values, for the pack in the folder `dir`.
|
||||
pub fn save_values(&self, dir: &Path) {
|
||||
let path = dir.join(&self.file);
|
||||
if let Err(e) = save_values(&path, &self.options, &self.values) {
|
||||
log::warn!("shader pack options of {}: {e}", self.file);
|
||||
}
|
||||
}
|
||||
|
||||
/// The option `i` one value on (`back`: one before), or at its default (`reset`). Whether
|
||||
/// the pack has that option.
|
||||
pub fn step_option(&mut self, i: usize, back: bool, reset: bool) -> bool {
|
||||
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
|
||||
return false;
|
||||
};
|
||||
*v = if reset {
|
||||
o.kind.default_constant()
|
||||
} else {
|
||||
step_value(&o.kind, *v, back)
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
/// Every option back to its default.
|
||||
pub fn reset_options(&mut self) {
|
||||
let defaults = self.options.iter().map(|o| o.kind.default_constant());
|
||||
self.values = defaults.collect();
|
||||
}
|
||||
}
|
||||
|
||||
/// The packs in `dir`, Horizon's first, each kind by title. A missing folder has none.
|
||||
@@ -53,11 +88,15 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
.ok()
|
||||
.map(|p| p.manifest)
|
||||
};
|
||||
let title = manifest.map_or_else(|| file.clone(), |m| m.name);
|
||||
let (title, options) =
|
||||
manifest.map_or_else(|| (file.clone(), Vec::new()), |m| (m.name, m.options));
|
||||
let values = load_values(path, &options);
|
||||
return Some(Entry {
|
||||
file,
|
||||
title,
|
||||
kind: Kind::Horizon,
|
||||
options,
|
||||
values,
|
||||
});
|
||||
}
|
||||
let zip = path.is_file()
|
||||
@@ -68,9 +107,127 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
title: file.clone(),
|
||||
file,
|
||||
kind: Kind::Classic,
|
||||
options: Vec::new(),
|
||||
values: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
|
||||
/// one `id=value` a line.
|
||||
pub fn values_path(pack: &Path) -> PathBuf {
|
||||
let mut path = pack.as_os_str().to_owned();
|
||||
path.push(".txt");
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
/// The value of each of `options` saved for the pack at `pack`, else its default; a value
|
||||
/// out of the option's range is brought into it.
|
||||
pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
|
||||
let text = std::fs::read_to_string(values_path(pack)).unwrap_or_default();
|
||||
let saved: std::collections::HashMap<&str, f64> = text
|
||||
.lines()
|
||||
.filter_map(|l| l.split_once('='))
|
||||
.filter_map(|(k, v)| Some((k.trim(), v.trim().parse().ok()?)))
|
||||
.collect();
|
||||
options
|
||||
.iter()
|
||||
.map(|o| {
|
||||
saved
|
||||
.get(o.id.as_str())
|
||||
.map_or_else(|| o.kind.default_constant(), |&v| clamp(&o.kind, v))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Saves `values` of `options`; values at their defaults are left out, and with none left
|
||||
/// the file goes.
|
||||
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
|
||||
let text: String = options
|
||||
.iter()
|
||||
.zip(values)
|
||||
.filter(|(o, v)| o.kind.default_constant() != **v)
|
||||
.map(|(o, v)| format!("{}={v}\n", o.id))
|
||||
.collect();
|
||||
let path = values_path(pack);
|
||||
if text.is_empty() {
|
||||
match std::fs::remove_file(&path) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
|
||||
_ => Ok(()),
|
||||
}
|
||||
} else {
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp(kind: &OptionKind, v: f64) -> f64 {
|
||||
match kind {
|
||||
OptionKind::Bool { .. } => f64::from(u8::from(v != 0.0)),
|
||||
OptionKind::Float { min, max, .. } => v.clamp(f64::from(*min), f64::from(*max)),
|
||||
OptionKind::Int { min, max, .. } => v.round().clamp(f64::from(*min), f64::from(*max)),
|
||||
OptionKind::Choice { values, .. } => v.round().clamp(0.0, values.len() as f64 - 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The option's next value (`back`: the one before), wrapping around at the ends.
|
||||
fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
|
||||
let dir = if back { -1.0 } else { 1.0 };
|
||||
match kind {
|
||||
OptionKind::Bool { .. } => f64::from(u8::from(v == 0.0)),
|
||||
OptionKind::Float { min, max, step, .. } => {
|
||||
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
|
||||
if !back && v >= max - step * 0.01 {
|
||||
min
|
||||
} else if back && v <= min + step * 0.01 {
|
||||
max
|
||||
} else {
|
||||
// On the grid of steps from the minimum, so float error never piles up.
|
||||
let n = ((v - min) / step).round() + dir;
|
||||
(min + n * step).clamp(min, max)
|
||||
}
|
||||
}
|
||||
OptionKind::Int { min, max, .. } => {
|
||||
let (min, max) = (f64::from(*min), f64::from(*max));
|
||||
let n = v.round() + dir;
|
||||
if n > max {
|
||||
min
|
||||
} else if n < min {
|
||||
max
|
||||
} else {
|
||||
n
|
||||
}
|
||||
}
|
||||
OptionKind::Choice { values, .. } => {
|
||||
let n = values.len() as f64;
|
||||
(v.round() + dir).rem_euclid(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value as the settings page shows it.
|
||||
pub fn show_value(kind: &OptionKind, v: f64) -> String {
|
||||
match kind {
|
||||
OptionKind::Bool { .. } => crate::lang::get(if v != 0.0 {
|
||||
"options.on"
|
||||
} else {
|
||||
"options.off"
|
||||
}),
|
||||
OptionKind::Float { step, .. } => {
|
||||
let digits = if *step >= 1.0 {
|
||||
0
|
||||
} else if *step >= 0.1 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
format!("{v:.digits$}")
|
||||
}
|
||||
OptionKind::Int { .. } => format!("{v:.0}"),
|
||||
OptionKind::Choice { values, .. } => {
|
||||
values.get(v.round() as usize).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -106,4 +263,59 @@ mod tests {
|
||||
assert!(scan(&d.join("missing")).is_empty());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
fn option(id: &str, kind: OptionKind) -> PackOption {
|
||||
PackOption {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
description: String::new(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_step_wrap_and_round_trip() {
|
||||
let float = OptionKind::Float {
|
||||
default: 1.0,
|
||||
min: 0.0,
|
||||
max: 2.0,
|
||||
step: 0.5,
|
||||
};
|
||||
assert_eq!(step_value(&float, 1.0, false), 1.5);
|
||||
assert_eq!(step_value(&float, 2.0, false), 0.0);
|
||||
assert_eq!(step_value(&float, 0.0, true), 2.0);
|
||||
assert_eq!(show_value(&float, 1.5), "1.5");
|
||||
let choice = OptionKind::Choice {
|
||||
values: vec!["a".into(), "b".into(), "c".into()],
|
||||
default: 0,
|
||||
};
|
||||
assert_eq!(step_value(&choice, 2.0, false), 0.0);
|
||||
assert_eq!(step_value(&choice, 0.0, true), 2.0);
|
||||
assert_eq!(show_value(&choice, 1.0), "b");
|
||||
let int = OptionKind::Int {
|
||||
default: 0,
|
||||
min: -1,
|
||||
max: 1,
|
||||
};
|
||||
assert_eq!(step_value(&int, 1.0, false), -1.0);
|
||||
let on = OptionKind::Bool { default: true };
|
||||
assert_eq!(step_value(&on, 1.0, false), 0.0);
|
||||
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-shader-values-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pack = dir.join("p.hshader");
|
||||
let options = [option("a", float), option("b", choice)];
|
||||
// Only what differs from the defaults is written; out of range comes back clamped.
|
||||
save_values(&pack, &options, &[1.0, 2.0]).unwrap();
|
||||
let text = std::fs::read_to_string(values_path(&pack)).unwrap();
|
||||
assert_eq!(text, "b=2\n");
|
||||
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
|
||||
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
|
||||
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
|
||||
assert!(!values_path(&pack).exists());
|
||||
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,30 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
|
||||
&[4, 4, 4, 4],
|
||||
];
|
||||
|
||||
/// Where a window is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Placement {
|
||||
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
|
||||
/// where the system opens it).
|
||||
Free(Option<([i32; 2], [u32; 2])>),
|
||||
/// Borderless over the monitor of this name.
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
|
||||
/// centred, each next one a step further, as systems cascade new windows (back to the centre
|
||||
/// after six), so new windows never open exactly on one another.
|
||||
pub fn cascade(number: u8) -> f64 {
|
||||
f64::from(number.saturating_sub(2) % 6) * 32.0
|
||||
}
|
||||
|
||||
/// A window: its number (1 is the game's own) and where it is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Window {
|
||||
pub number: u8,
|
||||
pub placement: Placement,
|
||||
}
|
||||
|
||||
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
|
||||
/// others, P1 first.
|
||||
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
|
||||
|
||||
@@ -34,9 +34,16 @@ fn parse_nine_slice(v: &serde_json::Value) -> Option<NineSlice> {
|
||||
let g = |k: &str| border[k].as_u64().unwrap_or(0) as u32;
|
||||
[g("left"), g("top"), g("right"), g("bottom")]
|
||||
};
|
||||
let (width, height) = (size("width")?, size("height")?);
|
||||
// `NineSlice.validate`: borders that meet leave no centre slice, so the
|
||||
// sprite has no nine-slice metadata (vanilla draws it as a stretch).
|
||||
let [l, t, r, b] = border;
|
||||
if l.saturating_add(r) >= width || t.saturating_add(b) >= height {
|
||||
return None;
|
||||
}
|
||||
Some(NineSlice {
|
||||
width: size("width")?,
|
||||
height: size("height")?,
|
||||
width,
|
||||
height,
|
||||
border,
|
||||
stretch_inner: s["stretch_inner"].as_bool().unwrap_or(false),
|
||||
})
|
||||
@@ -1458,6 +1465,17 @@ mod tests {
|
||||
assert_quad(q[1], ([32.0, 5.0, 40.0, 7.0], [0.0, 0.0, 0.25, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn a_border_that_leaves_no_centre_slice_is_no_metadata() {
|
||||
// NineSlice.validate rejects 12 + 12 >= 20, so the sprite has none.
|
||||
let json = serde_json::json!({"gui": {"scaling": {
|
||||
"type": "nine_slice", "width": 20, "height": 20, "border": 12}}});
|
||||
assert!(parse_nine_slice(&json).is_none());
|
||||
// 9 + 9 < 20 still leaves a centre slice.
|
||||
let json = serde_json::json!({"gui": {"scaling": {
|
||||
"type": "nine_slice", "width": 20, "height": 20, "border": 9}}});
|
||||
assert_eq!(parse_nine_slice(&json).unwrap().border, [9; 4]);
|
||||
}
|
||||
#[test]
|
||||
fn a_high_resolution_button_slices_in_its_gui_units() {
|
||||
// Faithful 32x's widget/button: 400x40 texels, gui.scaling 200x20.
|
||||
let n = faithful(
|
||||
|
||||
@@ -98,6 +98,25 @@ thread_local! {
|
||||
static TARGETS: RefCell<(Vec<Target>, Vec<Target>, u64)> = const { RefCell::new((Vec::new(), Vec::new(), 0)) };
|
||||
}
|
||||
|
||||
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
|
||||
/// runs on this thread ([`take`] and [`put`] around each turn).
|
||||
#[derive(Default)]
|
||||
pub struct Saved(
|
||||
(Vec<Widget>, Vec<Widget>, u64),
|
||||
(Vec<Target>, Vec<Target>, u64),
|
||||
);
|
||||
|
||||
/// This thread's buttons and targets, leaving none.
|
||||
pub fn take() -> Saved {
|
||||
Saved(WIDGETS.take(), TARGETS.take())
|
||||
}
|
||||
|
||||
/// Bring back what [`take`] kept.
|
||||
pub fn put(saved: Saved) {
|
||||
WIDGETS.set(saved.0);
|
||||
TARGETS.set(saved.1);
|
||||
}
|
||||
|
||||
/// An interactive rectangle (GUI pixels) a controller can focus and click.
|
||||
pub fn target(rect: [f32; 4]) {
|
||||
target_as(rect, Kind::Button);
|
||||
|
||||
@@ -83,6 +83,13 @@ open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# on the PATH.
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
|
||||
# `above_menu_bar`); all already in the tree through winit and wgpu.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
|
||||
@@ -205,5 +205,9 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
|
||||
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
|
||||
"horizon.loading.opening_world": "Opening world",
|
||||
"horizon.loading.cancelling_world": "Cancelling world"
|
||||
"horizon.loading.cancelling_world": "Cancelling world",
|
||||
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
|
||||
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay..."
|
||||
}
|
||||
@@ -199,5 +199,9 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
|
||||
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
|
||||
"horizon.loading.opening_world": "Abriendo el mundo",
|
||||
"horizon.loading.cancelling_world": "Cancelando"
|
||||
"horizon.loading.cancelling_world": "Cancelando",
|
||||
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
|
||||
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…"
|
||||
}
|
||||
@@ -14,5 +14,9 @@
|
||||
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
"horizon.loading.opening_world": "Otwieranie świata",
|
||||
"horizon.loading.cancelling_world": "Anulowanie"
|
||||
"horizon.loading.cancelling_world": "Anulowanie",
|
||||
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
|
||||
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…"
|
||||
}
|
||||
@@ -198,5 +198,9 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.loading.opening_world": "Открытие мира",
|
||||
"horizon.loading.cancelling_world": "Отмена"
|
||||
"horizon.loading.cancelling_world": "Отмена",
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…"
|
||||
}
|
||||
@@ -14,5 +14,9 @@
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
"horizon.loading.opening_world": "Відкриття світу",
|
||||
"horizon.loading.cancelling_world": "Скасування"
|
||||
"horizon.loading.cancelling_world": "Скасування",
|
||||
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
|
||||
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
|
||||
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…"
|
||||
}
|
||||
@@ -199,5 +199,9 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
|
||||
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
|
||||
"horizon.loading.opening_world": "正在打开世界",
|
||||
"horizon.loading.cancelling_world": "正在取消"
|
||||
"horizon.loading.cancelling_world": "正在取消",
|
||||
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
|
||||
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
|
||||
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
|
||||
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…"
|
||||
}
|
||||
@@ -5,6 +5,15 @@ fn main() {
|
||||
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
|
||||
println!("cargo:rerun-if-changed={ico}");
|
||||
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
|
||||
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
|
||||
Ok("msvc") => "/STACK:8388608",
|
||||
_ => "-Wl,--stack,8388608",
|
||||
};
|
||||
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let mut resource = winresource::WindowsResource::new();
|
||||
|
||||
@@ -291,6 +291,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for d in items {
|
||||
let _ = match d {
|
||||
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
|
||||
// The fixture's mobs keep no whole stacks.
|
||||
drops::Drop::Stack(s) => writeln!(out, "drop {} {}", s.item, s.count),
|
||||
drops::Drop::CarriedBlock(state) => {
|
||||
let stacks = enderman::carried_block_drops(
|
||||
&block_items,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use horizon_client::interaction_input::{
|
||||
Button, InteractionInput, InteractionWorld, TargetUseWorld, use_on_target,
|
||||
Button, InteractionInput, InteractionWorld, TargetUseWorld, release_on_screen_open,
|
||||
use_on_target,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::io::{BufRead, Write};
|
||||
@@ -103,14 +104,23 @@ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
passing: row["passing"].as_bool().unwrap_or(false),
|
||||
};
|
||||
let mut input = InteractionInput::default();
|
||||
for button in row["clicks"].as_array().unwrap() {
|
||||
input.press(match button.as_u64().unwrap() {
|
||||
0 => Button::Attack,
|
||||
1 => Button::Use,
|
||||
_ => Button::Pick,
|
||||
});
|
||||
let screen = row["screen"].as_bool().unwrap_or(false);
|
||||
if screen {
|
||||
// A keybind opened a screen: Gui.setScreen already ran
|
||||
// KeyMapping.releaseAll, so the same handleKeybinds call releases an
|
||||
// active use; with the screen open nothing else acts this tick.
|
||||
release_on_screen_open(&mut world);
|
||||
input.tick(&mut world, false, false);
|
||||
} else {
|
||||
for button in row["clicks"].as_array().unwrap() {
|
||||
input.press(match button.as_u64().unwrap() {
|
||||
0 => Button::Attack,
|
||||
1 => Button::Use,
|
||||
_ => Button::Pick,
|
||||
});
|
||||
}
|
||||
input.tick(&mut world, flag("attack_down"), flag("use_down"));
|
||||
}
|
||||
input.tick(&mut world, flag("attack_down"), flag("use_down"));
|
||||
writeln!(out, "== {i}")?;
|
||||
for event in world.events {
|
||||
writeln!(out, "{event}")?;
|
||||
|
||||
@@ -1071,6 +1071,15 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
}
|
||||
effects.advance(step);
|
||||
let name = &*self.scene.facts.get(state).name;
|
||||
crate::block_contact::emit(crate::block_contact::Event::Inside(
|
||||
crate::block_contact::Contact {
|
||||
entity: self.p.id as i32,
|
||||
kind: self.p.kind.id(),
|
||||
block: name,
|
||||
state,
|
||||
position: p,
|
||||
},
|
||||
));
|
||||
if matches!(name, "minecraft:fire" | "minecraft:soul_fire") {
|
||||
// BaseFireBlock.entityInside (its inFire hurt does nothing to arrows).
|
||||
effects.apply(Effect::ClearFreeze);
|
||||
|
||||
@@ -92,9 +92,7 @@ impl Banners {
|
||||
});
|
||||
}
|
||||
banners.no_item_required = read_tag(root, "no_item_required", 0);
|
||||
if let Some(raw) = fs::read(root.join("items.json")).ok().and_then(|b| {
|
||||
serde_json::from_slice::<std::collections::BTreeMap<String, Value>>(&b).ok()
|
||||
}) {
|
||||
if let Ok(raw) = horizon_world::item_defaults::read(root) {
|
||||
for (item, c) in raw {
|
||||
let id: Arc<str> = Arc::from(item.as_str());
|
||||
if let Some(dye) = c["minecraft:dye"].as_str() {
|
||||
|
||||
@@ -194,7 +194,8 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[flag("world", Ty::Str, "world folder to open or create"), flag("seed", Ty::Str, "seed for a new world"), flag("creative", Ty::Bool, "creative mode for a new world"),
|
||||
flag("version", Ty::Str, "an installed launcher version tag such as v2026.10.13"), flag("game", Ty::Str, "path to a horizon-client binary"),
|
||||
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)"),
|
||||
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions")],
|
||||
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions"),
|
||||
flag("headless", Ty::Bool, "no display, window or GPU: a player that plays through these commands only (no screenshots)")],
|
||||
mcp: true, local: true),
|
||||
cmd!("wait-for", Session, "wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND [--timeout S] [--since SEQ]", "wait until the game reaches a state, then reply",
|
||||
"screen takes the names state shows (playing, inventory, paused, dead, title...); widget an id from ui; loaded means a world is open, the player is in it and no chunk is pending; chat a new line containing TEXT; event a kind from events (death, respawn, item, screen...). chat and event count from --since SEQ (take last from events before acting), else from now. A timeout (default 30 s) is an error with kind timeout.",
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Synchronous mod-block contacts from the server's simulation code.
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
cell::Cell,
|
||||
sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Contact<'a> {
|
||||
pub entity: i32,
|
||||
pub kind: &'a str,
|
||||
pub block: &'a str,
|
||||
pub state: BlockStateId,
|
||||
pub position: BlockPos,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Event<'a> {
|
||||
StepOn(Contact<'a>),
|
||||
Inside(Contact<'a>),
|
||||
FallOn(Contact<'a>, f64),
|
||||
}
|
||||
|
||||
pub static HOOK: OnceLock<fn(&str, Event<'_>, &mut f32)> = OnceLock::new();
|
||||
/// Whether the loaded blocks hold one outside `minecraft:` (a content mod's).
|
||||
/// Without one no contact can dispatch, so the item and projectile sweeps
|
||||
/// skip their per-tick work: every item and thrown thing on a server would
|
||||
/// otherwise sweep its blocks each tick for nothing.
|
||||
static MOD_BLOCKS: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Called when the block tables load.
|
||||
pub fn set_mod_blocks(any: bool) {
|
||||
MOD_BLOCKS.store(any, Ordering::Relaxed);
|
||||
}
|
||||
thread_local! {
|
||||
static LEVEL: Cell<Option<&'static str>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Scope authority to this thread and restore it even on an error or panic.
|
||||
/// Nested guest actions may temporarily enter a different dimension.
|
||||
pub fn in_level<R>(dimension: Option<&'static str>, f: impl FnOnce() -> R) -> R {
|
||||
struct Restore(Option<&'static str>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
LEVEL.set(self.0);
|
||||
}
|
||||
}
|
||||
let _restore = Restore(LEVEL.replace(dimension));
|
||||
f()
|
||||
}
|
||||
|
||||
pub fn emit(event: Event<'_>) {
|
||||
dispatch(event, &mut 0.0);
|
||||
}
|
||||
|
||||
/// Damage is the calculated fall damage, before hurt handling and fall sounds.
|
||||
pub fn fall(contact: Contact<'_>, distance: f64, mut damage: f32) -> f32 {
|
||||
dispatch(Event::FallOn(contact, distance), &mut damage);
|
||||
damage
|
||||
}
|
||||
|
||||
fn dispatch(event: Event<'_>, damage: &mut f32) {
|
||||
let contact = match event {
|
||||
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
|
||||
};
|
||||
if contact.block.starts_with("minecraft:") {
|
||||
return;
|
||||
}
|
||||
if let Some(dimension) = LEVEL.get()
|
||||
&& let Some(hook) = HOOK.get()
|
||||
{
|
||||
let original = *damage;
|
||||
hook(dimension, event, damage);
|
||||
if !damage.is_finite() {
|
||||
*damage = original;
|
||||
}
|
||||
*damage = damage.max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reuse the entity block sweep for item and projectile ticks without an effect collector.
|
||||
pub fn swept_inside<'a>(
|
||||
entity: i32,
|
||||
kind: &str,
|
||||
from: horizon_core::geometry::Vec3,
|
||||
to: horizon_core::geometry::Vec3,
|
||||
bounds: horizon_core::geometry::Aabb,
|
||||
mut block: impl FnMut(BlockPos) -> (BlockStateId, &'a str),
|
||||
) {
|
||||
if !MOD_BLOCKS.load(Ordering::Relaxed) || LEVEL.get().is_none() || HOOK.get().is_none() {
|
||||
return;
|
||||
}
|
||||
let bounds = bounds.deflate(horizon_core::geometry::Vec3::new(1e-5, 1e-5, 1e-5));
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let _ =
|
||||
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, step| {
|
||||
if step >= 16 {
|
||||
return false;
|
||||
}
|
||||
if visited.insert(p) {
|
||||
let (state, name) = block(p);
|
||||
emit(Event::Inside(Contact {
|
||||
entity,
|
||||
kind,
|
||||
block: name,
|
||||
state,
|
||||
position: p,
|
||||
}));
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
thread_local! {
|
||||
static SEEN: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
fn hook(dimension: &str, event: Event<'_>, damage: &mut f32) {
|
||||
assert_eq!(dimension, "minecraft:the_nether");
|
||||
let c = match event {
|
||||
Event::FallOn(c, distance) => {
|
||||
assert_eq!(distance, 12.0);
|
||||
*damage = match *damage {
|
||||
99.0 => f32::NAN,
|
||||
98.0 => -1.0,
|
||||
_ => *damage * 0.2,
|
||||
};
|
||||
c
|
||||
}
|
||||
Event::Inside(c) | Event::StepOn(c) => c,
|
||||
};
|
||||
assert_eq!(
|
||||
(c.entity, c.kind, c.state, c.position),
|
||||
(
|
||||
17,
|
||||
"minecraft:zombie",
|
||||
BlockStateId(9),
|
||||
BlockPos::new(1, 2, 3)
|
||||
)
|
||||
);
|
||||
SEEN.set(SEEN.get() + 1);
|
||||
}
|
||||
#[test]
|
||||
fn contacts_require_server_scope_and_mod_blocks_and_restore_nested_levels() {
|
||||
HOOK.set(hook).unwrap();
|
||||
let c = Contact {
|
||||
entity: 17,
|
||||
kind: "minecraft:zombie",
|
||||
block: "test:hay",
|
||||
state: BlockStateId(9),
|
||||
position: BlockPos::new(1, 2, 3),
|
||||
};
|
||||
assert_eq!(fall(c, 12.0, 10.0), 10.0);
|
||||
in_level(Some("minecraft:the_nether"), || {
|
||||
emit(Event::StepOn(c));
|
||||
emit(Event::Inside(c));
|
||||
assert_eq!(fall(c, 12.0, 10.0), 2.0);
|
||||
in_level(None, || assert_eq!(fall(c, 12.0, 10.0), 10.0));
|
||||
assert_eq!(fall(c, 12.0, 0.0), 0.0);
|
||||
emit(Event::Inside(Contact {
|
||||
block: "minecraft:hay_block",
|
||||
..c
|
||||
}));
|
||||
});
|
||||
assert_eq!(fall(c, 12.0, 10.0), 10.0);
|
||||
assert_eq!(SEEN.get(), 4);
|
||||
in_level(Some("minecraft:the_nether"), || {
|
||||
assert_eq!(fall(c, 12.0, 99.0), 99.0);
|
||||
assert_eq!(fall(c, 12.0, 98.0), 0.0);
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
let to = Vec3::new(2.5, 2.0, 3.5);
|
||||
let bounds = Aabb::new(Vec3::new(2.25, 2.0, 3.25), Vec3::new(2.75, 2.5, 3.75));
|
||||
let block = |p| {
|
||||
(
|
||||
BlockStateId(9),
|
||||
if p == c.position {
|
||||
"test:hay"
|
||||
} else {
|
||||
"minecraft:air"
|
||||
},
|
||||
)
|
||||
};
|
||||
set_mod_blocks(true);
|
||||
swept_inside(17, c.kind, Vec3::new(0.5, 2.0, 3.5), to, bounds, block);
|
||||
assert_eq!(SEEN.get(), 7, "a block crossed mid-move is notified once");
|
||||
let at = Vec3::new(1.5, 2.0, 3.5);
|
||||
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
|
||||
swept_inside(17, c.kind, at, at, bounds, block);
|
||||
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
|
||||
});
|
||||
let _ =
|
||||
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
|
||||
assert_eq!(fall(c, 12.0, 10.0), 10.0);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Identity of the ordered block-state registry used by chunk packets, and of
|
||||
//! the item and mob effect registries once content mods add to them (item
|
||||
//! stacks and effect packets carry their network ids).
|
||||
//! the item, mob effect and entity type registries once content mods add to
|
||||
//! them (item stacks, effect and entity packets carry their network ids).
|
||||
use horizon_protocol::registry::Builtin;
|
||||
use serde::{Deserialize, Serialize, de::IgnoredAny};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -76,6 +76,21 @@ pub fn identity(root: &Path) -> Result<[u8; 32], IdentityError> {
|
||||
.collect();
|
||||
if !components.is_empty() {
|
||||
hash = hash_entries(hash, "horizon:nbt_components/v1", &components);
|
||||
// Defaults are not sent in a stack patch. Both peers must therefore
|
||||
// agree on their values as well as the registered wire IDs, native
|
||||
// mods' changes included.
|
||||
let items = horizon_world::item_defaults::read(root).map_err(IdentityError::Read)?;
|
||||
let mut defaults = Vec::new();
|
||||
for (item, components) in items {
|
||||
for (name, value) in components.as_object().into_iter().flatten() {
|
||||
if horizon_protocol::item::custom::id(name).is_some() {
|
||||
defaults.push((defaults.len() as u64, format!("{item}\0{name}\0{value}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !defaults.is_empty() {
|
||||
hash = hash_entries(hash, "horizon:nbt_component_defaults/v1", &defaults);
|
||||
}
|
||||
}
|
||||
identities.insert(root.to_owned(), hash);
|
||||
Ok(hash)
|
||||
@@ -93,7 +108,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; 2] = ["minecraft:item", "minecraft:mob_effect"];
|
||||
const REGISTRIES: [&str; 3] = [
|
||||
"minecraft:item",
|
||||
"minecraft:mob_effect",
|
||||
"minecraft:entity_type",
|
||||
];
|
||||
|
||||
fn parse_registries(bytes: &[u8]) -> Result<Builtin, IdentityError> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| IdentityError::Registries(e.to_string()))?;
|
||||
|
||||
@@ -182,47 +182,14 @@ impl ItemEntity {
|
||||
/// up) without a full collision cube, at `speed` (`nextFloat() * 0.2 +
|
||||
/// 0.1`).
|
||||
pub fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
|
||||
let bounds = self.bounds();
|
||||
let at = Vec3::new(
|
||||
self.position.x,
|
||||
(bounds.min_position().y + bounds.max_position().y) / 2.0,
|
||||
self.position.z,
|
||||
self.velocity = towards_closest_space(
|
||||
self.position,
|
||||
self.bounds(),
|
||||
self.velocity,
|
||||
world,
|
||||
facts,
|
||||
speed,
|
||||
);
|
||||
let pos = [
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
];
|
||||
let delta = [
|
||||
at.x - f64::from(pos[0]),
|
||||
at.y - f64::from(pos[1]),
|
||||
at.z - f64::from(pos[2]),
|
||||
];
|
||||
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
|
||||
let mut closest = (1, 1);
|
||||
let mut nearest = f64::MAX;
|
||||
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
|
||||
let mut n = pos;
|
||||
n[axis] += step;
|
||||
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
|
||||
let d = if step > 0 {
|
||||
1.0 - delta[axis]
|
||||
} else {
|
||||
delta[axis]
|
||||
};
|
||||
if d < nearest {
|
||||
nearest = d;
|
||||
closest = (axis, step);
|
||||
}
|
||||
}
|
||||
}
|
||||
let push = f64::from(closest.1 as f32 * speed);
|
||||
let v = self.velocity.scale(0.75);
|
||||
self.velocity = match closest.0 {
|
||||
0 => Vec3::new(push, v.y, v.z),
|
||||
1 => Vec3::new(v.x, push, v.z),
|
||||
_ => Vec3::new(v.x, v.y, push),
|
||||
};
|
||||
}
|
||||
/// Returns false when the item should be removed. `no_physics`: the item
|
||||
/// moves through blocks this tick ([`ItemEntity::inside_block`]).
|
||||
@@ -339,6 +306,37 @@ impl ItemEntity {
|
||||
&mut self.velocity,
|
||||
);
|
||||
}
|
||||
if !no_physics {
|
||||
// Entity.applyEffectsFromBlocks: stepOn, then checkInsideBlocks.
|
||||
if self.on_ground {
|
||||
let p = BlockPos::containing(self.position.add_vector(Vec3::new(
|
||||
0.0,
|
||||
-f64::from(0.2_f32),
|
||||
0.0,
|
||||
)));
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
crate::block_contact::emit(crate::block_contact::Event::StepOn(
|
||||
crate::block_contact::Contact {
|
||||
entity: self.id as i32,
|
||||
kind: "minecraft:item",
|
||||
block: &facts.get(state).name,
|
||||
state,
|
||||
position: p,
|
||||
},
|
||||
));
|
||||
}
|
||||
crate::block_contact::swept_inside(
|
||||
self.id as i32,
|
||||
"minecraft:item",
|
||||
self.previous,
|
||||
self.position,
|
||||
self.bounds(),
|
||||
|p| {
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
(state, &facts.get(state).name)
|
||||
},
|
||||
);
|
||||
}
|
||||
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
|
||||
self.needs_sync = true;
|
||||
}
|
||||
@@ -638,6 +636,7 @@ impl ExperienceOrb {
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
player: Option<[f64; 3]>,
|
||||
server: bool,
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
@@ -647,13 +646,22 @@ impl ExperienceOrb {
|
||||
self.position.y.floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
let bounds = self.bounds();
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
// `boolean colliding = !level.noCollision(getBoundingBox())`: an orb
|
||||
// inside blocks takes no gravity until it is pushed out below.
|
||||
let colliding = !crate::collisions::no_collision_empty(&mut scene, bounds);
|
||||
if facts.get(block).fluid == Fluid::Water {
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.99,
|
||||
(self.velocity.y + 5.0e-4).min(0.06),
|
||||
self.velocity.z * 0.99,
|
||||
);
|
||||
} else {
|
||||
} else if !colliding {
|
||||
self.velocity.y -= 0.03;
|
||||
}
|
||||
if facts.get(block).fluid == Fluid::Lava {
|
||||
@@ -681,12 +689,18 @@ impl ExperienceOrb {
|
||||
);
|
||||
}
|
||||
}
|
||||
let bounds = self.bounds();
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
if player.is_none() && server && colliding {
|
||||
// `followingPlayer == null && !level().isClientSide() && colliding`: an orb that would still
|
||||
// be stuck at its next position gets
|
||||
// `Entity.moveTowardsClosestSpace`, a nudge towards the nearest
|
||||
// free side of its block.
|
||||
if !crate::collisions::no_collision_empty(&mut scene, bounds.move_by(self.velocity)) {
|
||||
let speed = next_float() * 0.2 + 0.1;
|
||||
self.move_towards_closest_space(world, facts, speed);
|
||||
}
|
||||
}
|
||||
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
|
||||
// push and before the move.
|
||||
let wanted = self.velocity;
|
||||
let moved = scene
|
||||
.resolve(bounds, wanted, 0.0, self.on_ground)
|
||||
@@ -697,26 +711,114 @@ impl ExperienceOrb {
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
let mut friction = 0.98;
|
||||
// Entity.move's restituteMovementAfterCollisions, an orb's bounciness
|
||||
// 0: each collided axis's velocity is zeroed — x and z by Mth.equal's
|
||||
// 1.0E-5, y exactly.
|
||||
if (wanted.x - moved.x).abs() >= 1.0e-5 {
|
||||
self.velocity.x = 0.0;
|
||||
}
|
||||
if (wanted.z - moved.z).abs() >= 1.0e-5 {
|
||||
self.velocity.z = 0.0;
|
||||
}
|
||||
if moved.y != wanted.y {
|
||||
self.velocity.y = 0.0;
|
||||
}
|
||||
// `float friction = getAirDrag()` (0.98F), the ground's block
|
||||
// friction multiplied in as a float, widened once for the scale.
|
||||
let mut friction = 0.98_f32;
|
||||
if self.on_ground {
|
||||
let below = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y - 1.0).floor() as i32,
|
||||
(self.position.y - 0.999_999).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
friction = f64::from(facts.get(below).friction) * 0.98;
|
||||
friction *= facts.get(below).friction;
|
||||
}
|
||||
let friction = f64::from(friction);
|
||||
// The drag scales every axis by the same friction, the ground's
|
||||
// block friction included.
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * friction,
|
||||
self.velocity.y * 0.98,
|
||||
self.velocity.y * friction,
|
||||
self.velocity.z * friction,
|
||||
);
|
||||
if self.on_ground {
|
||||
self.velocity.y *= -0.9;
|
||||
// `if (verticalCollisionBelow && fallSpeed < -getGravity())`: the
|
||||
// landing bounce is 0.4 of the pre-move speed, not a fixed
|
||||
// restitution.
|
||||
if self.on_ground && wanted.y < -0.03 {
|
||||
self.velocity.y = -wanted.y * 0.4;
|
||||
}
|
||||
self.age += 1;
|
||||
self.age < 6000
|
||||
}
|
||||
/// `Entity.moveTowardsClosestSpace` from the middle of the orb's box:
|
||||
/// towards the nearest side of its block (north, south, west, east or
|
||||
/// up) without a full collision cube, at `speed` (the orb's own
|
||||
/// random, `nextFloat() * 0.2 + 0.1`).
|
||||
fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
|
||||
self.velocity = towards_closest_space(
|
||||
self.position,
|
||||
self.bounds(),
|
||||
self.velocity,
|
||||
world,
|
||||
facts,
|
||||
speed,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `Entity.moveTowardsClosestSpace` from the middle of an entity's box
|
||||
/// (`bounds`, its feet at `position`): the velocity pushed towards the
|
||||
/// nearest side of its block (north, south, west, east or up) without a full
|
||||
/// collision cube, at `speed` (`nextFloat() * 0.2 + 0.1`). Items and
|
||||
/// experience orbs share it.
|
||||
fn towards_closest_space(
|
||||
position: Vec3,
|
||||
bounds: Aabb,
|
||||
velocity: Vec3,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
speed: f32,
|
||||
) -> Vec3 {
|
||||
let at = Vec3::new(
|
||||
position.x,
|
||||
(bounds.min_position().y + bounds.max_position().y) / 2.0,
|
||||
position.z,
|
||||
);
|
||||
let pos = [
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
];
|
||||
let delta = [
|
||||
at.x - f64::from(pos[0]),
|
||||
at.y - f64::from(pos[1]),
|
||||
at.z - f64::from(pos[2]),
|
||||
];
|
||||
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
|
||||
let mut closest = (1, 1);
|
||||
let mut nearest = f64::MAX;
|
||||
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
|
||||
let mut n = pos;
|
||||
n[axis] += step;
|
||||
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
|
||||
let d = if step > 0 {
|
||||
1.0 - delta[axis]
|
||||
} else {
|
||||
delta[axis]
|
||||
};
|
||||
if d < nearest {
|
||||
nearest = d;
|
||||
closest = (axis, step);
|
||||
}
|
||||
}
|
||||
}
|
||||
let push = f64::from(closest.1 as f32 * speed);
|
||||
let v = velocity.scale(0.75);
|
||||
match closest.0 {
|
||||
0 => Vec3::new(push, v.y, v.z),
|
||||
1 => Vec3::new(v.x, push, v.z),
|
||||
_ => Vec3::new(v.x, v.y, push),
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
|
||||
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
|
||||
@@ -947,9 +1049,15 @@ pub struct ArmorStand {
|
||||
pub health: f32,
|
||||
pub id: u32,
|
||||
pub position: Vec3,
|
||||
/// `Entity.xo`, `yo`, `zo`: the position at the start of the tick, which
|
||||
/// the renderer blends toward `position` by the partial tick.
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
pub on_ground: bool,
|
||||
/// `yRot`, which the body follows (`ArmorStand.tickHeadTurn`: `yBodyRot`).
|
||||
pub yaw: f32,
|
||||
/// `yBodyRotO`: the body's yaw at the start of the tick.
|
||||
pub previous_yaw: f32,
|
||||
/// mainhand, offhand, feet, legs, chest, head (EquipmentSlot order).
|
||||
pub equipment: [Option<crate::items::ItemStack>; 6],
|
||||
pub small: bool,
|
||||
@@ -970,6 +1078,9 @@ pub struct ArmorStand {
|
||||
/// rides, which seats it every tick (`positionRider`), as NPC plugins hang
|
||||
/// a second name over a player's head.
|
||||
pub vehicle: Option<u32>,
|
||||
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
|
||||
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
|
||||
pub disabled_slots: i32,
|
||||
}
|
||||
impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
@@ -985,15 +1096,50 @@ impl ArmorStand {
|
||||
[-1.0, 0.0, -1.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
];
|
||||
/// Where the renderer draws the stand `alpha` of the way through the tick
|
||||
/// (`EntityRenderer`'s `Mth.lerp(partialTick, xo, x)` and so on).
|
||||
pub fn render_position(&self, alpha: f64) -> Vec3 {
|
||||
self.previous.lerp(self.position, alpha)
|
||||
}
|
||||
/// The body's yaw `alpha` of the way through the tick
|
||||
/// (`LivingEntityRenderer.solveBodyRot`: `Mth.rotLerp(partialTick,
|
||||
/// yBodyRotO, yBodyRot)`).
|
||||
pub fn render_yaw(&self, alpha: f32) -> f32 {
|
||||
self.previous_yaw + alpha * crate::camera::wrap_degrees(self.yaw - self.previous_yaw)
|
||||
}
|
||||
/// A server's stand on its client, each tick:
|
||||
/// `setOldPosAndRot` (`ClientLevel.tickNonPassenger`), then the step of
|
||||
/// its `SteppedInterpolationHandler` (`step`: the position and yaw it is
|
||||
/// put at, when one is due). The yaw
|
||||
/// turns the short way and stays continuous, so a frame between ticks
|
||||
/// never spins it.
|
||||
pub fn interpolate(&mut self, step: Option<(Vec3, f32)>) {
|
||||
self.previous = self.position;
|
||||
self.previous_yaw = self.yaw;
|
||||
if let Some((at, yaw)) = step {
|
||||
self.position = at;
|
||||
self.yaw += crate::camera::wrap_degrees(yaw - self.yaw);
|
||||
}
|
||||
}
|
||||
/// Put there at once, with nothing to blend from
|
||||
/// (`Entity.snapTo`).
|
||||
pub fn snap(&mut self, at: Vec3, yaw: f32) {
|
||||
self.position = at;
|
||||
self.previous = at;
|
||||
self.yaw = yaw;
|
||||
self.previous_yaw = yaw;
|
||||
}
|
||||
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
fire_ticks: 0,
|
||||
health: 20.0,
|
||||
id,
|
||||
position,
|
||||
previous: position,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: true,
|
||||
yaw,
|
||||
previous_yaw: yaw,
|
||||
equipment: Default::default(),
|
||||
small: false,
|
||||
show_arms: false,
|
||||
@@ -1005,8 +1151,29 @@ impl ArmorStand {
|
||||
last_hit: i64::MIN / 2,
|
||||
pose: Self::DEFAULT_POSE,
|
||||
vehicle: None,
|
||||
disabled_slots: 0,
|
||||
}
|
||||
}
|
||||
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
|
||||
/// hand, feet, legs, chest, head).
|
||||
fn filter_flag(slot: usize) -> u32 {
|
||||
[0, 5, 1, 2, 3, 4][slot]
|
||||
}
|
||||
/// `ArmorStand.isDisabled`: the slot is locked, or it is a hand and the
|
||||
/// stand has no arms.
|
||||
pub fn is_disabled(&self, slot: usize) -> bool {
|
||||
self.disabled_slots & (1 << Self::filter_flag(slot)) != 0 || (slot < 2 && !self.show_arms)
|
||||
}
|
||||
/// The `swapItem` locks: taking from a filled slot or putting into an
|
||||
/// empty one can each be disabled.
|
||||
pub fn swap_locked(&self, slot: usize) -> bool {
|
||||
let offset = if self.equipment[slot].is_some() {
|
||||
8
|
||||
} else {
|
||||
16
|
||||
};
|
||||
self.disabled_slots & (1 << (Self::filter_flag(slot) + offset)) != 0
|
||||
}
|
||||
pub fn environment_tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
@@ -1296,6 +1463,53 @@ mod fire_environment_tests {
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod armor_stand_render_tests {
|
||||
use super::*;
|
||||
|
||||
/// A falling stand is drawn between last tick's position and this one's,
|
||||
/// so it slides down every frame instead of jumping once a tick (#4416).
|
||||
#[test]
|
||||
fn a_falling_stand_is_drawn_between_its_ticks() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
|
||||
// A new stand has no move behind it.
|
||||
assert_eq!(stand.render_position(0.5), stand.position);
|
||||
let fall = |s: &ArmorStand| Some((Vec3::new(0.5, s.position.y - 1.0, 0.5), 0.0));
|
||||
stand.interpolate(fall(&stand));
|
||||
assert_eq!(stand.render_position(0.0).y, 70.0);
|
||||
assert_eq!(stand.render_position(0.25).y, 69.75);
|
||||
assert_eq!(stand.render_position(1.0).y, 69.0);
|
||||
stand.interpolate(fall(&stand));
|
||||
assert_eq!(stand.render_position(0.5).y, 68.5);
|
||||
// Landed: no step is due, so it stays put, and so does every frame.
|
||||
stand.interpolate(None);
|
||||
assert_eq!(stand.render_position(0.5).y, 68.0);
|
||||
}
|
||||
|
||||
/// A turning stand's body turns between ticks too, the short way round.
|
||||
#[test]
|
||||
fn a_turning_stand_is_drawn_between_its_ticks() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::ZERO, 170.0);
|
||||
assert_eq!(stand.render_yaw(0.5), 170.0);
|
||||
// To -170 is 20 degrees on through 180, not 340 back through 0.
|
||||
stand.interpolate(Some((Vec3::ZERO, -170.0)));
|
||||
assert_eq!(stand.render_yaw(0.0), 170.0);
|
||||
assert_eq!(stand.render_yaw(0.5), 180.0);
|
||||
assert_eq!(stand.render_yaw(1.0), 190.0);
|
||||
stand.interpolate(None);
|
||||
assert_eq!(stand.render_yaw(0.5), 190.0);
|
||||
}
|
||||
|
||||
/// A snap (a move too far to interpolate) leaves nothing to blend from.
|
||||
#[test]
|
||||
fn a_snapped_stand_is_drawn_where_it_landed() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
|
||||
stand.interpolate(Some((Vec3::new(0.5, 69.0, 0.5), 0.0)));
|
||||
stand.snap(Vec3::new(200.5, 64.0, 0.5), 90.0);
|
||||
assert_eq!(stand.render_position(0.5), Vec3::new(200.5, 64.0, 0.5));
|
||||
assert_eq!(stand.render_yaw(0.5), 90.0);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod merge_tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -1141,6 +1141,25 @@ pub fn mob_to_nbt(
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
// A worn piece or held item that kept its damage, name or
|
||||
// components is saved with them.
|
||||
if slot != "mainhand"
|
||||
&& item.is_some()
|
||||
&& let Some(stack) = crate::mobs::finalize::stored_stack(
|
||||
m,
|
||||
[
|
||||
crate::mobs::finalize::MAINHAND,
|
||||
crate::mobs::finalize::OFFHAND,
|
||||
crate::mobs::finalize::HEAD,
|
||||
crate::mobs::finalize::CHEST,
|
||||
crate::mobs::finalize::LEGS,
|
||||
crate::mobs::finalize::FEET,
|
||||
][index],
|
||||
)
|
||||
{
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
// Enchanted or trimmed gear is saved with its components.
|
||||
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
|
||||
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
|
||||
@@ -1405,6 +1424,10 @@ pub fn mob_from_nbt(
|
||||
crate::mobs::gear::set(&mut m, index, stack.enchantments().to_vec());
|
||||
let trim = stack.data().and_then(|d| d.trim.clone());
|
||||
crate::mobs::gear::set_trim(&mut m, index, trim);
|
||||
// The damage, name and components stay with the slot.
|
||||
if index != crate::mobs::gear::MAIN {
|
||||
crate::mobs::gear::set_stack(&mut m, index, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -2155,6 +2178,7 @@ pub fn arrow_from_nbt(
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.owner_uuid = owner;
|
||||
let a = p.arrow.as_mut()?;
|
||||
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
|
||||
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
|
||||
@@ -2198,26 +2222,106 @@ pub fn fireball_to_nbt(
|
||||
"acceleration_power",
|
||||
Tag::Double(p.acceleration.to_bits()),
|
||||
);
|
||||
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
|
||||
// (a fireball saved before its first tick keeps what it was loaded with).
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"LeftOwner".into());
|
||||
}
|
||||
if p.left_owner {
|
||||
put(&mut tag, "LeftOwner", Tag::Byte(1));
|
||||
}
|
||||
if p.age > 0 || tag.get("HasBeenShot").is_none() {
|
||||
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
|
||||
}
|
||||
put_projectile(&mut tag, p, owner);
|
||||
if let Some(power) = power {
|
||||
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
/// `Projectile.addAdditionalSaveData`: Owner, LeftOwner only when set, and
|
||||
/// HasBeenShot once it has ticked (one saved before its first tick keeps
|
||||
/// what it was loaded with).
|
||||
fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
|
||||
if let Tag::Compound(map) = tag {
|
||||
map.remove(&"LeftOwner".into());
|
||||
}
|
||||
if p.left_owner {
|
||||
put(tag, "LeftOwner", Tag::Byte(1));
|
||||
}
|
||||
if p.age > 0 || tag.get("HasBeenShot").is_none() {
|
||||
put(tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
|
||||
}
|
||||
if let Some(owner) = owner {
|
||||
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
|
||||
put(tag, "Owner", Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The thrown item projectiles Horizon saves: each entity type's id is also
|
||||
/// the item it saves when none was loaded (`getDefaultItem`).
|
||||
const THROWABLES: [(ProjectileKind, &str); 3] = [
|
||||
(ProjectileKind::Snowball, "minecraft:snowball"),
|
||||
(ProjectileKind::Egg, "minecraft:egg"),
|
||||
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
|
||||
];
|
||||
fn throwable_item(kind: &ProjectileKind) -> Option<&'static str> {
|
||||
THROWABLES
|
||||
.iter()
|
||||
.find(|(k, _)| k == kind)
|
||||
.map(|&(_, id)| id)
|
||||
}
|
||||
|
||||
/// Whether its chunk saves it: the kinds `arrow_to_nbt`, `fireball_to_nbt`
|
||||
/// and [`throwable_to_nbt`] write.
|
||||
pub fn saved_with_chunk(p: &Projectile) -> bool {
|
||||
matches!(
|
||||
p.kind,
|
||||
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
|
||||
) || (p.arrow.is_some() && matches!(p.kind, ProjectileKind::Arrow { .. }))
|
||||
|| throwable_item(&p.kind).is_some()
|
||||
}
|
||||
|
||||
/// `ThrowableItemProjectile.addAdditionalSaveData`: `Projectile`'s keys and
|
||||
/// the `Item` it flies as.
|
||||
pub fn throwable_to_nbt(
|
||||
p: &Projectile,
|
||||
uuid: [i32; 4],
|
||||
owner: Option<[i32; 4]>,
|
||||
raw: Option<&Tag>,
|
||||
) -> Option<Tag> {
|
||||
let item = throwable_item(&p.kind)?;
|
||||
let base = Base {
|
||||
id: p.kind.id(),
|
||||
uuid,
|
||||
position: p.position,
|
||||
motion: p.velocity,
|
||||
rotation: [p.yaw, p.pitch],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put_projectile(&mut tag, p, owner);
|
||||
// the loaded item stays (a blue egg's, a renamed one's).
|
||||
if tag.get("Item").is_none() {
|
||||
put(
|
||||
&mut tag,
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new(item, 1)),
|
||||
);
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
/// `ThrowableItemProjectile.readAdditionalSaveData` for the kinds
|
||||
/// [`throwable_to_nbt`] writes; the owner is the player when it is theirs.
|
||||
pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let id = kind(tag)?;
|
||||
let (kind, _) = THROWABLES.iter().find(|(_, i)| *i == id)?.clone();
|
||||
let owner = owner_uuid(tag);
|
||||
let from_player = player.is_some() && owner == player;
|
||||
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.owner_uuid = owner;
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// `Projectile`'s `Owner`.
|
||||
pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
|
||||
uuid_from_tag(tag.get("Owner"))
|
||||
}
|
||||
|
||||
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = match kind(tag)?.as_str() {
|
||||
"minecraft:fireball" => ProjectileKind::LargeFireball {
|
||||
@@ -2234,6 +2338,7 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.owner_uuid = owner;
|
||||
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
|
||||
Some(p)
|
||||
}
|
||||
@@ -2498,6 +2603,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
} else {
|
||||
put(&mut tag, "equipment", Tag::compound(equipment));
|
||||
}
|
||||
put(&mut tag, "DisabledSlots", Tag::Int(a.disabled_slots));
|
||||
for (key, value) in [
|
||||
("Small", a.small),
|
||||
("ShowArms", a.show_arms),
|
||||
@@ -2572,6 +2678,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
}
|
||||
a.small = flag(tag, "Small");
|
||||
a.show_arms = flag(tag, "ShowArms");
|
||||
a.disabled_slots = number(tag.get("DisabledSlots")).unwrap_or(0) as i32;
|
||||
a.no_base_plate = flag(tag, "NoBasePlate");
|
||||
a.invisible = flag(tag, "Invisible");
|
||||
a.marker = flag(tag, "Marker");
|
||||
@@ -4084,6 +4191,69 @@ mod fireball_nbt_tests {
|
||||
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
|
||||
let player = Some([5, 6, 7, 8]);
|
||||
for (kind, id) in [
|
||||
(ProjectileKind::Snowball, "minecraft:snowball"),
|
||||
(ProjectileKind::Egg, "minecraft:egg"),
|
||||
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
|
||||
] {
|
||||
let mut p =
|
||||
Projectile::new(kind.clone(), Vec3::new(1.5, 70.0, -2.5), Some(Who::Player));
|
||||
(p.velocity, p.left_owner, p.age) = (Vec3::new(0.3, -0.1, 1.2), true, 4);
|
||||
[p.yaw, p.pitch] = [30.0, -10.0];
|
||||
let tag = throwable_to_nbt(&p, [1, 2, 3, 4], player, None).unwrap();
|
||||
assert_eq!(super::kind(&tag).as_deref(), Some(id));
|
||||
// ThrowableItemProjectile: the item it flies as, by its default.
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!((&*item.item, item.count), (id, 1));
|
||||
assert_eq!(number(tag.get("HasBeenShot")), Some(1));
|
||||
assert_eq!(uuid_from_tag(tag.get("Owner")), player);
|
||||
let back = throwable_from_nbt(&tag, player).unwrap();
|
||||
assert_eq!((back.kind, back.owner), (kind, Some(Who::Player)));
|
||||
assert_eq!((back.position, back.velocity), (p.position, p.velocity));
|
||||
assert_eq!(
|
||||
([back.yaw, back.pitch], back.left_owner),
|
||||
([30.0, -10.0], true)
|
||||
);
|
||||
// an owner who is not here keeps their UUID through a moved resave.
|
||||
let mut away = throwable_from_nbt(&tag, Some([9; 4])).unwrap();
|
||||
assert_eq!((away.owner, away.owner_uuid), (None, player));
|
||||
away.position.y += 3.0;
|
||||
let resaved = throwable_to_nbt(&away, [1; 4], away.owner_uuid, None).unwrap();
|
||||
assert_eq!(uuid_from_tag(resaved.get("Owner")), player);
|
||||
}
|
||||
// the chunk saves arrows, fireballs and these, and so takes them with it.
|
||||
let kinds = [
|
||||
(ProjectileKind::SmallFireball, true),
|
||||
(ProjectileKind::Snowball, true),
|
||||
(ProjectileKind::WindCharge, false),
|
||||
(ProjectileKind::Trident, false),
|
||||
];
|
||||
for (kind, saved) in kinds {
|
||||
let p = Projectile::new(kind, Vec3::ZERO, None);
|
||||
assert_eq!(saved_with_chunk(&p), saved, "{:?}", p.kind);
|
||||
}
|
||||
// a loaded item stays, and a new projectile leaves LeftOwner out.
|
||||
let raw = Tag::compound([
|
||||
("id", Tag::String("minecraft:egg".into())),
|
||||
("Pos", doubles(&[0.0, 64.0, 0.0])),
|
||||
(
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new("minecraft:blue_egg", 1)),
|
||||
),
|
||||
]);
|
||||
let egg = throwable_from_nbt(&raw, None).unwrap();
|
||||
let tag = throwable_to_nbt(&egg, [1; 4], None, Some(&raw)).unwrap();
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!(&*item.item, "minecraft:blue_egg");
|
||||
assert!(tag.get("LeftOwner").is_none() && tag.get("Owner").is_none());
|
||||
assert_eq!(number(tag.get("HasBeenShot")), Some(0));
|
||||
let wind = Projectile::new(ProjectileKind::WindCharge, Vec3::ZERO, None);
|
||||
assert!(throwable_to_nbt(&wind, [1; 4], None, None).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -4136,6 +4306,43 @@ mod neutral_anger_tests {
|
||||
mod held_item_name_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_worn_piece_keeps_its_damage_and_name_through_the_save() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<_, _> = data
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(id, kind)| (id, Arc::new(kind)))
|
||||
.collect();
|
||||
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
|
||||
let mut helmet = crate::items::ItemStack::new("minecraft:iron_helmet", 1);
|
||||
helmet.damage = 40;
|
||||
helmet.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Hat".into()));
|
||||
crate::mobs::finalize::set_item_stack(&mut mob, crate::mobs::finalize::HEAD, helmet);
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
|
||||
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
|
||||
let worn = crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
|
||||
assert_eq!(worn.damage, 40);
|
||||
assert_eq!(worn.custom_name().as_deref(), Some("Hat"));
|
||||
// Replacing the item forgets the old stack.
|
||||
let mut loaded = loaded;
|
||||
crate::mobs::finalize::set_item(
|
||||
&mut loaded,
|
||||
crate::mobs::finalize::HEAD,
|
||||
"minecraft:iron_helmet",
|
||||
);
|
||||
let fresh =
|
||||
crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
|
||||
assert_eq!(fresh.damage, 0);
|
||||
assert!(fresh.custom_name().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -150,6 +150,52 @@ impl FlatTerrain {
|
||||
void_platform,
|
||||
})
|
||||
}
|
||||
/// The settings of a superflat overworld that lets structure sets generate
|
||||
/// (`FlatLevelSource.createState`): its layers, biome and `structure_overrides`
|
||||
/// (no key means every set; `[]` none). `None` for anything else, a world with no
|
||||
/// structure set to generate, the Void preset's biome (its platform is a feature
|
||||
/// this generator places) or one that is not superflat.
|
||||
pub fn structure_settings(
|
||||
settings: &Tag,
|
||||
) -> Option<horizon_world::worldgen::generator::FlatSettings> {
|
||||
let generator = settings
|
||||
.get("dimensions")?
|
||||
.get("minecraft:overworld")?
|
||||
.get("generator")?;
|
||||
if crate::save::text(generator.get("type"))?.trim_start_matches("minecraft:") != "flat" {
|
||||
return None;
|
||||
}
|
||||
let s = generator.get("settings")?;
|
||||
let biome = crate::save::text(s.get("biome")).unwrap_or_else(|| "minecraft:plains".into());
|
||||
if biome == "minecraft:the_void" {
|
||||
return None;
|
||||
}
|
||||
let mut layers = Vec::new();
|
||||
if let Some(Tag::List(list)) = s.get("layers") {
|
||||
for layer in list {
|
||||
let block = crate::save::text(layer.get("block"))?;
|
||||
let height = crate::save::number(layer.get("height")).unwrap_or(0).max(0) as i32;
|
||||
layers.push((block, height));
|
||||
}
|
||||
}
|
||||
let structure_overrides = match s.get("structure_overrides") {
|
||||
Some(Tag::List(sets)) => Some(
|
||||
sets.iter()
|
||||
.filter_map(|set| crate::save::text(Some(set)))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
Some(Tag::String(set)) => Some(vec![set.to_string_lossy()]),
|
||||
_ => None,
|
||||
};
|
||||
if structure_overrides.as_ref().is_some_and(Vec::is_empty) {
|
||||
return None;
|
||||
}
|
||||
Some(horizon_world::worldgen::generator::FlatSettings {
|
||||
layers,
|
||||
biome,
|
||||
structure_overrides,
|
||||
})
|
||||
}
|
||||
/// FlatLevelSource.getSpawnHeight: the top of the layers.
|
||||
pub fn spawn_height(&self) -> i32 {
|
||||
self.shape.min_y + self.layers.len().min(self.shape.height as usize) as i32
|
||||
@@ -479,4 +525,67 @@ mod tests {
|
||||
(None, vec!["Sum of layer heights is > 4064".into()])
|
||||
);
|
||||
}
|
||||
|
||||
/// `FlatLevelSource.createState`: the settings a flat world's structure sets run with.
|
||||
#[test]
|
||||
fn a_flat_world_names_the_structure_sets_that_may_generate() {
|
||||
let world = |generator: Tag| {
|
||||
Tag::compound([(
|
||||
"dimensions",
|
||||
Tag::compound([(
|
||||
"minecraft:overworld",
|
||||
Tag::compound([("generator", generator)]),
|
||||
)]),
|
||||
)])
|
||||
};
|
||||
let flat = |extra: Vec<(&str, Tag)>, biome: &str| {
|
||||
let mut settings = vec![
|
||||
("biome", Tag::String(biome.into())),
|
||||
(
|
||||
"layers",
|
||||
Tag::List(vec![Tag::compound([
|
||||
("block", Tag::String("minecraft:stone".into())),
|
||||
("height", Tag::Int(3)),
|
||||
])]),
|
||||
),
|
||||
];
|
||||
settings.extend(extra);
|
||||
world(Tag::compound([
|
||||
("type", Tag::String("minecraft:flat".into())),
|
||||
("settings", Tag::compound(settings)),
|
||||
]))
|
||||
};
|
||||
// No key: every set. A list: those. An empty list: none. The void and a noise world: none.
|
||||
let all = FlatTerrain::structure_settings(&flat(vec![], "minecraft:plains")).unwrap();
|
||||
assert_eq!(all.structure_overrides, None);
|
||||
assert_eq!(all.layers, [("minecraft:stone".to_owned(), 3)]);
|
||||
let some = FlatTerrain::structure_settings(&flat(
|
||||
vec![(
|
||||
"structure_overrides",
|
||||
Tag::List(vec![Tag::String("minecraft:villages".into())]),
|
||||
)],
|
||||
"minecraft:desert",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
some.structure_overrides,
|
||||
Some(vec!["minecraft:villages".to_owned()])
|
||||
);
|
||||
assert_eq!(some.biome, "minecraft:desert");
|
||||
assert!(
|
||||
FlatTerrain::structure_settings(&flat(
|
||||
vec![("structure_overrides", Tag::List(vec![]))],
|
||||
"minecraft:plains"
|
||||
))
|
||||
.is_none()
|
||||
);
|
||||
assert!(FlatTerrain::structure_settings(&flat(vec![], "minecraft:the_void")).is_none());
|
||||
assert!(
|
||||
FlatTerrain::structure_settings(&world(Tag::compound([(
|
||||
"type",
|
||||
Tag::String("minecraft:noise".into())
|
||||
)])))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -457,6 +457,8 @@ impl Game<'_> {
|
||||
EntityView {
|
||||
kind: "minecraft:player".into(),
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
// Entity.isOnFire on a server: remainingFireTicks (players aren't fire immune).
|
||||
on_fire: p.fire_ticks > 0,
|
||||
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
// Input.shift: the held key, as `BlockItem.place`'s sneaking reads it.
|
||||
input_sneak: self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft),
|
||||
|
||||
@@ -522,7 +522,9 @@ impl Game<'_> {
|
||||
let slot = match &held {
|
||||
Some(h) => {
|
||||
let slot = self.equipment_slot(h);
|
||||
if slot < 2 && !a.show_arms {
|
||||
// ArmorStand.interactAt: a disabled slot, or a hand without
|
||||
// arms, is FAIL.
|
||||
if a.is_disabled(slot) {
|
||||
return true;
|
||||
}
|
||||
slot
|
||||
@@ -550,6 +552,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
};
|
||||
// An empty hand takes from the main hand when the clicked slot is
|
||||
// disabled (`isDisabled(clickedSlot) ? slot : clickedSlot`).
|
||||
let slot = if held.is_none() && a.is_disabled(slot) {
|
||||
0
|
||||
} else {
|
||||
slot
|
||||
};
|
||||
// ArmorStand.swapItem: taking from a filled slot or putting into an
|
||||
// empty one can be disabled; the click then passes.
|
||||
if a.swap_locked(slot) {
|
||||
return false;
|
||||
}
|
||||
let current = self.armor_stands[i].equipment[slot].clone();
|
||||
if held.is_none() && current.is_none() {
|
||||
return false;
|
||||
@@ -722,6 +736,8 @@ impl Game<'_> {
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
for (i, a) in self.armor_stands.iter_mut().enumerate() {
|
||||
a.previous = a.position;
|
||||
a.previous_yaw = a.yaw;
|
||||
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
@@ -785,11 +801,10 @@ impl Game<'_> {
|
||||
let dragon = a.equipment[5]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:dragon_head");
|
||||
let position = super::entity_culling::at(a.position);
|
||||
if !self.standing_entity_in_view(
|
||||
camera,
|
||||
position,
|
||||
position,
|
||||
super::entity_culling::at(a.previous),
|
||||
super::entity_culling::at(a.position),
|
||||
alpha,
|
||||
width,
|
||||
height,
|
||||
@@ -798,7 +813,9 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let p = glam::DVec3::new(a.position.x, a.position.y, a.position.z) - camera;
|
||||
// EntityRenderer: drawn between last tick's position and this one's.
|
||||
let at = a.render_position(alpha);
|
||||
let p = super::entity_culling::at(at) - camera;
|
||||
// setupRotations: 180 - body yaw, plus the wobble for 5 ticks after a hit.
|
||||
let wiggle = (self.world.game_time - a.last_hit) as f32 + alpha as f32;
|
||||
let wobble = if wiggle < 5.0 {
|
||||
@@ -807,13 +824,15 @@ impl Game<'_> {
|
||||
0.0
|
||||
};
|
||||
let base = glam::Mat4::from_translation(p.as_vec3())
|
||||
* glam::Mat4::from_rotation_y((180.0 - a.yaw + wobble).to_radians())
|
||||
* glam::Mat4::from_rotation_y(
|
||||
(180.0 - a.render_yaw(alpha as f32) + wobble).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
|
||||
let light = self.world.light(
|
||||
a.position.x.floor() as i32,
|
||||
(a.position.y + 0.5).floor() as i32,
|
||||
a.position.z.floor() as i32,
|
||||
at.x.floor() as i32,
|
||||
(at.y + 0.5).floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
self.armor_stand_vertices(a, base, packed, out);
|
||||
@@ -1322,6 +1341,26 @@ mod swap_tests {
|
||||
ItemStack::new(item, count)
|
||||
}
|
||||
#[test]
|
||||
fn disabled_slots_lock_by_filter_flag() {
|
||||
let mut a = ArmorStand::new(1, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
// Hands need arms.
|
||||
assert!(a.is_disabled(0) && a.is_disabled(1));
|
||||
a.show_arms = true;
|
||||
assert!(!a.is_disabled(0) && !a.is_disabled(1));
|
||||
// Bit 4 locks the head (equipment index 5), bit 5 the off hand (1).
|
||||
a.disabled_slots = (1 << 4) | (1 << 5);
|
||||
assert!(a.is_disabled(5) && a.is_disabled(1));
|
||||
assert!(!a.is_disabled(4) && !a.is_disabled(0));
|
||||
// Taking (+8) from a filled slot and putting (+16) into an empty one.
|
||||
a.disabled_slots = (1 << (3 + 8)) | (1 << (1 + 16));
|
||||
assert!(!a.swap_locked(4), "an empty chest slot is not take-locked");
|
||||
a.equipment[4] = Some(stack("minecraft:iron_chestplate", 1));
|
||||
assert!(a.swap_locked(4));
|
||||
assert!(a.swap_locked(2), "an empty feet slot is put-locked");
|
||||
a.equipment[2] = Some(stack("minecraft:iron_boots", 1));
|
||||
assert!(!a.swap_locked(2));
|
||||
}
|
||||
#[test]
|
||||
fn creative_swap_gives_the_worn_piece_to_the_hand() {
|
||||
let swap = swap_with_equipment_slot(
|
||||
&stack("minecraft:diamond_chestplate", 1),
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
leash::{Holder, Pending},
|
||||
riders,
|
||||
},
|
||||
motion::RiddenBoat,
|
||||
motion::{BubbleColumn, RiddenBoat, bubble_column_at},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
|
||||
@@ -48,6 +48,25 @@ pub(super) struct Boat {
|
||||
water_level: f64,
|
||||
land_friction: f32,
|
||||
out_of_control: f32,
|
||||
/// `AbstractBoat.DATA_ID_BUBBLE_TIME` (synched): the ticks of `BUBBLE_TIME`
|
||||
/// left before a bubble column lifts or drags the boat; 0 when it is not
|
||||
/// over one.
|
||||
pub bubble_time: i32,
|
||||
/// `AbstractBoat.isAboveBubbleColumn`, set by `onAboveBubbleColumn` and
|
||||
/// cleared by `tickBubbleColumn` (the server's).
|
||||
above_bubble_column: bool,
|
||||
/// `AbstractBoat.bubbleColumnDirectionIsDown`.
|
||||
bubble_down: bool,
|
||||
/// A bubble column's drag asked a rider the server can't move itself (a
|
||||
/// player) to get out: `guest_vehicle_tick` takes it (`ejectPassengers`).
|
||||
pub eject_riders: bool,
|
||||
/// `Entity.tickCount`, counted where the wobble is (a client's tick).
|
||||
tick_count: i32,
|
||||
/// `AbstractBoat.bubbleMultiplier`, `bubbleAngle` and `bubbleAngleO`
|
||||
/// (a client's).
|
||||
bubble_multiplier: f32,
|
||||
bubble_angle: f32,
|
||||
bubble_angle_old: f32,
|
||||
pub hurt_time: i32,
|
||||
pub hurt_dir: i32,
|
||||
pub damage: f32,
|
||||
@@ -72,6 +91,13 @@ pub(super) struct Boat {
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
/// `AbstractBoat.BUBBLE_TIME`: the ticks over a bubble column before it
|
||||
/// lifts or drags the boat.
|
||||
const BUBBLE_TIME: i32 = 60;
|
||||
/// `EntityEvent` 71 and 72, which `AbstractBoat.tickBubbleColumn` broadcasts
|
||||
/// for a down and an up column.
|
||||
pub(super) const BUBBLE_DOWN_EVENT: i8 = 71;
|
||||
pub(super) const BUBBLE_UP_EVENT: i8 = 72;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
pub(super) const HEIGHT: f64 = 0.5625;
|
||||
impl Boat {
|
||||
@@ -90,6 +116,14 @@ impl Boat {
|
||||
water_level: 0.0,
|
||||
land_friction: 0.0,
|
||||
out_of_control: 0.0,
|
||||
bubble_time: 0,
|
||||
above_bubble_column: false,
|
||||
bubble_down: false,
|
||||
eject_riders: false,
|
||||
tick_count: 0,
|
||||
bubble_multiplier: 0.0,
|
||||
bubble_angle: 0.0,
|
||||
bubble_angle_old: 0.0,
|
||||
hurt_time: 0,
|
||||
hurt_dir: 1,
|
||||
damage: 0.0,
|
||||
@@ -174,6 +208,42 @@ impl Boat {
|
||||
yaw,
|
||||
)
|
||||
}
|
||||
/// `AbstractBoat.getBubbleAngle`: the wobble, in degrees, `a` of the way
|
||||
/// from the last tick's to this one's.
|
||||
pub fn bubble_angle(&self, a: f32) -> f32 {
|
||||
self.bubble_angle_old + a * (self.bubble_angle - self.bubble_angle_old)
|
||||
}
|
||||
/// `AbstractBoat.tickBubbleColumn`: on a client, the wobble grows by 0.05 a
|
||||
/// tick while the synched bubble time runs and fades by 0.1 otherwise.
|
||||
pub(super) fn tick_bubble_wobble(&mut self) {
|
||||
self.tick_count += 1;
|
||||
if self.bubble_time > 0 {
|
||||
self.bubble_multiplier += 0.05;
|
||||
} else {
|
||||
self.bubble_multiplier -= 0.1;
|
||||
}
|
||||
self.bubble_multiplier = self.bubble_multiplier.clamp(0.0, 1.0);
|
||||
self.bubble_angle_old = self.bubble_angle;
|
||||
self.bubble_angle =
|
||||
10.0 * (0.5 * f64::from(self.tick_count)).sin() as f32 * self.bubble_multiplier;
|
||||
}
|
||||
/// `AbstractBoat.tickBubbleColumn`'s countdown on the server: the bubble
|
||||
/// time drops while the boat stays over a column and resets when it leaves.
|
||||
/// `Some(down)` on the tick it runs out: the boat is thrown up (`false`) or
|
||||
/// pulled down (`true`).
|
||||
pub(super) fn tick_bubble_countdown(&mut self) -> Option<bool> {
|
||||
if !self.above_bubble_column {
|
||||
self.bubble_time = 0;
|
||||
}
|
||||
if self.bubble_time <= 0 {
|
||||
return None;
|
||||
}
|
||||
self.bubble_time -= 1;
|
||||
let diff = BUBBLE_TIME - self.bubble_time - 1;
|
||||
let fired = (diff > 0 && self.bubble_time == 0).then_some(self.bubble_down);
|
||||
self.above_bubble_column = false;
|
||||
fired
|
||||
}
|
||||
/// `getRowingTime`.
|
||||
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
|
||||
if self.paddling[side] {
|
||||
@@ -684,9 +754,194 @@ impl Game<'_> {
|
||||
if self.boats[i].out_of_control >= 60.0 {
|
||||
self.eject_boat_mobs(i, false);
|
||||
}
|
||||
self.boat_bubble_tick(i);
|
||||
self.boat_contacts(i);
|
||||
}
|
||||
}
|
||||
/// `Entity.canSimulateMovement`: for a boat (`AUTHORITATIVE_SIDE`) a
|
||||
/// server leaves the boat its player drives to that player's client, and a
|
||||
/// client moves only the boat its player drives.
|
||||
fn boat_can_simulate_movement(&self, id: u32) -> bool {
|
||||
if self.remote.is_some() {
|
||||
self.riding == Some(id)
|
||||
} else {
|
||||
!self.guest_vehicles().contains(&id)
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.tick`: `status = getStatus()` for a boat this window
|
||||
/// does not tick (`tick_boat` sets it for the others): a client's boats
|
||||
/// other than the one it drives, and a server's boats its players drive.
|
||||
pub(super) fn refresh_boat_status(&mut self, i: usize) {
|
||||
let b = &self.boats[i];
|
||||
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
|
||||
let status = self.boat_status(&mut probe);
|
||||
self.boats[i].status = status;
|
||||
}
|
||||
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
|
||||
/// (its box deflated by 1e-5 as `Entity.checkInsideBlocks` does), in x, y,
|
||||
/// z order.
|
||||
pub(super) fn boat_bubble_columns(&self, i: usize) -> Vec<BubbleColumn> {
|
||||
let b = self.boats[i].bounds().deflate(Vec3::new(
|
||||
f64::from(1.0e-5f32),
|
||||
f64::from(1.0e-5f32),
|
||||
f64::from(1.0e-5f32),
|
||||
));
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let mut columns = Vec::new();
|
||||
for x in lo.x.floor() as i32..=(hi.x.ceil() as i32 - 1) {
|
||||
for y in lo.y.floor() as i32..=(hi.y.ceil() as i32 - 1) {
|
||||
for z in lo.z.floor() as i32..=(hi.z.ceil() as i32 - 1) {
|
||||
if let Some(column) = bubble_column_at(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
BlockPos::new(x, y, z),
|
||||
) {
|
||||
columns.push(column);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
columns
|
||||
}
|
||||
/// `Entity.handleOnInsideBubbleColumn`: a column under more water lifts
|
||||
/// boat `i` up to 0.7 or drags it down to -0.3 (`AbstractBoat` does not
|
||||
/// override `onInsideBubbleColumn`), applied by the column's own push below
|
||||
/// the surface.
|
||||
pub(super) fn boat_inside_bubble_column(&mut self, i: usize, column: BubbleColumn) {
|
||||
if !self.boat_can_simulate_movement(self.boats[i].id) {
|
||||
return;
|
||||
}
|
||||
column.push(&mut self.boats[i].velocity);
|
||||
}
|
||||
/// `AbstractBoat.onAboveBubbleColumn`: whether the splash comes up, one
|
||||
/// time in a hundred for a boat that is not under water.
|
||||
pub(super) fn boat_splash_roll(&mut self, i: usize) -> bool {
|
||||
!self.boats[i].under_water() && self.world.random.next_int_bound(100).unwrap_or(1) == 0
|
||||
}
|
||||
/// `AbstractBoat.onAboveBubbleColumn`: the client's half after the roll,
|
||||
/// the splash sound and particle at the boat.
|
||||
pub(super) fn boat_bubble_splash(&mut self, i: usize) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let p = self.boats[i].position;
|
||||
let pitch = 0.8 + 0.4 * self.world.random.next_float();
|
||||
self.audio.play(
|
||||
"minecraft:entity.generic.splash",
|
||||
Some([p.x, p.y, p.z]),
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
let x = p.x + f64::from(self.world.random.next_float());
|
||||
let z = p.z + f64::from(self.world.random.next_float());
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::Splash),
|
||||
[x, p.y + 0.7, z],
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
/// `AbstractBoat.onAboveBubbleColumn`: on the server (this window
|
||||
/// simulates the boat) the column is remembered, the countdown starts and
|
||||
/// the splash's game event goes to the listeners (its sound and particle
|
||||
/// are the client's).
|
||||
fn boat_above_bubble_column(&mut self, i: usize, drag_down: bool) {
|
||||
let b = &mut self.boats[i];
|
||||
b.above_bubble_column = true;
|
||||
b.bubble_down = drag_down;
|
||||
if b.bubble_time == 0 {
|
||||
b.bubble_time = BUBBLE_TIME;
|
||||
}
|
||||
if !self.boat_splash_roll(i) {
|
||||
return;
|
||||
}
|
||||
// A window that is its own client hears and sees the splash too.
|
||||
if self.options.execution_role.is_client() {
|
||||
self.boat_bubble_splash(i);
|
||||
}
|
||||
let (id, p) = (self.boats[i].id, self.boats[i].position);
|
||||
// Entity.gameEvent(SPLASH, getControllingPassenger()): the window's
|
||||
// own player when it drives the boat.
|
||||
let ctx = if self.riding == Some(id) && self.player.is_some() {
|
||||
self.player_context(None)
|
||||
} else {
|
||||
self.who_context(None, None)
|
||||
};
|
||||
self.post_game_event("minecraft:splash", [p.x, p.y, p.z], ctx);
|
||||
}
|
||||
/// `AbstractBoat.handleBubbleColumnEffect`: an up column throws the boat
|
||||
/// up at 0.6, a down column pulls it down by 0.7.
|
||||
pub(super) fn handle_bubble_column_effect(&mut self, i: usize, drag_down: bool) {
|
||||
if !self.boat_can_simulate_movement(self.boats[i].id) {
|
||||
return;
|
||||
}
|
||||
let v = &mut self.boats[i].velocity;
|
||||
if drag_down {
|
||||
// movement.add(0.0, -0.7, 0.0)
|
||||
v.y += -0.7;
|
||||
} else {
|
||||
v.y = 0.6;
|
||||
}
|
||||
}
|
||||
/// `Entity.ejectPassengers`: for a down column the mobs get out here, this
|
||||
/// window's player gets out, and a server's player is asked to
|
||||
/// (`guest_vehicle_tick` lets it out through its client).
|
||||
fn eject_boat_riders(&mut self, i: usize) {
|
||||
self.eject_boat_mobs(i, false);
|
||||
let id = self.boats[i].id;
|
||||
if self.riding == Some(id) && self.player.is_some() {
|
||||
self.dismount(i);
|
||||
} else if self.guest_rides(id) {
|
||||
self.boats[i].eject_riders = true;
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.tickBubbleColumn`: on the server, a boat that stays over
|
||||
/// a column for `BUBBLE_TIME` ticks is thrown up or pulled down (a down
|
||||
/// column lets the riders out first), and every client is told.
|
||||
fn tick_boat_bubble_column(&mut self, i: usize) {
|
||||
if let Some(down) = self.boats[i].tick_bubble_countdown() {
|
||||
let (id, p) = (self.boats[i].id, self.boats[i].position);
|
||||
let event = if down {
|
||||
BUBBLE_DOWN_EVENT
|
||||
} else {
|
||||
BUBBLE_UP_EVENT
|
||||
};
|
||||
if down {
|
||||
self.eject_boat_riders(i);
|
||||
}
|
||||
self.lan_entity_event(id as i32, event, [p.x, p.y, p.z]);
|
||||
self.handle_bubble_column_effect(i, down);
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.tick`: the end of it on the server for boat `i`, the two
|
||||
/// `applyEffectsFromBlocks` that reach bubble columns (the lava is
|
||||
/// `boat_lava_tick`'s), then `tickBubbleColumn`; and the wobble when this
|
||||
/// window is its own client.
|
||||
fn boat_bubble_tick(&mut self, i: usize) {
|
||||
let id = self.boats[i].id;
|
||||
// The boat a server's player drives is not ticked here: its status is.
|
||||
if self.guest_vehicles().contains(&id) {
|
||||
self.refresh_boat_status(i);
|
||||
}
|
||||
// An ejection nobody took (its player left the boat first) is void.
|
||||
if !self.guest_rides(id) {
|
||||
self.boats[i].eject_riders = false;
|
||||
}
|
||||
let touches = self.boat_bubble_columns(i);
|
||||
// AbstractBoat.tick calls applyEffectsFromBlocks twice.
|
||||
for _ in 0..2 {
|
||||
for &column in &touches {
|
||||
if column.surface {
|
||||
self.boat_above_bubble_column(i, column.drag);
|
||||
} else {
|
||||
self.boat_inside_bubble_column(i, column);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tick_boat_bubble_column(i);
|
||||
if self.options.execution_role.is_client() {
|
||||
self.boats[i].tick_bubble_wobble();
|
||||
}
|
||||
}
|
||||
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
|
||||
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
|
||||
/// damage, so lava breaks a boat on its first tick inside, and the fire
|
||||
@@ -1053,6 +1308,8 @@ impl Game<'_> {
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
self.eject_boat_mobs(i, true);
|
||||
let id = self.boats[i].id;
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
// AbstractBoat.remove: a destroyed boat drops its lead.
|
||||
@@ -1132,6 +1389,16 @@ impl Game<'_> {
|
||||
(hurt.sin() * hurt * damage / 10.0 * b.hurt_dir as f32).to_radians(),
|
||||
);
|
||||
}
|
||||
// AbstractBoatRenderer.extractRenderState: bubbleAngle.
|
||||
let wobble = b.bubble_angle(a);
|
||||
// AbstractBoatRenderer.submit: a boat above water wobbles in a
|
||||
// bubble column, about the axis (1, 0, 1).
|
||||
if !b.under_water() && wobble.abs() >= 1.0e-5 {
|
||||
base *= glam::Mat4::from_axis_angle(
|
||||
glam::Vec3::new(1.0, 0.0, 1.0).normalize(),
|
||||
wobble * (std::f64::consts::PI / 180.0) as f32,
|
||||
);
|
||||
}
|
||||
base *= glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_rotation_y(90f32.to_radians());
|
||||
let mut poses = Poses::new(model);
|
||||
@@ -1186,3 +1453,95 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn boat() -> Boat {
|
||||
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// `AbstractBoat.tickBubbleColumn` on a client: the wobble grows while the
|
||||
/// bubble time runs and fades after.
|
||||
#[test]
|
||||
fn the_wobble_grows_while_the_bubble_time_runs_and_fades_after() {
|
||||
let mut b = boat();
|
||||
b.bubble_time = BUBBLE_TIME;
|
||||
for _ in 0..30 {
|
||||
b.tick_bubble_wobble();
|
||||
}
|
||||
// 0.05 a tick, capped at 1.
|
||||
assert_eq!(b.bubble_multiplier, 1.0);
|
||||
// 10 * sin(0.5 * tickCount) * multiplier.
|
||||
let expected = 10.0 * (0.5 * 30.0f64).sin() as f32;
|
||||
assert!((b.bubble_angle - expected).abs() < 1.0e-4);
|
||||
// The render angle lerps between the last two ticks.
|
||||
let mid = b.bubble_angle(0.5);
|
||||
assert!((mid - (b.bubble_angle_old + b.bubble_angle) / 2.0).abs() < 1.0e-5);
|
||||
// Once the bubble time is 0 the multiplier fades by 0.1 a tick.
|
||||
b.bubble_time = 0;
|
||||
for _ in 0..4 {
|
||||
b.tick_bubble_wobble();
|
||||
}
|
||||
assert!((b.bubble_multiplier - 0.6).abs() < 1.0e-5);
|
||||
for _ in 0..10 {
|
||||
b.tick_bubble_wobble();
|
||||
}
|
||||
assert_eq!(b.bubble_multiplier, 0.0);
|
||||
assert_eq!(b.bubble_angle, 0.0);
|
||||
}
|
||||
|
||||
/// `AbstractBoat.tickBubbleColumn` on the server: a boat held over a
|
||||
/// column fires on the 60th tick, with the column's direction, then the
|
||||
/// time restarts.
|
||||
#[test]
|
||||
fn the_bubble_countdown_fires_every_sixty_ticks_over_a_column() {
|
||||
for down in [false, true] {
|
||||
let mut b = boat();
|
||||
let mut fired = Vec::new();
|
||||
for tick in 1..=130 {
|
||||
// What boat_above_bubble_column does each tick.
|
||||
b.above_bubble_column = true;
|
||||
b.bubble_down = down;
|
||||
if b.bubble_time == 0 {
|
||||
b.bubble_time = BUBBLE_TIME;
|
||||
}
|
||||
if let Some(d) = b.tick_bubble_countdown() {
|
||||
fired.push((tick, d));
|
||||
}
|
||||
assert!(!b.above_bubble_column);
|
||||
}
|
||||
assert_eq!(fired, vec![(60, down), (120, down)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaving the column resets the time: nothing fires, and a boat with no
|
||||
/// time left does nothing over a column until the column sets it.
|
||||
#[test]
|
||||
fn leaving_the_column_resets_the_bubble_countdown() {
|
||||
let mut b = boat();
|
||||
b.bubble_time = BUBBLE_TIME;
|
||||
for _ in 0..30 {
|
||||
b.above_bubble_column = true;
|
||||
assert_eq!(b.tick_bubble_countdown(), None);
|
||||
}
|
||||
assert_eq!(b.bubble_time, 30);
|
||||
// A tick away from the column.
|
||||
assert_eq!(b.tick_bubble_countdown(), None);
|
||||
assert_eq!(b.bubble_time, 0);
|
||||
b.above_bubble_column = true;
|
||||
assert_eq!(b.tick_bubble_countdown(), None);
|
||||
assert!(b.above_bubble_column);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boat_that_never_saw_a_column_does_not_wobble() {
|
||||
let mut b = boat();
|
||||
for _ in 0..10 {
|
||||
b.tick_bubble_wobble();
|
||||
}
|
||||
assert_eq!(b.bubble_angle, 0.0);
|
||||
assert_eq!(b.bubble_angle(0.5), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -1432,6 +1432,7 @@ impl Game<'_> {
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: true,
|
||||
});
|
||||
self.ui.screenshot_message = Some(path);
|
||||
}
|
||||
|
||||
@@ -1227,21 +1227,11 @@ impl Game<'_> {
|
||||
static RARITY: std::sync::OnceLock<std::collections::HashMap<String, String>> =
|
||||
std::sync::OnceLock::new();
|
||||
let map = RARITY.get_or_init(|| {
|
||||
let v: serde_json::Value = serde_json::from_slice(
|
||||
&std::fs::read(self.options.resources.join("items.json")).unwrap_or_default(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
v.as_object()
|
||||
.map(|o| {
|
||||
o.iter()
|
||||
.filter_map(|(k, c)| {
|
||||
c["minecraft:rarity"]
|
||||
.as_str()
|
||||
.map(|r| (k.clone(), r.to_owned()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
horizon_world::item_defaults::read(&self.options.resources)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|(k, c)| c["minecraft:rarity"].as_str().map(|r| (k, r.to_owned())))
|
||||
.collect()
|
||||
});
|
||||
map.get(item).cloned().unwrap_or_else(|| "common".into())
|
||||
}
|
||||
@@ -1951,11 +1941,15 @@ impl Game<'_> {
|
||||
Target::Boat(id) => {
|
||||
// AbstractBoat.remove: a killed boat drops its lead.
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
self.boat_removed(&b);
|
||||
}
|
||||
}
|
||||
Target::Cart(id) => self.carts.retain(|c| c.id != id),
|
||||
Target::Cart(id) => {
|
||||
self.release_vehicle_container(id);
|
||||
self.carts.retain(|c| c.id != id);
|
||||
}
|
||||
Target::Other(id) => self.others.retain(|o| o.id != id),
|
||||
Target::Crystal(id) => self.kill_crystal(id),
|
||||
Target::Dragon(id) => self.kill_dragon(id),
|
||||
|
||||
@@ -26,6 +26,8 @@ pub(in crate::game) enum Slot {
|
||||
/// An armor stand's equipment (`ArmorStand::SLOTS` index).
|
||||
Stand(u32, usize),
|
||||
Frame(u32),
|
||||
/// A chest or hopper minecart's slot (`ContainerEntity.getChestVehicleSlot`).
|
||||
Cart(u32, usize),
|
||||
Block([i32; 3], usize),
|
||||
/// A slot of another player (`for_player`): reached with their state in place.
|
||||
Guest(u32, Box<Slot>),
|
||||
@@ -168,6 +170,19 @@ impl Game<'_> {
|
||||
add(Slot::Frame(*id));
|
||||
}
|
||||
}
|
||||
// `getChestVehicleSlot` takes any index below `getContainerSize`.
|
||||
Owner::Entity(Target::Cart(id)) => {
|
||||
let size = self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id == *id)
|
||||
.map_or(0, |c| c.kind.slots());
|
||||
for i in 0..size {
|
||||
if matches_name(name, &format!("container.{i}")) {
|
||||
add(Slot::Cart(*id, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
Owner::Entity(_) => {}
|
||||
}
|
||||
out
|
||||
@@ -209,6 +224,7 @@ impl Game<'_> {
|
||||
.cloned()
|
||||
.flatten(),
|
||||
Slot::Frame(id) => self.item_frames.iter().find(|f| f.id == *id)?.item.clone(),
|
||||
Slot::Cart(id, i) => self.cart_slots(*id)?.get(*i).cloned().flatten(),
|
||||
Slot::Block(p, i) => match self.world.block_entities.get(&(p[0], p[1], p[2])) {
|
||||
Some(horizon_client::containers::BlockEntity::Chest(items)) => {
|
||||
items.get(*i).cloned().flatten()
|
||||
@@ -296,6 +312,13 @@ impl Game<'_> {
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
Slot::Cart(id, i) => match self.cart_slots(*id).and_then(|c| c.get_mut(*i)) {
|
||||
Some(slot) => {
|
||||
*slot = stack;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
Slot::Block(p, i) => match self.world.block_entities.get_mut(&(p[0], p[1], p[2])) {
|
||||
Some(horizon_client::containers::BlockEntity::Chest(items)) => {
|
||||
match items.get_mut(*i) {
|
||||
|
||||
@@ -61,7 +61,11 @@ impl Game<'_> {
|
||||
.unwrap_or(Target::Player)
|
||||
}
|
||||
|
||||
/// `PlayerList.getPlayerByName`: names are not case sensitive.
|
||||
/// `PlayerList.getPlayerByName`: names are not case sensitive. Horizon:
|
||||
/// on a server where a mod gave players nicknames
|
||||
/// (`native_set_nickname`), a name no player has finds the player going
|
||||
/// by it, without its `~` or with it (quoted), so `/msg` and `/tell`
|
||||
/// reach a player by the name chat shows. No nickname, no change.
|
||||
pub(super) fn player_named(&self, name: &str) -> Option<Target> {
|
||||
if self.player.is_some() && name.eq_ignore_ascii_case(self.acting_player_name()) {
|
||||
return Some(Target::Player);
|
||||
@@ -69,6 +73,21 @@ impl Game<'_> {
|
||||
self.command_players()
|
||||
.find(|g| g.name.eq_ignore_ascii_case(name))
|
||||
.map(|g| Target::Guest(g.conn))
|
||||
.or_else(|| self.player_nicknamed(name))
|
||||
}
|
||||
|
||||
/// The player whose nickname is `name` (any case, a leading `~` either
|
||||
/// side ignored).
|
||||
fn player_nicknamed(&self, name: &str) -> Option<Target> {
|
||||
let bare = |s: &str| s.strip_prefix('~').unwrap_or(s).to_ascii_lowercase();
|
||||
let wanted = bare(name);
|
||||
let hub = &self.lan.as_ref()?.hub;
|
||||
let uuid = hub
|
||||
.nicknames
|
||||
.iter()
|
||||
.find(|(_, nick)| bare(nick) == wanted)?
|
||||
.0;
|
||||
self.target_by_uuid(*uuid)
|
||||
}
|
||||
|
||||
/// `EntitySelector.findEntities` with a UUID: the player with it, else
|
||||
|
||||
@@ -644,6 +644,7 @@ impl Game<'_> {
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.trim().into(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
});
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
@@ -895,6 +896,15 @@ impl Game<'_> {
|
||||
);
|
||||
ok()
|
||||
}
|
||||
// A splitscreen player leaves splitscreen; only P1's game ends the process.
|
||||
"quit" if self.options.split_guest => {
|
||||
if self.remote.is_some() {
|
||||
self.leave_server(None)?;
|
||||
} else if let Some(title) = self.title.as_mut() {
|
||||
title.escape();
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"quit" => {
|
||||
self.begin_closing_save(true)?;
|
||||
ok()
|
||||
|
||||
@@ -89,6 +89,12 @@ pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub targets: Vec<(Who, Aabb)>,
|
||||
}
|
||||
impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
|
||||
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
|
||||
let state = self.w.world.block(p.x, p.y, p.z);
|
||||
(state, &self.w.facts.get(state).name)
|
||||
});
|
||||
}
|
||||
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: self.w.world,
|
||||
@@ -1201,7 +1207,13 @@ impl Game<'_> {
|
||||
self.villager_murdered(m, slain_by_player, killer.flatten());
|
||||
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
|
||||
let killer_entity = self.killer_loot_entity(killer.flatten());
|
||||
self.drop_death_loot(&mut dying, by_player, killer_entity, consumed, pos);
|
||||
// LAST_DAMAGE_PLAYER only while the player-hurt memory lasts
|
||||
// (LivingEntity.dropFromLootTable's playerKilled): a mob that dies to
|
||||
// the environment long after a player's hit names no player.
|
||||
let player = killer
|
||||
.filter(|_| m.ai.last_hurt_by_player > 0)
|
||||
.and_then(|conn| self.killer_uuid(conn));
|
||||
self.drop_death_loot(&mut dying, player, by_player, killer_entity, consumed, pos);
|
||||
self.create_wither_rose(m);
|
||||
if m.species() == Species::CopperGolem {
|
||||
// CopperGolem.dropEquipment: the preserved equipment too.
|
||||
@@ -1318,9 +1330,26 @@ impl Game<'_> {
|
||||
}
|
||||
own(self)
|
||||
}
|
||||
/// The UUID of the player `last_hurt_by_player` keeps: the guest on
|
||||
/// connection `conn` (None once they left), else the player in place
|
||||
/// (None on a server, which has none of its own).
|
||||
fn killer_uuid(&mut self, conn: Option<u32>) -> Option<u128> {
|
||||
match conn {
|
||||
// During that guest's own turn, the hub is out of `lan`.
|
||||
Some(conn) if self.lan_acting != Some(conn) => {
|
||||
Some(self.lan.as_ref()?.hub.guest(conn)?.uuid)
|
||||
}
|
||||
Some(_) => Some(self.native_acting_id()),
|
||||
None if self.lan_acting.is_none() && self.player.is_some() => {
|
||||
Some(self.native_acting_id())
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
fn drop_death_loot(
|
||||
&mut self,
|
||||
m: &mut Mob,
|
||||
player_uuid: Option<u128>,
|
||||
by_player: bool,
|
||||
killer: horizon_world::loot::LootEntity,
|
||||
consumed: bool,
|
||||
@@ -1403,22 +1432,48 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let raider = horizon_client::mobs::raider::is_raider(m.species());
|
||||
// The item drops come before the carried block and the orbs, so the
|
||||
// mods get them all at once and they still spawn in that order.
|
||||
let mut stacks = Vec::new();
|
||||
let mut rest = Vec::new();
|
||||
for d in items {
|
||||
match d {
|
||||
drops::Drop::Item(stack) => {
|
||||
let item = if raider && stack.item == horizon_client::mobs::raider::BANNER {
|
||||
drops::Drop::Item(stack) => stacks.push(
|
||||
if raider && stack.item == horizon_client::mobs::raider::BANNER {
|
||||
horizon_client::mobs::raider::ominous_banner()
|
||||
} else {
|
||||
from_loot(&stack)
|
||||
};
|
||||
// Every death drop is its own ItemEntity, and the drop
|
||||
// entity's own random (not the level's) gives it the
|
||||
// constructor's horizontal push (its motion otherwise
|
||||
// stays [0, 0.2, 0] and the drops land stacked at the
|
||||
// mob's centre, without disturbing the level random that
|
||||
// the orbs' merge ids and block ticks read next).
|
||||
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
},
|
||||
),
|
||||
drops::Drop::Stack(stack) => stacks.push(stack),
|
||||
other => rest.push(other),
|
||||
}
|
||||
}
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
let table = drops::death_loot_table(m, death.mob_drops).filter(|t| loot.has_table(t));
|
||||
let context = super::native_hooks::NativeLootDrops {
|
||||
table: table.as_deref().unwrap_or_default(),
|
||||
dimension: self.dim.id(),
|
||||
origin: [m.position.x, m.position.y, m.position.z],
|
||||
block: None,
|
||||
entity: Some((m.id, &kind)),
|
||||
player: player_uuid,
|
||||
tool: None,
|
||||
explosion: None,
|
||||
};
|
||||
self.native_loot_drops(&context, &mut stacks);
|
||||
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
|
||||
for item in stacks {
|
||||
// Every death drop is its own ItemEntity, and the drop entity's
|
||||
// own random (not the level's) gives it the constructor's
|
||||
// horizontal push (its motion otherwise stays [0, 0.2, 0] and
|
||||
// the drops land stacked at the mob's centre, without disturbing
|
||||
// the level random that the orbs' merge ids and block ticks read
|
||||
// next).
|
||||
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
for d in rest {
|
||||
match d {
|
||||
drops::Drop::CarriedBlock(state) => {
|
||||
// Enderman.dropCustomDeathLoot: spawnAtLocation for each
|
||||
// of the carried block's silk-touch drops.
|
||||
@@ -1434,7 +1489,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
|
||||
drops::Drop::Orb(_) => {}
|
||||
drops::Drop::Item(_) | drops::Drop::Stack(_) | drops::Drop::Orb(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1599,6 +1654,15 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
}
|
||||
// Projectile.setOwner: the acting player's or the mob's UUID, which
|
||||
// its save keeps even once the owner is gone.
|
||||
if p.owner_uuid.is_none() {
|
||||
p.owner_uuid = match p.owner {
|
||||
Some(Who::Player) => Some(self.acting_player_uuid()),
|
||||
Some(Who::Mob(id)) => Some(self.uuid_for(id)),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
@@ -2218,13 +2282,14 @@ impl Game<'_> {
|
||||
self.destroy_particles(pos, old);
|
||||
}
|
||||
if drop {
|
||||
let drops = world_items!(self).block_drops_in(
|
||||
let mut drops = world_items!(self).block_drops_in(
|
||||
self.pack.assets.registry,
|
||||
old,
|
||||
None,
|
||||
&mut self.world.random,
|
||||
Some((&mut self.rand_sequences, self.level.seed)),
|
||||
);
|
||||
self.native_block_drops(pos, old, None, None, None, &mut drops);
|
||||
for d in drops {
|
||||
self.pop_resource(pos, d);
|
||||
}
|
||||
@@ -3096,7 +3161,7 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
if raised {
|
||||
self.after_block(outcome, amount, attacker, projectile);
|
||||
self.after_block(outcome, attacker, projectile);
|
||||
}
|
||||
outcome.hurt
|
||||
}
|
||||
@@ -3411,9 +3476,10 @@ impl Game<'_> {
|
||||
} = hit
|
||||
&& let Some(j) = wind_charge_hit(id)
|
||||
{
|
||||
let (owner, guest) = (p.owner, p.guest);
|
||||
let (owner, owner_uuid, guest) = (p.owner, p.owner_uuid, p.guest);
|
||||
let look = self.owner_look(owner, guest);
|
||||
super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest);
|
||||
let wind = &mut projectiles[j];
|
||||
super::deflect::aim_deflect(wind, look, owner, owner_uuid, guest);
|
||||
}
|
||||
}
|
||||
let mut idx = 0;
|
||||
@@ -3564,6 +3630,7 @@ impl Game<'_> {
|
||||
ProjectileKind::Arrow { .. }
|
||||
| ProjectileKind::Trident
|
||||
| ProjectileKind::Snowball
|
||||
| ProjectileKind::Thrown(_)
|
||||
| ProjectileKind::Egg
|
||||
| ProjectileKind::SmallFireball
|
||||
| ProjectileKind::LargeFireball { .. }
|
||||
@@ -3860,6 +3927,7 @@ impl Game<'_> {
|
||||
let amount = match p.kind {
|
||||
ProjectileKind::SmallFireball => 5.0,
|
||||
ProjectileKind::LargeFireball { .. } => 6.0,
|
||||
ProjectileKind::Thrown(t) => t.damage,
|
||||
_ => 0.0,
|
||||
};
|
||||
let kind = match p.kind {
|
||||
@@ -3880,6 +3948,7 @@ impl Game<'_> {
|
||||
ProjectileKind::EnderPearl
|
||||
| ProjectileKind::Egg
|
||||
| ProjectileKind::Snowball
|
||||
| ProjectileKind::Thrown(_)
|
||||
| ProjectileKind::LargeFireball { .. }
|
||||
) {
|
||||
return Ok(());
|
||||
@@ -4133,6 +4202,47 @@ impl Game<'_> {
|
||||
self.thrown_break_event(p.id, atp);
|
||||
self.item_burst(atp, "minecraft:snowball");
|
||||
}
|
||||
// a mod's copy of Snowball: its damage to anything it hits.
|
||||
(ProjectileKind::Thrown(t), hit) => {
|
||||
match hit {
|
||||
Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
} => {
|
||||
let push = [p.velocity.x, p.velocity.z];
|
||||
let hurt = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id)
|
||||
.is_some_and(|m| m.damage_projectile(t.damage, source, aim, push));
|
||||
if hurt {
|
||||
self.player_hurt_mob(p, id);
|
||||
}
|
||||
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
|
||||
if hurt
|
||||
&& p.owner == Some(Who::Player)
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
{
|
||||
let victim = self.mob_entity(m);
|
||||
let direct = horizon_client::advancements::EntityView {
|
||||
kind: t.id.into(),
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
self.player_hit(&victim, "minecraft:thrown", Some(direct), t.damage);
|
||||
}
|
||||
}
|
||||
// Player.hurtServer takes no 0 damage.
|
||||
Hit::Entity {
|
||||
who: Who::Player, ..
|
||||
} if t.damage > 0.0 => {
|
||||
let from = [p.previous.x, p.previous.y, p.previous.z];
|
||||
self.mob_hits_player(t.damage, from, "minecraft:thrown");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.thrown_break_event(p.id, atp);
|
||||
self.item_burst(atp, t.item);
|
||||
}
|
||||
(ProjectileKind::Egg, hit) => {
|
||||
if let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
@@ -4450,7 +4560,7 @@ impl Game<'_> {
|
||||
/// client entity's random (the particle engine's here) for an egg.
|
||||
pub(super) fn item_burst(&mut self, at: [f64; 3], item: &str) {
|
||||
let options = self.item_particle(&horizon_client::items::ItemStack::new(item, 1));
|
||||
let egg = item != "minecraft:snowball";
|
||||
let egg = item == "minecraft:egg";
|
||||
for _ in 0..8 {
|
||||
let v = if egg {
|
||||
let r = self.particles.random();
|
||||
@@ -5450,7 +5560,10 @@ impl Game<'_> {
|
||||
return Ok(thrown);
|
||||
}
|
||||
_ if item.ends_with("_spawn_egg") => return self.use_spawn_egg(stack),
|
||||
_ => return Ok(false),
|
||||
_ => match horizon_client::projectiles::thrown_by_item(item) {
|
||||
Some(t) => ProjectileKind::Thrown(t),
|
||||
None => return Ok(false),
|
||||
},
|
||||
};
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return Ok(false);
|
||||
@@ -5480,7 +5593,9 @@ impl Game<'_> {
|
||||
// second is the `use_cooldown` component, as for any thrown item.
|
||||
self.apply_use_cooldown(stack);
|
||||
let sound = match kind {
|
||||
ProjectileKind::Snowball => "minecraft:entity.snowball.throw",
|
||||
ProjectileKind::Snowball | ProjectileKind::Thrown(_) => {
|
||||
"minecraft:entity.snowball.throw"
|
||||
}
|
||||
ProjectileKind::Egg => "minecraft:entity.egg.throw",
|
||||
_ => "minecraft:entity.ender_pearl.throw",
|
||||
};
|
||||
@@ -6080,8 +6195,8 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(stack) = a.potion_item() {
|
||||
let owner = super::item_draw::owner_at(at, 0.0, "minecraft:splash_potion");
|
||||
if let Some(stack) = a.drawn_item() {
|
||||
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
|
||||
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
|
||||
let pose = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_quat(self.camera_orientation());
|
||||
|
||||
@@ -374,7 +374,7 @@ impl Game<'_> {
|
||||
let p = c.position;
|
||||
self.world.modified.insert((c.pos[0] >> 4, c.pos[2] >> 4));
|
||||
if self.riding == Some(c.id) {
|
||||
self.riding = None;
|
||||
self.dismount_removed_vehicle(p.y);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.break",
|
||||
@@ -468,6 +468,49 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
/// `LivingEntity.dismountVehicle` from a removed vehicle (a broken
|
||||
/// cushion) whose position was at `vehicle_y`: up to the higher of the
|
||||
/// rider's and the vehicle's heights, then the free spot nearest there
|
||||
/// (`findFreePosition` within the rider's own box). No get-up sound:
|
||||
/// `Cushion.removePassenger` plays none once the cushion is removed.
|
||||
pub(super) fn dismount_removed_vehicle(&mut self, vehicle_y: f64) {
|
||||
self.riding = None;
|
||||
let border = self.border_bounds();
|
||||
let b = self.res.behaviours();
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = (
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
let mut at = Vec3::new(p.position.x, p.position.y.max(vehicle_y), p.position.z);
|
||||
// `getBbWidth() <= 4 && getBbHeight() <= 4`.
|
||||
if w <= 4.0 && h <= 4.0 {
|
||||
let center = at.add_vector(Vec3::new(0.0, h / 2.0, 0.0));
|
||||
let size = Vec3::new(w, h, w);
|
||||
let free = horizon_world::level::portal_travel::find_free_position(
|
||||
Aabb::of_size(center, size),
|
||||
center,
|
||||
size,
|
||||
&border,
|
||||
|q| {
|
||||
b.collision_shape(self.world.block(q.x, q.y, q.z))
|
||||
.map(|s| s.moved(Vec3::new(f64::from(q.x), f64::from(q.y), f64::from(q.z))))
|
||||
},
|
||||
);
|
||||
if let Some(free) = free {
|
||||
at = free.add_vector(Vec3::new(0.0, -h / 2.0, 0.0));
|
||||
}
|
||||
}
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
|
||||
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
|
||||
let Some(c) = self
|
||||
|
||||
@@ -954,6 +954,20 @@ impl Game<'_> {
|
||||
let p = c.position;
|
||||
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
|
||||
}
|
||||
// Interaction: EntityDimensions.scalable(width, height), so its eyes
|
||||
// are at 0.85 of the height. Its rotation is not kept here.
|
||||
for i in self.interactions.values() {
|
||||
let b = i.bounds();
|
||||
let p = i.position;
|
||||
g.entity_box(
|
||||
[b.min(Axis::X), b.min(Axis::Y), b.min(Axis::Z)],
|
||||
[b.max(Axis::X), b.max(Axis::Y), b.max(Axis::Z)],
|
||||
[p.x, p.y, p.z],
|
||||
i.height.max(0.0) * 0.85,
|
||||
false,
|
||||
(0.0, 0.0),
|
||||
);
|
||||
}
|
||||
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
|
||||
// ArmorStand.getDimensions: a marker has none, a small one is
|
||||
// half scale with its eyes at 0.9875.
|
||||
@@ -962,8 +976,13 @@ impl Game<'_> {
|
||||
(_, true) => ((0.25, 0.9875), 0.9875),
|
||||
_ => ((0.5, 1.975), 1.7775),
|
||||
};
|
||||
let p = a.position;
|
||||
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
|
||||
g.hitbox(
|
||||
at(a.previous, a.position),
|
||||
size,
|
||||
eye,
|
||||
true,
|
||||
(0.0, a.render_yaw(alpha)),
|
||||
);
|
||||
}
|
||||
for b in &self.boats {
|
||||
let h = super::boats::HEIGHT as f32;
|
||||
@@ -1023,9 +1042,12 @@ impl Game<'_> {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
let (size, eye) = match p.kind {
|
||||
K::Arrow { .. } | K::Trident => (0.5, 0.13),
|
||||
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
|
||||
(0.25, 0.2125)
|
||||
}
|
||||
K::Snowball
|
||||
| K::Egg
|
||||
| K::EnderPearl
|
||||
| K::LlamaSpit
|
||||
| K::Potion { .. }
|
||||
| K::Thrown(_) => (0.25, 0.2125),
|
||||
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
|
||||
(0.3125, 0.265_625)
|
||||
}
|
||||
|
||||
@@ -1720,6 +1720,55 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The anticheat bans a player (`HostEvent::Ban`): `banned-players.json` gets it as `/ban` adds it,
|
||||
/// with the anticheat as its source, and the player is sent away with the ban's message.
|
||||
pub(super) fn anticheat_ban(
|
||||
&mut self,
|
||||
hub: &mut horizon_client::lan::hub::Hub,
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
reason: &str,
|
||||
) {
|
||||
let Some(lists) = &self.server_lists else {
|
||||
// No ban list to add to: the anticheat's kick, so a flagged cheater still doesn't stay.
|
||||
log(
|
||||
"WARN",
|
||||
&format!("Can't ban {name} ({reason}): no ban list; kicked"),
|
||||
);
|
||||
let conns: Vec<u32> = hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.uuid == uuid)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
hub.anticheat_kick(conn);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let user = NameAndId {
|
||||
id: uuid,
|
||||
name: name.to_owned(),
|
||||
};
|
||||
lists.lock().unwrap().bans.add(BanEntry::new(
|
||||
user,
|
||||
Some("Horizon anticheat".into()),
|
||||
None,
|
||||
Some(reason.to_owned()),
|
||||
));
|
||||
log("INFO", &format!("Banned {name}: {reason}"));
|
||||
let conns: Vec<u32> = hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.uuid == uuid)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
hub.kick(conn, translatable("multiplayer.disconnect.banned", &[]));
|
||||
}
|
||||
events::record("ban", name, uuid, serde_json::json!({ "reason": reason }));
|
||||
}
|
||||
|
||||
pub(super) fn kick_uuid(&mut self, uuid: u128, reason: Tag) {
|
||||
if let Some(h) = self.lan.as_mut() {
|
||||
let conns: Vec<u32> = h
|
||||
|
||||
@@ -34,14 +34,14 @@ pub(super) fn aim_deflect(
|
||||
p: &mut Projectile,
|
||||
look: Option<Vec3>,
|
||||
owner: Option<Who>,
|
||||
owner_uuid: Option<[i32; 4]>,
|
||||
guest: Option<u32>,
|
||||
) {
|
||||
if let Some(look) = look {
|
||||
p.velocity = look;
|
||||
p.needs_sync = true;
|
||||
}
|
||||
p.owner = owner;
|
||||
p.guest = guest;
|
||||
(p.owner, p.owner_uuid, p.guest) = (owner, owner_uuid, guest);
|
||||
p.acceleration = 0.1;
|
||||
}
|
||||
|
||||
@@ -196,14 +196,14 @@ impl Game<'_> {
|
||||
/// `Projectile.deflect(AIM_DEFLECT, player, player, true)`.
|
||||
pub(super) fn deflect(&mut self, i: usize) {
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let guest = self.lan_acting;
|
||||
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
|
||||
let p = &mut self.creatures.projectiles[i];
|
||||
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
|
||||
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
|
||||
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
|
||||
p.owner = Some(Who::Player);
|
||||
// On a server, the guest who struck it (their projectile now).
|
||||
p.guest = guest;
|
||||
(p.guest, p.owner_uuid) = (guest, owner_uuid);
|
||||
// Not into the one who turned it on its way out.
|
||||
p.left_owner = false;
|
||||
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
|
||||
@@ -274,9 +274,9 @@ mod tests {
|
||||
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
|
||||
let mut p = wind_charge();
|
||||
let look = Vec3::new(0.0, -1.0, 0.0);
|
||||
aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3));
|
||||
aim_deflect(&mut p, Some(look), Some(Who::Player), Some([4; 4]), Some(3));
|
||||
assert_eq!(p.velocity, look);
|
||||
assert_eq!(p.owner, Some(Who::Player));
|
||||
assert_eq!((p.owner, p.owner_uuid), (Some(Who::Player), Some([4; 4])));
|
||||
assert_eq!(p.guest, Some(3));
|
||||
assert_eq!(p.acceleration, 0.1);
|
||||
assert!(p.needs_sync);
|
||||
@@ -285,9 +285,10 @@ mod tests {
|
||||
#[test]
|
||||
fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() {
|
||||
let mut p = wind_charge();
|
||||
aim_deflect(&mut p, None, None, None);
|
||||
p.owner_uuid = Some([7; 4]);
|
||||
aim_deflect(&mut p, None, None, None, None);
|
||||
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
|
||||
assert_eq!(p.owner, None);
|
||||
assert_eq!((p.owner, p.owner_uuid), (None, None));
|
||||
assert_eq!(p.acceleration, 0.1);
|
||||
assert!(!p.needs_sync);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ use horizon_client::discord_presence::{self as presence, Dimension, Play, Status
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the presence thread.
|
||||
pub(super) fn update_discord_presence(&self) {
|
||||
// One Discord status per computer: P1's, not a splitscreen player's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
presence::set(self.discord_status());
|
||||
}
|
||||
fn discord_status(&self) -> Status {
|
||||
|
||||
@@ -357,7 +357,7 @@ impl Game<'_> {
|
||||
for p in containers {
|
||||
let entity = &self.world.block_entities[&p];
|
||||
let single = match entity {
|
||||
BlockEntity::Jukebox(j) => std::slice::from_ref(&j.record),
|
||||
BlockEntity::Jukebox(j) => &j.items,
|
||||
_ => entity.slots(),
|
||||
};
|
||||
for (i, slot) in single.iter().enumerate() {
|
||||
@@ -744,7 +744,7 @@ impl Game<'_> {
|
||||
let jukebox =
|
||||
&*self.res.facts().get(self.world.block(p.0, p.1, p.2)).name == "minecraft:jukebox";
|
||||
match self.world.block_entities.get_mut(&p) {
|
||||
Some(BlockEntity::Jukebox(j)) => j.record = stack,
|
||||
Some(BlockEntity::Jukebox(j)) => j.items[0] = stack,
|
||||
Some(BlockEntity::Lectern(l)) => l.book = stack,
|
||||
Some(BlockEntity::Campfire(c)) => {
|
||||
if let Some(cell) = c.items.get_mut(slot) {
|
||||
@@ -761,7 +761,7 @@ impl Game<'_> {
|
||||
self.world.block_entities.insert(
|
||||
p,
|
||||
BlockEntity::Jukebox(horizon_client::containers::Jukebox {
|
||||
record: stack,
|
||||
items: [stack],
|
||||
ticks: 0,
|
||||
playing: false,
|
||||
}),
|
||||
|
||||
@@ -67,6 +67,15 @@ fn y_rot(d: Direction) -> f32 {
|
||||
_ => 270.0,
|
||||
}
|
||||
}
|
||||
/// `DirectionalPlaceContext.getRotation`: `get2DDataValue() * 90`, so a
|
||||
/// vertical direction (`data2d = -1`) gives -90 where `toYRot` gives 270: the
|
||||
/// same horizontal facing, but the raw value a mod's placement hook reads.
|
||||
fn place_rotation(d: Direction) -> f32 {
|
||||
match d {
|
||||
Direction::Up | Direction::Down => -90.0,
|
||||
_ => y_rot(d),
|
||||
}
|
||||
}
|
||||
fn block_box(p: Pos) -> Aabb {
|
||||
Aabb::new(
|
||||
Vec3::new(f64::from(p.0), f64::from(p.1), f64::from(p.2)),
|
||||
@@ -90,6 +99,15 @@ fn shrink(mut stack: ItemStack) -> Option<ItemStack> {
|
||||
/// A dispenser's answer: the slot's new content and the level event played.
|
||||
type Dispensed = (Option<ItemStack>, i32);
|
||||
|
||||
/// A dispenser hook's answer: the slot's remainder (None empties it) and the
|
||||
/// level event to play, `CLICK` (1000), `FAIL` (1001) or 0 for none.
|
||||
pub(super) type NativeDispensed = (Option<ItemStack>, i32);
|
||||
|
||||
/// None keeps vanilla's behaviour; an explicit empty remainder consumes the stack.
|
||||
fn mod_dispensed(answer: Option<NativeDispensed>) -> Option<Dispensed> {
|
||||
answer.map(|(left, event)| (left.filter(|stack| stack.count > 0), event))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// `OptionalDispenseItemBehavior.success` of the registered behaviours
|
||||
/// that leave it unset on some paths (brush, TNT, honeycomb, chest).
|
||||
@@ -495,6 +513,29 @@ impl Game<'_> {
|
||||
self.spawn_dispensed(pos, facing, one, 6);
|
||||
(stack.count > 0).then_some(stack)
|
||||
}
|
||||
/// Drop one item as DefaultDispenseItemBehavior does. For a dispenser hook:
|
||||
/// return this remainder in its answer with `CLICK`; the caller plays the
|
||||
/// answer's event and smoke once. A client, another dimension or an unloaded
|
||||
/// chunk keeps the input stack unchanged. API @API:dispenser_behaviour@.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_dispense_default(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
facing: Direction,
|
||||
stack: ItemStack,
|
||||
) -> Option<ItemStack> {
|
||||
if !(self.serving.is_some() || self.options.dedicated.is_some())
|
||||
|| dimension != self.dim.id()
|
||||
|| !self.world.chunks.contains_key(&(pos[0] >> 4, pos[2] >> 4))
|
||||
{
|
||||
return Some(stack);
|
||||
}
|
||||
if stack.count == 0 {
|
||||
return None;
|
||||
}
|
||||
self.default_execute((pos[0], pos[1], pos[2]), facing, stack)
|
||||
}
|
||||
/// `new DefaultDispenseItemBehavior().dispense(..)` including its events.
|
||||
pub(super) fn dispense_default(
|
||||
&mut self,
|
||||
@@ -584,6 +625,25 @@ impl Game<'_> {
|
||||
facing: Direction,
|
||||
stack: ItemStack,
|
||||
) -> Result<Option<ItemStack>> {
|
||||
let mut answer = None;
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_dispense_item(
|
||||
self.dim.id(),
|
||||
[pos.0, pos.1, pos.2],
|
||||
facing,
|
||||
&stack,
|
||||
&mut answer,
|
||||
);
|
||||
}
|
||||
if let Some((left, event)) = mod_dispensed(answer) {
|
||||
// DefaultDispenseItemBehavior.dispense: playSound, then playAnimation;
|
||||
// a NOOP behaviour (event 0) plays neither.
|
||||
if event != 0 {
|
||||
self.dispenser_event(event, pos, 0);
|
||||
self.dispenser_event(2000, pos, data_value(facing));
|
||||
}
|
||||
return Ok(left);
|
||||
}
|
||||
let item = stack.item.to_string();
|
||||
let (left, event) = match item.as_str() {
|
||||
"minecraft:arrow"
|
||||
@@ -1530,7 +1590,7 @@ impl Game<'_> {
|
||||
f64::from(front.2) + 0.5,
|
||||
),
|
||||
inside: false,
|
||||
yaw: y_rot(facing),
|
||||
yaw: place_rotation(facing),
|
||||
pitch: 0.0,
|
||||
sneaking: false,
|
||||
item: stack.item.to_string(),
|
||||
@@ -2148,3 +2208,27 @@ pub(super) fn tipped_effect(stack: &ItemStack) -> Option<(&'static str, i32)> {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mod_dispenser_answers_preserve_fallback_and_own_consumption() {
|
||||
assert!(
|
||||
mod_dispensed(None).is_none(),
|
||||
"no registered behaviour keeps vanilla"
|
||||
);
|
||||
assert_eq!(mod_dispensed(Some((None, CLICK))), Some((None, CLICK)));
|
||||
let stack = ItemStack::new("minecraft:stone", 7);
|
||||
let Some((Some(left), event)) = mod_dispensed(Some((Some(stack.clone()), FAIL))) else {
|
||||
panic!("handled stack")
|
||||
};
|
||||
assert_eq!(left, stack, "the returned remainder is not consumed again");
|
||||
assert_eq!(event, FAIL, "the answer chooses its sound");
|
||||
assert_eq!(
|
||||
mod_dispensed(Some((Some(ItemStack { count: 0, ..stack }), 0))),
|
||||
Some((None, 0))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,7 @@ impl Game<'_> {
|
||||
};
|
||||
let Some(m) = self.mobs.get(mob) else { return };
|
||||
let kind = m.kind.id.clone();
|
||||
let victim = m.id;
|
||||
let ctx = EffectContext {
|
||||
this: Some(&kind),
|
||||
attacker: Some("minecraft:player"),
|
||||
@@ -187,6 +188,7 @@ impl Game<'_> {
|
||||
// ignite (fire aspect) and apply_mob_effect (bane of arthropods).
|
||||
let mut ignite = 0.0f32;
|
||||
let mut mob_effects = Vec::new();
|
||||
let mut hook_calls: Vec<(String, String, Value, u32)> = Vec::new();
|
||||
for (id, level) in held.enchantments() {
|
||||
let Some(def) = world_stations!(self).enchantments.get(id) else {
|
||||
continue;
|
||||
@@ -197,14 +199,17 @@ impl Game<'_> {
|
||||
.flatten()
|
||||
{
|
||||
let e = &effect["effect"];
|
||||
let kind_of = e["type"].as_str();
|
||||
if effect["enchanted"] != "attacker"
|
||||
|| effect["affected"] != "victim"
|
||||
|| !matches!(
|
||||
kind_of,
|
||||
Some("minecraft:ignite" | "minecraft:apply_mob_effect")
|
||||
)
|
||||
{
|
||||
if effect["enchanted"] != "attacker" || effect["affected"] != "victim" {
|
||||
continue;
|
||||
}
|
||||
// A bare type name is minecraft's own, as a bare item id is.
|
||||
let kind_of = match e["type"].as_str() {
|
||||
Some(k) if k.contains(':') => k.to_owned(),
|
||||
Some(k) => format!("minecraft:{k}"),
|
||||
None => continue,
|
||||
};
|
||||
let own = !kind_of.starts_with("minecraft:");
|
||||
if !own && !matches!(&*kind_of, "minecraft:ignite" | "minecraft:apply_mob_effect") {
|
||||
continue;
|
||||
}
|
||||
let mut random = self.world.random.clone();
|
||||
@@ -218,7 +223,11 @@ impl Game<'_> {
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
if kind_of == Some("minecraft:ignite") {
|
||||
// A mod's own kind: its hook answers, after this loop (the
|
||||
// stations borrow is held here).
|
||||
if own {
|
||||
hook_calls.push((id.to_string(), kind_of, e.clone(), *level));
|
||||
} else if kind_of == "minecraft:ignite" {
|
||||
ignite =
|
||||
ignite.max(horizon_client::enchant::level_value(&e["duration"], *level));
|
||||
} else {
|
||||
@@ -235,6 +244,9 @@ impl Game<'_> {
|
||||
for (effect, level) in mob_effects {
|
||||
self.apply_mob_effect(mob, &effect, level);
|
||||
}
|
||||
for (id, kind_of, data, level) in hook_calls {
|
||||
self.native_enchant_effect(&id, &kind_of, &data, level, victim);
|
||||
}
|
||||
self.post_attack_bursts(&held, &kind);
|
||||
}
|
||||
/// `EnchantmentHelper.doPostPiercingAttackEffects`: the
|
||||
|
||||
@@ -435,7 +435,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if raised {
|
||||
self.after_block(outcome, amount, None, false);
|
||||
self.after_block(outcome, None, false);
|
||||
}
|
||||
pushed
|
||||
}
|
||||
@@ -941,7 +941,7 @@ impl Game<'_> {
|
||||
// tables' survives_explosion and explosion_decay draw from their
|
||||
// random sequence.
|
||||
let decay = (interaction == BlockInteraction::DestroyWithDecay).then_some(radius);
|
||||
let drops = world_items!(self).block_drops_exploded(
|
||||
let mut drops = world_items!(self).block_drops_exploded(
|
||||
self.pack.assets.registry,
|
||||
state,
|
||||
None,
|
||||
@@ -951,6 +951,7 @@ impl Game<'_> {
|
||||
decay,
|
||||
false,
|
||||
);
|
||||
self.native_block_drops(pos, state, None, None, Some(radius), &mut drops);
|
||||
for mut stack in drops {
|
||||
if &*stack.item == "minecraft:air" || stack.count == 0 {
|
||||
continue;
|
||||
|
||||
@@ -93,13 +93,18 @@ impl Game<'_> {
|
||||
}
|
||||
/// Auto-reconnect's next attempt after a disconnect, when it is on and
|
||||
/// the reason allows one.
|
||||
pub(super) fn reconnect_after(&mut self, reason: &str, from_server: bool) -> Option<Retry> {
|
||||
pub(super) fn reconnect_after(
|
||||
&mut self,
|
||||
reason: &str,
|
||||
key: Option<&str>,
|
||||
from_server: bool,
|
||||
) -> Option<Retry> {
|
||||
if !self.settings.extras.auto_reconnect {
|
||||
self.extras.reconnect.reset();
|
||||
return None;
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
self.extras.reconnect.ended(reason, from_server, now)
|
||||
self.extras.reconnect.ended(reason, key, from_server, now)
|
||||
}
|
||||
/// Show the attempt's countdown on the Disconnected screen.
|
||||
pub(super) fn offer_reconnect(&mut self, retry: Option<Retry>) {
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
pose::Pose,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, trig},
|
||||
geometry::{Aabb, Vec3, traverse_blocks, trig},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
@@ -68,6 +68,12 @@ fn bounds(p: Vec3) -> Aabb {
|
||||
fn triangle(r: &mut impl RandomSource, mode: f64, deviation: f64) -> f64 {
|
||||
mode + deviation * (f64::from(r.next_float()) - f64::from(r.next_float()))
|
||||
}
|
||||
/// `FishingHook.onHitBlock`: `getDeltaMovement().normalize().scale(hit.distanceTo(this))`,
|
||||
/// where `HitResult.distanceTo` is the *squared* distance to the hook: the bobber
|
||||
/// springs back a little off a wall instead of stopping dead.
|
||||
fn rebound(velocity: Vec3, hit: Vec3, at: Vec3) -> Vec3 {
|
||||
velocity.normalize().scale(hit.distance_squared(at))
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn holds_rod(&self) -> bool {
|
||||
self.held()
|
||||
@@ -568,6 +574,28 @@ impl Game<'_> {
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
// checkCollision: the block the path hits before the move, then
|
||||
// `onHitBlock`'s rebound.
|
||||
let to = h.position.add_vector(h.velocity);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let hit = traverse_blocks(h.position, to, 1024, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.facts().is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(h.position, to, p)
|
||||
});
|
||||
if let Some(hit) = hit.ok().flatten() {
|
||||
h.velocity = rebound(h.velocity, hit.location, h.position);
|
||||
}
|
||||
}
|
||||
State::Hooked(id) => {
|
||||
// updateHookedEntity: ride the hooked mob, player or item at 0.8 of its height.
|
||||
@@ -1291,3 +1319,17 @@ impl Game<'_> {
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::rebound;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// `HitResult.distanceTo` is squared: a hit 0.5 away rebounds at 0.25.
|
||||
#[test]
|
||||
fn a_block_hit_rebounds_by_the_squared_distance() {
|
||||
let v = Vec3::new(0.0, 0.0, -2.0);
|
||||
let hit = Vec3::new(0.0, 0.0, -0.5);
|
||||
assert_eq!(rebound(v, hit, Vec3::ZERO), Vec3::new(0.0, 0.0, -0.25));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! `--headless` (#4951): a client with no display, no window and no renderer.
|
||||
//!
|
||||
//! winit's event loop needs a display server even when no window is shown, so a headless game
|
||||
//! runs this loop instead. Each step is what winit's `about_to_wait` does (`Game::idle`: stop
|
||||
//! requests, the closing save, world entry, the control socket), and `idle` runs the frame
|
||||
//! (the client tick, nothing drawn) through the same `RedrawRequested` path a window's game
|
||||
//! takes. Input comes only from the control socket (`horizon-ctl`), as synthetic window events.
|
||||
|
||||
use super::Game;
|
||||
use super::event_loop::EventLoopCtl;
|
||||
use std::{
|
||||
cell::Cell,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use winit::event_loop::ControlFlow;
|
||||
|
||||
/// Time between headless frames. The client ticks at 20 Hz whatever the frame rate (a frame
|
||||
/// runs the ticks its elapsed time owes), so one frame per tick does all the work there is
|
||||
/// without spending a core on frames nobody sees.
|
||||
const FRAME: Duration = Duration::from_millis(50);
|
||||
|
||||
/// The loop's side of `EventLoopCtl`: what the game asked for during a step.
|
||||
struct HeadlessLoop {
|
||||
exit: Cell<bool>,
|
||||
flow: Cell<ControlFlow>,
|
||||
}
|
||||
|
||||
impl EventLoopCtl for HeadlessLoop {
|
||||
fn exit(&self) {
|
||||
self.exit.set(true);
|
||||
}
|
||||
fn set_control_flow(&self, flow: ControlFlow) {
|
||||
self.flow.set(flow);
|
||||
}
|
||||
}
|
||||
|
||||
impl HeadlessLoop {
|
||||
/// When the step that started at `started` wants the next one: at the time the game asked
|
||||
/// for (`WaitUntil`, as the closing save's polls do), else one frame later.
|
||||
fn next_step(&self, started: Instant) -> Instant {
|
||||
match self.flow.get() {
|
||||
ControlFlow::WaitUntil(at) => at,
|
||||
ControlFlow::Poll | ControlFlow::Wait => started + FRAME,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Runs the game until it asks to stop (`horizon-ctl quit`, a signal, an error), then
|
||||
/// saves as a window's game does when its loop ends.
|
||||
pub(super) fn run_headless(&mut self) {
|
||||
let ctl = HeadlessLoop {
|
||||
exit: Cell::new(false),
|
||||
flow: Cell::new(ControlFlow::Poll),
|
||||
};
|
||||
self.previous = Instant::now();
|
||||
while !ctl.exit.get() {
|
||||
let started = Instant::now();
|
||||
ctl.flow.set(ControlFlow::Poll);
|
||||
self.idle(&ctl);
|
||||
if let Some(wait) = ctl
|
||||
.next_step(started)
|
||||
.checked_duration_since(Instant::now())
|
||||
{
|
||||
std::thread::sleep(wait);
|
||||
}
|
||||
}
|
||||
self.shut_down();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctl(flow: ControlFlow) -> HeadlessLoop {
|
||||
HeadlessLoop {
|
||||
exit: Cell::new(false),
|
||||
flow: Cell::new(flow),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steps_come_a_frame_apart_unless_the_game_asks_for_sooner() {
|
||||
let started = Instant::now();
|
||||
assert_eq!(ctl(ControlFlow::Poll).next_step(started), started + FRAME);
|
||||
let soon = started + Duration::from_millis(4);
|
||||
assert_eq!(ctl(ControlFlow::WaitUntil(soon)).next_step(started), soon);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_is_remembered_for_the_loop() {
|
||||
let c = ctl(ControlFlow::Poll);
|
||||
assert!(!c.exit.get());
|
||||
EventLoopCtl::exit(&c);
|
||||
assert!(c.exit.get());
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,9 @@ struct Flags {
|
||||
entity_distance: AtomicU32,
|
||||
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
|
||||
saving: AtomicBool,
|
||||
/// Splitscreen players joined through memory (offline uuids): let in although the world is
|
||||
/// not open to the LAN, and the game no longer pauses while they play.
|
||||
split_players: Mutex<Vec<u128>>,
|
||||
/// The generators of the levels the server has run, by dimension: what
|
||||
/// F3's chunk generation entry asks `ServerChunkCache.getGenerator`.
|
||||
terrain: Mutex<
|
||||
@@ -120,7 +123,42 @@ impl Integrated {
|
||||
}
|
||||
/// `IntegratedServer.tickServer` skips the world while the game is paused.
|
||||
pub fn set_paused(&self, paused: bool) {
|
||||
self.flags.paused.store(paused, Ordering::Relaxed);
|
||||
let split = !self.flags.split_players.lock().unwrap().is_empty();
|
||||
self.flags.paused.store(paused && !split, Ordering::Relaxed);
|
||||
}
|
||||
/// Splitscreen: `name` left; the game pauses again once every such player has. The owner's
|
||||
/// permissions it was given go with it (a LAN player could otherwise join under that name).
|
||||
pub fn leave_split(&self, name: &str) {
|
||||
let uuid = horizon_protocol::crypto::offline_uuid(name);
|
||||
self.flags
|
||||
.split_players
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|&u| u != uuid);
|
||||
self.call(move |g| {
|
||||
if let Some(host) = g.lan.as_mut() {
|
||||
host.hub.ops.remove(&uuid);
|
||||
}
|
||||
});
|
||||
}
|
||||
/// Splitscreen: a connection through memory for another player on this machine, who gets
|
||||
/// the owner's permissions. `None` once the server has stopped.
|
||||
pub fn join_split(&self, name: &str) -> Option<horizon_protocol::connection::Pipe> {
|
||||
let uuid = horizon_protocol::crypto::offline_uuid(name);
|
||||
self.flags.split_players.lock().unwrap().push(uuid);
|
||||
let pipe = self
|
||||
.call(move |g| {
|
||||
let host = g.lan.as_mut()?;
|
||||
let owner = host.owner.as_ref()?.uuid;
|
||||
let level = host.hub.ops.get(&owner).copied().unwrap_or(0);
|
||||
host.hub.ops.insert(uuid, level);
|
||||
host.hub.lan.connect_local().ok()
|
||||
})
|
||||
.flatten();
|
||||
if pipe.is_none() {
|
||||
self.leave_split(name);
|
||||
}
|
||||
pipe
|
||||
}
|
||||
/// The window's render and simulation distances and its Entity Distance
|
||||
/// option, for the server to follow.
|
||||
@@ -679,14 +717,25 @@ impl Game<'_> {
|
||||
};
|
||||
let owner = launch.owner;
|
||||
let published = flags.clone();
|
||||
let local_players = flags.clone();
|
||||
let net = NetConfig {
|
||||
// No network until it is opened to the LAN; the memory connection
|
||||
// is not compressed (`Connection.isMemoryConnection`).
|
||||
listen: false,
|
||||
online: horizon_client::lan::OnlineMode::lan(),
|
||||
gate: Some(Arc::new(move |_, uuid, _| {
|
||||
(uuid != owner && published.published.load(Ordering::Relaxed) == 0)
|
||||
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
|
||||
// Registered local seats share this computer; the vanilla LAN capacity still
|
||||
// applies to other connections when the world is published.
|
||||
bypasses_limit: Some(Arc::new(move |uuid| {
|
||||
local_players.split_players.lock().unwrap().contains(&uuid)
|
||||
})),
|
||||
gate: Some(Arc::new(move |address, uuid, _| {
|
||||
refused(
|
||||
uuid == owner,
|
||||
published.published.load(Ordering::Relaxed) != 0,
|
||||
published.split_players.lock().unwrap().contains(&uuid),
|
||||
address == horizon_client::lan::conn::MEMORY_ADDRESS,
|
||||
)
|
||||
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
|
||||
})),
|
||||
levels: self.level_shapes,
|
||||
owner_profile: launch.owner_profile.clone(),
|
||||
@@ -1033,12 +1082,17 @@ impl Game<'_> {
|
||||
/// `IntegratedServer.setWorldAllowCommands`: the owner is an operator
|
||||
/// while the world allows commands; everyone's level is sent again.
|
||||
pub(super) fn owner_allow_commands(&mut self, allow: bool) {
|
||||
let Some(owner) = self.serving.as_ref().map(|s| s.owner) else {
|
||||
let Some((owner, flags)) = self.serving.as_ref().map(|s| (s.owner, s.flags.clone())) else {
|
||||
return;
|
||||
};
|
||||
let published = self.published_port().is_some();
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.ops.insert(owner, if allow { 4 } else { 0 });
|
||||
let level = if allow { 4 } else { 0 };
|
||||
host.hub.ops.insert(owner, level);
|
||||
// Splitscreen seats have the owner's permissions (`join_split`): they follow it.
|
||||
for &seat in flags.split_players.lock().unwrap().iter() {
|
||||
host.hub.ops.insert(seat, level);
|
||||
}
|
||||
if published {
|
||||
host.hub.config.guest_commands = allow && host.settings.guest_commands;
|
||||
}
|
||||
@@ -1144,10 +1198,38 @@ fn server_game(
|
||||
Game::new(res, pack, gui, options, settings, audio, None)
|
||||
}
|
||||
|
||||
/// Who the integrated server turns away: anyone but the owner until it is opened to the LAN,
|
||||
/// except a splitscreen seat. A seat's name is let in only through memory, so a LAN player
|
||||
/// logging in under it never gets its place or the owner's permissions it holds.
|
||||
fn refused(owner: bool, published: bool, split_seat: bool, memory: bool) -> bool {
|
||||
if split_seat {
|
||||
!memory
|
||||
} else {
|
||||
!owner && !published
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_splitscreen_seat_is_let_in_only_through_memory() {
|
||||
// (owner, published, seat, memory)
|
||||
assert!(!refused(false, false, true, true), "a local seat joins");
|
||||
assert!(
|
||||
refused(false, true, true, false),
|
||||
"a LAN player under a seat's name"
|
||||
);
|
||||
assert!(refused(false, false, true, false));
|
||||
assert!(
|
||||
!refused(false, true, false, false),
|
||||
"anyone once open to the LAN"
|
||||
);
|
||||
assert!(refused(false, false, false, false), "nobody else before");
|
||||
assert!(!refused(true, false, false, true), "the owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_request_does_not_join_a_server_still_saving() {
|
||||
let flags = Arc::new(Flags::default());
|
||||
|
||||
@@ -53,6 +53,47 @@ impl Game<'_> {
|
||||
self.container_changed((pos[0], pos[1], pos[2]));
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
/// `JukeboxBlockEntity.setTheItem` from a container (a hopper or a dropper
|
||||
/// feeding the slot): a disc that lands in a jukebox that is not playing
|
||||
/// starts its song, as the player's own use does, with the block state,
|
||||
/// the song's level event and the neighbour and comparator updates.
|
||||
pub(super) fn jukebox_inserted(&mut self, pos: [i32; 3]) {
|
||||
let Some(song) = self
|
||||
.jukebox(pos)
|
||||
.filter(|j| !j.playing)
|
||||
.and_then(|j| j.record())
|
||||
.and_then(|record| self.res.items().get(&record.item))
|
||||
.and_then(|info| info.jukebox_song.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(BlockEntity::Jukebox(j)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
j.playing = true;
|
||||
j.ticks = 0;
|
||||
}
|
||||
self.set_has_record(pos, true);
|
||||
self.start_song(pos, &song);
|
||||
self.jukebox_changed(pos);
|
||||
}
|
||||
/// `JukeboxBlockEntity.setTheItem(EMPTY)` from a container (a hopper
|
||||
/// below taking the disc): the song stops, the block loses its record,
|
||||
/// and the neighbours and comparators hear it.
|
||||
pub(super) fn jukebox_taken(&mut self, pos: [i32; 3]) {
|
||||
if let Some(BlockEntity::Jukebox(j)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
if j.record().is_some() {
|
||||
return;
|
||||
}
|
||||
j.playing = false;
|
||||
j.ticks = 0;
|
||||
}
|
||||
self.stop_song(pos);
|
||||
self.set_has_record(pos, false);
|
||||
self.jukebox_changed(pos);
|
||||
}
|
||||
/// `JukeboxBlock.useItemOn` / `useWithoutItem` on the jukebox at `pos`.
|
||||
pub(super) fn use_jukebox(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
if &*self
|
||||
@@ -64,7 +105,7 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self.jukebox(pos).is_some_and(|j| j.record.is_some()) {
|
||||
if self.jukebox(pos).is_some_and(|j| j.record().is_some()) {
|
||||
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
|
||||
if self.hand != Hand::Main {
|
||||
return false;
|
||||
@@ -95,7 +136,7 @@ impl Game<'_> {
|
||||
self.world.block_entities.insert(
|
||||
(pos[0], pos[1], pos[2]),
|
||||
BlockEntity::Jukebox(Jukebox {
|
||||
record: Some(record),
|
||||
items: [Some(record)],
|
||||
ticks: 0,
|
||||
playing: true,
|
||||
}),
|
||||
@@ -200,7 +241,7 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Jukebox(j)) => {
|
||||
j.playing = false;
|
||||
j.ticks = 0;
|
||||
j.record.take()
|
||||
j.items[0].take()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
@@ -224,7 +265,7 @@ impl Game<'_> {
|
||||
/// `JukeboxSongPlayer.tick` of the jukebox at `key`, while it plays.
|
||||
pub(super) fn tick_jukebox(&mut self, key: (i32, i32, i32)) {
|
||||
let playing = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Jukebox(j)) if j.playing => j.record.as_ref().and_then(|r| {
|
||||
Some(BlockEntity::Jukebox(j)) if j.playing => j.record().and_then(|r| {
|
||||
let song = self.items().get(&r.item)?.jukebox_song.clone()?;
|
||||
Some((song, j.ticks))
|
||||
}),
|
||||
@@ -314,8 +355,7 @@ pub(super) fn jukebox_signals(
|
||||
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
|
||||
Some(BlockEntity::Jukebox(j)) => {
|
||||
let comparator = j
|
||||
.record
|
||||
.as_ref()
|
||||
.record()
|
||||
.and_then(|r| items.get(&r.item)?.jukebox_song.as_ref())
|
||||
.and_then(|s| items.songs.get(s))
|
||||
.map_or(0, |s| s.comparator);
|
||||
|
||||
@@ -1482,6 +1482,25 @@ impl Game<'_> {
|
||||
/// `PlayerList.removeAll`: every guest leaves in turn, the vehicle it
|
||||
/// alone rides with it (`PlayerList.remove`), and is saved.
|
||||
pub(super) fn save_leaving_guests(&mut self, host: &mut LanHost) {
|
||||
// Every open menu closes first, in its guest's level
|
||||
// (`ServerPlayer.disconnect` -> `doCloseContainer`), so the cursor,
|
||||
// the trade and the inputs are back in the file that is saved.
|
||||
let back = self.dim;
|
||||
let open: Vec<(u32, super::Dim)> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| (g.conn, super::Dim::from_id(&g.dimension)))
|
||||
.collect();
|
||||
for (conn, dim) in open {
|
||||
if self.enter_level(Some(host), dim).is_ok() {
|
||||
self.guest_close_all(host, conn);
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
for i in 0..host.hub.guests.len() {
|
||||
let mut g = host.hub.guests.remove(i);
|
||||
self.take_parent_vehicle(host, &mut g);
|
||||
@@ -1490,15 +1509,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Save a guest when they leave or the world closes (after
|
||||
/// `take_parent_vehicle`): an integrated server's owner with the world
|
||||
/// (`save_game`), anyone else in their file.
|
||||
/// `take_parent_vehicle`, `PlayerList.save`): an integrated server's owner
|
||||
/// into the level (`/data`) and both its files (`playerdata::save_owner`),
|
||||
/// anyone else into their own file.
|
||||
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
|
||||
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) && guest.placed {
|
||||
// The world's own player state rides in the level (`/data`).
|
||||
playerdata::store(guest, &mut self.level, None);
|
||||
}
|
||||
if guest.placed {
|
||||
// PlayerList.save/remove: every player saves to their own file.
|
||||
if guest.placed && host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
|
||||
playerdata::save_owner(guest, &mut self.level, &self.world_root);
|
||||
} else if guest.placed {
|
||||
playerdata::save(guest, &self.world_root);
|
||||
}
|
||||
self.save_guest_records(host, guest);
|
||||
@@ -1900,6 +1917,9 @@ impl Game<'_> {
|
||||
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
|
||||
self.lan_log_event(event);
|
||||
}
|
||||
HostEvent::Ban { uuid, name, reason } => {
|
||||
self.anticheat_ban(&mut host.hub, uuid, &name, &reason);
|
||||
}
|
||||
HostEvent::ChatFiltered {
|
||||
sender,
|
||||
name,
|
||||
@@ -1976,15 +1996,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
HostEvent::Left(mut guest) => {
|
||||
// The container they had open loses an opener.
|
||||
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
|
||||
&& !guest.spectator()
|
||||
{
|
||||
// In the level they were in.
|
||||
// The menu they had open closes, in the level they were in
|
||||
// (`ServerPlayer.disconnect` -> `doCloseContainer`).
|
||||
if guest.placed {
|
||||
let back = self.dim;
|
||||
let theirs = super::Dim::from_id(&guest.dimension);
|
||||
if self.enter_level(Some(&mut host), theirs).is_ok() {
|
||||
self.container_openers_changed(pos, false);
|
||||
let conn = guest.conn;
|
||||
let at = host.hub.guests.len();
|
||||
host.hub.guests.push(*guest);
|
||||
self.guest_close_all(&mut host, conn);
|
||||
*guest = host.hub.guests.remove(at);
|
||||
}
|
||||
if let Err(e) = self.enter_level(Some(&mut host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
@@ -2349,28 +2371,15 @@ impl Game<'_> {
|
||||
let force = host.hub.config.force_game_mode;
|
||||
let root = self.world_root.clone();
|
||||
let in_place = host.hub.config.dimension.clone();
|
||||
let mut owner = host.owner.as_mut();
|
||||
let owner = host.owner.as_mut();
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
let is_owner = owner.as_ref().is_some_and(|o| o.uuid == g.uuid);
|
||||
// PlayerDataStorage.load: the player's own file first; the world's
|
||||
// own player state is the fallback for a first join.
|
||||
let known = if is_owner && playerdata::load(g, &root) {
|
||||
true
|
||||
} else {
|
||||
let saved = if is_owner {
|
||||
owner.as_mut().and_then(|o| o.saved.take())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match saved {
|
||||
Some(level) => {
|
||||
playerdata::apply(g, &level);
|
||||
level.position.is_some()
|
||||
}
|
||||
None => playerdata::load(g, &root),
|
||||
}
|
||||
// PlayerList.loadPlayerData: the owner gets the world's player file,
|
||||
// everyone else their own.
|
||||
let known = match owner.filter(|o| o.uuid == g.uuid) {
|
||||
Some(o) => playerdata::load_owner(g, &root, o.saved.take().as_deref()),
|
||||
None => playerdata::load(g, &root),
|
||||
};
|
||||
if !known || force {
|
||||
g.game_mode = default_mode;
|
||||
|
||||
@@ -295,7 +295,9 @@ impl Game<'_> {
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
let swap = self.enter_guest(host, conn)?;
|
||||
let result = f(self);
|
||||
let dimension =
|
||||
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
|
||||
let result = horizon_client::block_contact::in_level(dimension, || f(self));
|
||||
self.leave_guest(host, swap);
|
||||
Some(result)
|
||||
}
|
||||
@@ -482,6 +484,9 @@ impl Game<'_> {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.is_passenger = g.vehicle.is_some();
|
||||
}
|
||||
// Entity.collide: the guest's body collides with the level's world
|
||||
// border too, as it is this turn (it moves with /worldborder).
|
||||
self.player_border();
|
||||
let host_station = self.station.take();
|
||||
// The guest's bed, sleep counter and respawn point; the host's
|
||||
// respawn point, anchor and action bar stay theirs.
|
||||
@@ -1179,8 +1184,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.guest_close_all(host, conn);
|
||||
let hidden = self.native_hidden_slots(pos, items.len());
|
||||
host.hub
|
||||
.open_container(conn, menu, title, pos, slots, items);
|
||||
@@ -1228,8 +1232,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.guest_close_all(host, conn);
|
||||
host.hub
|
||||
.open_container(conn, menu, title, pos, slots, items);
|
||||
true
|
||||
@@ -1250,6 +1253,38 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes whatever menu the guest has open, the way each `ContainerClose`
|
||||
/// does (`ServerPlayer.doCloseContainer`, `AbstractContainerMenu.removed`):
|
||||
/// the container's lid, a vehicle's stand-in (its items go back to the
|
||||
/// vehicle), the trade (written back to the villager), the crafting table,
|
||||
/// station and mount menus, and the cursor stack back to the inventory.
|
||||
/// Nothing is sent to the client: this is for a guest who is leaving or
|
||||
/// whose server is closing. The guest must be in `host.hub.guests` and the
|
||||
/// level they are in must be the one in place.
|
||||
pub(super) fn guest_close_all(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
if let Some((window, slots)) = host.hub.guests[gi]
|
||||
.menu
|
||||
.as_ref()
|
||||
.map(|m| (m.window, m.slots))
|
||||
{
|
||||
self.guest_container_closed(host, conn);
|
||||
if let Slots::Block(key) = slots
|
||||
&& key.1 == super::minecarts::CART_KEY_Y
|
||||
{
|
||||
self.close_guest_vehicle_menu(host, conn, key);
|
||||
}
|
||||
host.hub.container_closed(conn, window);
|
||||
}
|
||||
self.lan_merchant_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_mount_closed(host, conn, None);
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
|
||||
/// `ContainerEntity`'s `Container.stopOpen` when a vehicle menu closes
|
||||
/// (`AbstractChestBoat.stopOpen` / `MinecartChest.stopOpen`): the menu's
|
||||
/// slots go back to the vehicle, the shared placeholder leaves
|
||||
@@ -1294,15 +1329,7 @@ impl Game<'_> {
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g))
|
||||
.filter(|g| {
|
||||
g.menu.as_ref().is_some_and(|m| {
|
||||
(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|
||||
|| m.pos.is_some_and(|k| {
|
||||
k.1 == super::minecarts::CART_KEY_Y
|
||||
&& !self.chest_vehicle_still_valid(k.2, g)
|
||||
})
|
||||
})
|
||||
})
|
||||
.filter(|g| !self.container_menu_valid(g))
|
||||
.map(|g| {
|
||||
let vehicle = g.menu.as_ref().and_then(|m| match m.slots {
|
||||
Slots::Block(key) if key.1 == super::minecarts::CART_KEY_Y => Some(key),
|
||||
@@ -1323,6 +1350,78 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `stillValid` of the guest's open container: its blocks are still there
|
||||
/// and in reach, or its vehicle exists and is in reach. True with none open.
|
||||
fn container_menu_valid(&self, g: &horizon_client::lan::guest::Guest) -> bool {
|
||||
g.menu.as_ref().is_none_or(|m| {
|
||||
!(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|
||||
&& !m.pos.is_some_and(|k| {
|
||||
k.1 == super::minecarts::CART_KEY_Y && !self.chest_vehicle_still_valid(k.2, g)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.stillValid` of whatever menu the guest has open
|
||||
/// (a container or vehicle, a trade, the crafting table, a station, a
|
||||
/// lectern, a mount's inventory). True when none is open.
|
||||
fn guest_menu_still_valid(&self, host: &LanHost, conn: u32) -> bool {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return true;
|
||||
};
|
||||
if !self.container_menu_valid(g) {
|
||||
return false;
|
||||
}
|
||||
let Some(p) = host.play.get(&conn) else {
|
||||
return true;
|
||||
};
|
||||
p.crafting
|
||||
.table
|
||||
.as_ref()
|
||||
.is_none_or(|t| self.lan_table_valid(g, t.3))
|
||||
&& p.station
|
||||
.as_ref()
|
||||
.is_none_or(|s| self.lan_station_valid(g, s))
|
||||
&& p.lectern
|
||||
.as_ref()
|
||||
.is_none_or(|l| self.lan_lectern_valid(g, l.key))
|
||||
&& p.merchant
|
||||
.as_ref()
|
||||
.and_then(|m| m.merchant.mob)
|
||||
.is_none_or(|id| self.lan_merchant_valid(g, id))
|
||||
&& p.mount_window
|
||||
.as_ref()
|
||||
.is_none_or(|m| self.lan_mount_valid(g, m.mob))
|
||||
}
|
||||
|
||||
/// The check before a menu click: a menu that is no longer valid closes
|
||||
/// (returning what it held, as `ContainerClose` does) and the client's
|
||||
/// window with it, so the click lands on nothing
|
||||
/// (`ServerGamePacketListenerImpl.handleContainerClick` and the others
|
||||
/// ask `stillValid` first).
|
||||
fn guest_validate_menu(&mut self, host: &mut LanHost, conn: u32) {
|
||||
if self.guest_menu_still_valid(host, conn) {
|
||||
return;
|
||||
}
|
||||
let mut windows: Vec<i32> = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.menu.as_ref().map(|m| m.window))
|
||||
.into_iter()
|
||||
.collect();
|
||||
if let Some(p) = host.play.get(&conn) {
|
||||
windows.extend(p.crafting.table.as_ref().map(|t| t.0));
|
||||
windows.extend(p.station.as_ref().map(|s| s.window));
|
||||
windows.extend(p.lectern.as_ref().map(|l| l.window));
|
||||
windows.extend(p.merchant.as_ref().map(|m| m.window));
|
||||
windows.extend(p.mount_window.as_ref().map(|m| m.window));
|
||||
}
|
||||
self.guest_close_all(host, conn);
|
||||
for container in windows {
|
||||
host.hub
|
||||
.send_to(conn, &C::ContainerClose(play::ContainerClose { container }));
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
|
||||
/// boat is still there and within the guest's entity interaction range
|
||||
/// plus 4 of its box.
|
||||
@@ -1434,6 +1533,19 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
packet,
|
||||
S::ContainerClick(_)
|
||||
| S::ContainerButtonClick(_)
|
||||
| S::ContainerSlotStateChanged(_)
|
||||
| S::RenameItem(_)
|
||||
| S::SetBeacon(_)
|
||||
| S::SelectTrade(_)
|
||||
| S::SelectBundleItem(_)
|
||||
| S::PlaceRecipe(_)
|
||||
) {
|
||||
self.guest_validate_menu(host, conn);
|
||||
}
|
||||
let spectator = host.hub.guests[gi].spectator();
|
||||
match packet {
|
||||
S::PlayerAction(a) => match a.action {
|
||||
|
||||
@@ -186,9 +186,27 @@ impl Game<'_> {
|
||||
let targets: Vec<usize> = match (raw, &selector.player_name, selector.uuid) {
|
||||
(Some("@s"), ..) => vec![i],
|
||||
(Some("@a"), ..) => (0..guests.len()).filter(placed).collect(),
|
||||
(_, Some(name), _) => (0..guests.len())
|
||||
.filter(|&k| placed(&k) && guests[k].name.eq_ignore_ascii_case(name))
|
||||
.collect(),
|
||||
(_, Some(name), _) => {
|
||||
let named: Vec<usize> = (0..guests.len())
|
||||
.filter(|&k| placed(&k) && guests[k].name.eq_ignore_ascii_case(name))
|
||||
.collect();
|
||||
if named.is_empty() {
|
||||
// No player has that name: the one going by it as a nickname (`~` optional).
|
||||
let bare = |s: &str| s.strip_prefix('~').unwrap_or(s).to_ascii_lowercase();
|
||||
let wanted = bare(name);
|
||||
let nicknamed = |k: &usize| {
|
||||
host.hub
|
||||
.nicknames
|
||||
.get(&guests[*k].uuid)
|
||||
.is_some_and(|nick| bare(nick) == wanted)
|
||||
};
|
||||
(0..guests.len())
|
||||
.filter(|k| placed(k) && nicknamed(k))
|
||||
.collect()
|
||||
} else {
|
||||
named
|
||||
}
|
||||
}
|
||||
(_, None, Some(uuid)) => (0..guests.len())
|
||||
.filter(|&k| placed(&k) && guests[k].uuid == uuid)
|
||||
.collect(),
|
||||
|
||||
@@ -21,7 +21,7 @@ pub(super) struct GuestCrafting {
|
||||
/// The inventory menu; its grid lives in the hub's guest.
|
||||
inventory: Option<Box<Menu>>,
|
||||
/// The open crafting table: its window, state id, menu and block.
|
||||
table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
|
||||
pub(super) table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
|
||||
/// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`,
|
||||
/// `BrewingStandMenu`); its slots live in the block entity and the hub's
|
||||
/// container menu keeps the client in sync.
|
||||
@@ -436,10 +436,8 @@ impl Game<'_> {
|
||||
|
||||
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
|
||||
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
// The container it replaces loses its opener.
|
||||
self.guest_container_closed(host, conn);
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let title = horizon_protocol::text::translatable("container.crafting", &[]);
|
||||
let Some(window) = host.hub.open_window(conn, "minecraft:crafting", title) else {
|
||||
return;
|
||||
@@ -509,7 +507,7 @@ impl Game<'_> {
|
||||
|
||||
/// `CraftingMenu.stillValid`: the block is still a crafting table and the
|
||||
/// guest is within reach of it.
|
||||
fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
|
||||
pub(super) fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
|
||||
let name = &self
|
||||
.res
|
||||
.facts()
|
||||
|
||||
@@ -12,6 +12,15 @@ use horizon_client::lan::{
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
use horizon_world::{nbt::Tag, registry::BlockStateId};
|
||||
|
||||
/// Allocate once: a native summon may need its id before the tracking tick.
|
||||
pub(super) fn assign_entity_id(next: &mut u32, id: &mut u32) -> u32 {
|
||||
if *id == 0 {
|
||||
*next += 1;
|
||||
*id = *next;
|
||||
}
|
||||
*id
|
||||
}
|
||||
|
||||
fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> EntityState {
|
||||
EntityState {
|
||||
id: id as i32,
|
||||
@@ -125,6 +134,13 @@ impl Game<'_> {
|
||||
.push((11 + k as u8, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// AbstractBoat.DATA_ID_BUBBLE_TIME, back to 0 once the boat is off
|
||||
// the column.
|
||||
if b.bubble_time != 0 {
|
||||
e.meta
|
||||
.push((13, serializer::INT, Value::Int(b.bubble_time)));
|
||||
}
|
||||
e.defaults.push((13, serializer::INT, Value::Int(0)));
|
||||
out.push(e);
|
||||
}
|
||||
for c in &self.carts {
|
||||
@@ -443,10 +459,7 @@ impl Game<'_> {
|
||||
}
|
||||
let next = &mut self.next_entity;
|
||||
let mut give = |id: &mut u32| {
|
||||
if *id == 0 {
|
||||
*next += 1;
|
||||
*id = *next;
|
||||
}
|
||||
assign_entity_id(next, id);
|
||||
};
|
||||
self.tnts.iter_mut().for_each(|t| give(&mut t.id));
|
||||
self.falling.iter_mut().for_each(|f| give(&mut f.id));
|
||||
@@ -729,6 +742,22 @@ fn arrow_meta(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn summoned_ids_are_unique_and_survive_the_tracking_pass() {
|
||||
let mut next = 40;
|
||||
let mut roots = [0; 3];
|
||||
let returned = roots.each_mut().map(|id| assign_entity_id(&mut next, id));
|
||||
assert_eq!(returned, [41, 42, 43]);
|
||||
// Tracking sees the same roots plus an unrelated deferred entity.
|
||||
for id in &mut roots {
|
||||
assign_entity_id(&mut next, id);
|
||||
}
|
||||
let mut later = 0;
|
||||
assert_eq!(assign_entity_id(&mut next, &mut later), 44);
|
||||
assert_eq!(roots, returned);
|
||||
assert_eq!(next, 44);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_stand_poses_send_only_what_differs() {
|
||||
let mut stand = horizon_client::entities::ArmorStand::new(1, Default::default(), 0.0);
|
||||
|
||||
@@ -15,8 +15,8 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// A guest's open mount menu.
|
||||
pub(super) struct GuestMount {
|
||||
window: i32,
|
||||
mob: u32,
|
||||
pub(super) window: i32,
|
||||
pub(super) mob: u32,
|
||||
columns: usize,
|
||||
state: i32,
|
||||
/// The menu's slots and the carried stack the client was last sent.
|
||||
@@ -31,10 +31,8 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
|
||||
self.lan_mount_closed(host, conn, None);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.guest_container_closed(host, conn);
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
|
||||
return;
|
||||
};
|
||||
@@ -182,10 +180,28 @@ impl Game<'_> {
|
||||
closed
|
||||
}
|
||||
|
||||
/// `AbstractMountInventoryMenu.stillValid`: the mount is alive and within
|
||||
/// the entity interaction range (3) plus 4 of the guest's eyes.
|
||||
pub(super) fn lan_mount_valid(&self, g: &horizon_client::lan::guest::Guest, mob: u32) -> bool {
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
|
||||
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
|
||||
let p = [m.position.x, m.position.y, m.position.z];
|
||||
let lo = [p[0] - w, p[1], p[2] - w];
|
||||
let hi = [p[0] + w, p[1] + h, p[2] + w];
|
||||
let d: f64 = (0..3)
|
||||
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
|
||||
.sum();
|
||||
m.alive() && d < 7.0 * 7.0
|
||||
})
|
||||
}
|
||||
|
||||
/// Each tick: a mount menu whose mount died or is out of reach closes
|
||||
/// (`AbstractMountInventoryMenu.stillValid`: alive, within the entity
|
||||
/// interaction range plus 4 of the eyes), and the others follow the
|
||||
/// mount's and the inventory's slots.
|
||||
/// mount's and the inventory's slots. Only the level the player is in
|
||||
/// checks its menu (`ServerPlayer.tick`): another level ticking has
|
||||
/// none of its mobs (#5331).
|
||||
pub(super) fn tick_lan_mount_menus(&mut self, host: &mut LanHost) {
|
||||
let open: Vec<(u32, u32, i32)> = host
|
||||
.play
|
||||
@@ -196,19 +212,10 @@ impl Game<'_> {
|
||||
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
|
||||
continue;
|
||||
};
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let valid = self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
|
||||
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
|
||||
let p = [m.position.x, m.position.y, m.position.z];
|
||||
let lo = [p[0] - w, p[1], p[2] - w];
|
||||
let hi = [p[0] + w, p[1] + h, p[2] + w];
|
||||
let d: f64 = (0..3)
|
||||
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
|
||||
.sum();
|
||||
// Player.ENTITY_INTERACTION_RANGE 3, plus 4.
|
||||
m.alive() && d < 7.0 * 7.0
|
||||
});
|
||||
if valid {
|
||||
if !host.hub.here(g) {
|
||||
continue;
|
||||
}
|
||||
if self.lan_mount_valid(g, mob) {
|
||||
self.lan_send_mount(host, conn);
|
||||
} else {
|
||||
self.lan_mount_closed(host, conn, Some(window));
|
||||
|
||||
@@ -16,12 +16,16 @@ use horizon_client::{
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// The player projectiles that meet other players: arrows (their hurt), and
|
||||
/// snowballs and eggs, which break on them (`Player.hurtServer` takes no 0
|
||||
/// damage). Pearls, potions and wind charges fly past players as before.
|
||||
/// snowballs, eggs and mods' thrown types, which break on them
|
||||
/// (`Player.hurtServer` takes no 0 damage). Pearls, potions and wind charges
|
||||
/// fly past players as before.
|
||||
pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
ProjectileKind::Arrow { .. } | ProjectileKind::Snowball | ProjectileKind::Egg
|
||||
ProjectileKind::Arrow { .. }
|
||||
| ProjectileKind::Snowball
|
||||
| ProjectileKind::Egg
|
||||
| ProjectileKind::Thrown(_)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// A guest's open workstation menu.
|
||||
pub(super) struct GuestStation {
|
||||
window: i32,
|
||||
pub(super) window: i32,
|
||||
state: i32,
|
||||
kind: StationKind,
|
||||
pos: [i32; 3],
|
||||
@@ -34,9 +34,9 @@ pub(super) struct GuestStation {
|
||||
/// A guest's open lectern: `LecternMenu` over the block entity, made on the
|
||||
/// menu engine for each click (`Game::lectern_menu`).
|
||||
pub(super) struct GuestLectern {
|
||||
window: i32,
|
||||
pub(super) window: i32,
|
||||
state: i32,
|
||||
key: (i32, i32, i32),
|
||||
pub(super) key: (i32, i32, i32),
|
||||
/// The book and page the client was last sent (`broadcastChanges`).
|
||||
sent: Option<(Option<ItemStack>, i32)>,
|
||||
}
|
||||
@@ -69,10 +69,8 @@ impl Game<'_> {
|
||||
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
|
||||
return false;
|
||||
};
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
// The container it replaces loses its opener.
|
||||
self.guest_container_closed(host, conn);
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
|
||||
let Some(window) = host.hub.open_window(conn, menu_type, title) else {
|
||||
return true;
|
||||
@@ -151,9 +149,8 @@ impl Game<'_> {
|
||||
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
|
||||
/// block entity, with its book and page.
|
||||
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.guest_container_closed(host, conn);
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let title = horizon_protocol::text::translatable("container.lectern", &[]);
|
||||
let Some(window) = host.hub.open_window(conn, "minecraft:lectern", title) else {
|
||||
return;
|
||||
@@ -644,10 +641,7 @@ impl Game<'_> {
|
||||
let Some(gs) = host.play.get(&conn).and_then(|p| p.station.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
let [x, y, z] = gs.pos;
|
||||
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
|
||||
let block = StationKind::from_block(name) == Some(gs.kind);
|
||||
if !(block && g.within_block_range(gs.pos, 4.0)) {
|
||||
if !self.lan_station_valid(g, gs) {
|
||||
let window = gs.window;
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
@@ -669,6 +663,32 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A station menu's `stillValid`: its block is still that station and the
|
||||
/// guest is within `isWithinBlockInteractionRange(pos, 4)`.
|
||||
pub(super) fn lan_station_valid(
|
||||
&self,
|
||||
g: &horizon_client::lan::guest::Guest,
|
||||
gs: &GuestStation,
|
||||
) -> bool {
|
||||
let [x, y, z] = gs.pos;
|
||||
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
|
||||
StationKind::from_block(name) == Some(gs.kind) && g.within_block_range(gs.pos, 4.0)
|
||||
}
|
||||
|
||||
/// `LecternMenu.stillValid` (`Container.stillValidBlockEntity`): the block
|
||||
/// entity still holds a book and the guest is within the interaction
|
||||
/// range and its buffer.
|
||||
pub(super) fn lan_lectern_valid(
|
||||
&self,
|
||||
g: &horizon_client::lan::guest::Guest,
|
||||
key: (i32, i32, i32),
|
||||
) -> bool {
|
||||
matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Lectern(l)) if l.book.is_some()
|
||||
) && g.within_block_range([key.0, key.1, key.2], 4.0)
|
||||
}
|
||||
|
||||
/// The guests' lecterns each tick: `stillValid` (the block entity, its
|
||||
/// book, the reach) closes them, and `broadcastChanges` sends a book or
|
||||
/// page another player changed.
|
||||
@@ -685,13 +705,7 @@ impl Game<'_> {
|
||||
if !host.hub.here(g) {
|
||||
continue;
|
||||
}
|
||||
let has_book = matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Lectern(l)) if l.book.is_some()
|
||||
);
|
||||
// Container.stillValidBlockEntity: the interaction range and its buffer.
|
||||
let near = g.within_block_range([key.0, key.1, key.2], 4.0);
|
||||
if has_book && near {
|
||||
if self.lan_lectern_valid(g, key) {
|
||||
self.lan_send_lectern(host, conn, false);
|
||||
} else {
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
|
||||
@@ -57,9 +57,8 @@ impl Game<'_> {
|
||||
state.session.update_special_prices();
|
||||
}
|
||||
let title = self.merchant_display_name(&state);
|
||||
// The container or workstation the trading screen replaces is closed.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let Some(window) = host.hub.open_merchant_window(conn, title) else {
|
||||
self.merchant_closed(&mut state);
|
||||
return;
|
||||
@@ -350,28 +349,37 @@ impl Game<'_> {
|
||||
self.merchant_closed(&mut gm.merchant);
|
||||
true
|
||||
}
|
||||
/// `Merchant.stillValid` for a trader: it is alive and the guest is within
|
||||
/// the entity interaction range plus 4 of its box.
|
||||
pub(super) fn lan_merchant_valid(
|
||||
&self,
|
||||
g: &horizon_client::lan::guest::Guest,
|
||||
id: u32,
|
||||
) -> bool {
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.is_some_and(|m| m.alive() && guest_within_entity_range(g, &m.bounds(), 4.0))
|
||||
}
|
||||
/// `Villager.customServerAiStep` for the guests' trading villagers: the
|
||||
/// TRADE reputation event, then the prices follow.
|
||||
pub(super) fn lan_merchant_tick(&mut self, host: &mut LanHost) {
|
||||
// `AbstractVillager.stillValid`: the trade screen closes once the trader
|
||||
// is gone or dead, or the guest is more than 4 blocks past its reach.
|
||||
let far: Vec<(u32, i32)> =
|
||||
host.play
|
||||
.iter()
|
||||
.filter_map(|(conn, p)| {
|
||||
let gm = p.merchant.as_ref()?;
|
||||
let id = gm.merchant.mob?;
|
||||
let g = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == *conn && host.hub.here(g))?;
|
||||
let valid = self.mobs.iter().find(|m| m.id == id).is_some_and(|m| {
|
||||
m.alive() && guest_within_entity_range(g, &m.bounds(), 4.0)
|
||||
});
|
||||
(!valid).then_some((*conn, gm.window))
|
||||
})
|
||||
.collect();
|
||||
let far: Vec<(u32, i32)> = host
|
||||
.play
|
||||
.iter()
|
||||
.filter_map(|(conn, p)| {
|
||||
let gm = p.merchant.as_ref()?;
|
||||
let id = gm.merchant.mob?;
|
||||
let g = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == *conn && host.hub.here(g))?;
|
||||
(!self.lan_merchant_valid(g, id)).then_some((*conn, gm.window))
|
||||
})
|
||||
.collect();
|
||||
for (conn, window) in far {
|
||||
if self.lan_merchant_closed(host, conn, Some(window)) {
|
||||
host.hub.send_to(
|
||||
|
||||
@@ -126,12 +126,15 @@ impl Game<'_> {
|
||||
(g.vehicle, g.vehicle_good) = (None, None);
|
||||
return;
|
||||
}
|
||||
let mut ejected = false;
|
||||
if let Some(i) = boat {
|
||||
// AbstractBoat.tick on the server for a boat its rider drives:
|
||||
// only the hurt animation and the damage wear off here.
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_time = (b.hurt_time - 1).max(0);
|
||||
b.damage = (b.damage - 1.0).max(0.0);
|
||||
// AbstractBoat.tickBubbleColumn: a down column ejects the rider.
|
||||
ejected = std::mem::take(&mut b.eject_riders);
|
||||
}
|
||||
// ServerGamePacketListenerImpl.tick: the vehicle's last good position
|
||||
// is where it is now, so a move the server made (a piston, a push)
|
||||
@@ -161,7 +164,7 @@ impl Game<'_> {
|
||||
return Some([p.x, p.y, p.z]);
|
||||
}
|
||||
if let Some(i) = game.boats.iter().position(|b| Some(b.id) == game.riding) {
|
||||
if keys.shift {
|
||||
if keys.shift || ejected {
|
||||
game.dismount(i);
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -246,7 +246,14 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if self.mobs[index].extra.leash == Some(Holder::Player) {
|
||||
// Mob.checkAndHandleImportantInteractions: only the player the lead
|
||||
// is tied to unties it (`getLeashHolder() == player`).
|
||||
if self.mobs[index].extra.leash == Some(Holder::Player)
|
||||
&& self.mobs[index]
|
||||
.extra
|
||||
.leash_player
|
||||
.is_none_or(|p| p == self.acting_player_uuid())
|
||||
{
|
||||
let creative = self.creative;
|
||||
self.break_leash(id, !creative, false);
|
||||
self.lead_game_event("minecraft:entity_interact", at);
|
||||
@@ -301,12 +308,31 @@ impl Game<'_> {
|
||||
.filter(|&i| self.mobs[i].extra.leash == Some(Holder::Knot(knot_pos)))
|
||||
.collect();
|
||||
let existing_boats = self.boats_on(Holder::Knot(knot_pos));
|
||||
// `LeashFenceKnotEntity.interact` runs only when the ray meets the knot
|
||||
// before the post: the knot's box (`recalculateBoundingBox`, 0.375 wide,
|
||||
// y + 0.375 to y + 0.875) is wider than the post, so a hit point on the
|
||||
// post inside that box went through the knot first. Any other click on
|
||||
// the fence is `FenceBlock.useWithoutItem` / `LeadItem.useOn`, which only
|
||||
// ties the player's own mobs (`LeadItem.bindPlayerMobs`).
|
||||
let knot_hit = !(existing.is_empty() && existing_boats.is_empty()) && {
|
||||
let box_ = leash::knot_state(knot_pos);
|
||||
let half = box_.width / 2.0 + 1.0e-6;
|
||||
(hit.point[0] - box_.position.x).abs() <= half
|
||||
&& (hit.point[2] - box_.position.z).abs() <= half
|
||||
&& hit.point[1] >= box_.position.y - 1.0e-6
|
||||
&& hit.point[1] <= box_.position.y + box_.height + 1.0e-6
|
||||
// The knot is an entity: a hit on it counts only within the
|
||||
// entity interaction range (`filterHitResult`), shorter than
|
||||
// the block range the fence was hit at.
|
||||
&& self.eye_position().is_some_and(|eye| {
|
||||
let d = [0, 1, 2].map(|i| hit.point[i] - eye[i]);
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= self.entity_reach * self.entity_reach
|
||||
})
|
||||
};
|
||||
let held_item = self.held().map(|s| s.item.to_string());
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
// Shears snip every lead on the knot.
|
||||
if held_item.as_deref() == Some("minecraft:shears")
|
||||
&& !(existing.is_empty() && existing_boats.is_empty())
|
||||
{
|
||||
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);
|
||||
@@ -335,7 +361,7 @@ impl Game<'_> {
|
||||
attached = true;
|
||||
}
|
||||
}
|
||||
if !attached && !sneaking {
|
||||
if !attached && !sneaking && knot_hit {
|
||||
let player = self.player_center();
|
||||
let holder = self.acting_player_uuid();
|
||||
for i in existing {
|
||||
|
||||
@@ -82,6 +82,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
|
||||
// A mod block the level answered bone meal for: the port knows no
|
||||
// `BonemealableBlock.Type` for it, and vanilla's `GROWER` is the
|
||||
// one a block growing itself has, so the sparks stay inside it
|
||||
// rather than spreading over its neighbours.
|
||||
None if !facts.get(state).name.starts_with("minecraft:") => {
|
||||
(p, count, 0.5, self.block_shape_top(p), true)
|
||||
}
|
||||
None => return,
|
||||
};
|
||||
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
|
||||
|
||||
@@ -411,18 +411,23 @@ impl Game<'_> {
|
||||
}
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
result = if paused {
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
} else {
|
||||
let running = running.unwrap_or_else(|| self.clock.world_runs());
|
||||
if running {
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
self.tick_world(running)
|
||||
};
|
||||
result = horizon_client::block_contact::in_level(
|
||||
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(dim.id()),
|
||||
|| {
|
||||
if paused {
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
} else {
|
||||
let running = running.unwrap_or_else(|| self.clock.world_runs());
|
||||
if running {
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
self.tick_world(running)
|
||||
}
|
||||
},
|
||||
);
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ enum Kind {
|
||||
Brewing,
|
||||
/// `ChiseledBookShelfBlockEntity`: books only, one to a slot.
|
||||
Bookshelf,
|
||||
/// `JukeboxBlockEntity`'s single slot: a playable disc only.
|
||||
Jukebox,
|
||||
/// A mod block's block entity (`ModContainer`).
|
||||
Mod,
|
||||
/// `ShulkerBoxBlockEntity`: a shulker box doesn't go through a face into one.
|
||||
@@ -209,6 +211,7 @@ impl Containers<'_> {
|
||||
(BlockEntity::Crafter(_), _) => Kind::Crafter,
|
||||
(BlockEntity::Brewing(_), _) => Kind::Brewing,
|
||||
(BlockEntity::ChiseledBookshelf(_), _) => Kind::Bookshelf,
|
||||
(BlockEntity::Jukebox(_), _) => Kind::Jukebox,
|
||||
(_, "minecraft:hopper") => Kind::Hopper,
|
||||
(_, n) if is_shulker_box(n) => Kind::Shulker,
|
||||
_ => Kind::Generic,
|
||||
@@ -302,6 +305,15 @@ impl Containers<'_> {
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
if view.kind == Kind::Jukebox {
|
||||
// JukeboxBlockEntity.canPlaceItem: a playable disc, into the one
|
||||
// slot, which has to be empty first (its maximum stack is 1).
|
||||
return self
|
||||
.items
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.jukebox_song.is_some())
|
||||
&& self.get(view, slot).is_none();
|
||||
}
|
||||
if view.kind == Kind::Bookshelf {
|
||||
// ListBackedContainer.canPlaceItem: `acceptsItemType` (the books)
|
||||
// and a free slot, as `getMaxStackSize` is 1.
|
||||
@@ -572,6 +584,12 @@ impl Game<'_> {
|
||||
let (rest, moved) = self.move_in_item(from_hopper, view, stack, slot);
|
||||
if moved {
|
||||
self.view_changed(view);
|
||||
// JukeboxBlockEntity.setTheItem through a hopper's or a dropper's
|
||||
// transfer: the disc it lands on starts playing.
|
||||
if view.kind == Kind::Jukebox {
|
||||
let (x, y, z) = view.parts[0].0;
|
||||
self.jukebox_inserted([x, y, z]);
|
||||
}
|
||||
}
|
||||
rest
|
||||
}
|
||||
@@ -868,6 +886,12 @@ impl Game<'_> {
|
||||
match self.add_item(source_hopper, hopper, one, None) {
|
||||
None => {
|
||||
self.view_changed(source);
|
||||
// JukeboxBlockEntity.removeItem -> setTheItem(EMPTY): a
|
||||
// disc a hopper takes out stops the song.
|
||||
if source.kind == Kind::Jukebox {
|
||||
let (x, y, z) = source.parts[0].0;
|
||||
self.jukebox_taken([x, y, z]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Some(rest) => self.put_back(source, slot, rest),
|
||||
|
||||
@@ -56,7 +56,7 @@ impl CartKind {
|
||||
Self::CommandBlock => "minecraft:command_block_minecart",
|
||||
}
|
||||
}
|
||||
fn slots(self) -> usize {
|
||||
pub(super) fn slots(self) -> usize {
|
||||
match self {
|
||||
Self::Chest => 27,
|
||||
Self::Hopper => 5,
|
||||
@@ -963,6 +963,7 @@ impl Game<'_> {
|
||||
if let Some(i) = self.carts.iter().position(|x| x.id == c.id) {
|
||||
self.eject_cart_mobs(i);
|
||||
}
|
||||
self.release_vehicle_container(c.id);
|
||||
self.carts.retain(|x| x.id != c.id);
|
||||
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
|
||||
let interaction = self.destroy_type("tnt_explosion_drop_decay");
|
||||
@@ -1226,6 +1227,7 @@ impl Game<'_> {
|
||||
}
|
||||
} else {
|
||||
self.eject_cart_mobs(i);
|
||||
self.release_vehicle_container(id);
|
||||
self.carts.remove(i);
|
||||
}
|
||||
}
|
||||
@@ -1331,6 +1333,7 @@ impl Game<'_> {
|
||||
self.push_from_cart(i);
|
||||
} else {
|
||||
self.eject_cart_mobs(i);
|
||||
self.release_vehicle_container(c.id);
|
||||
self.carts.remove(i);
|
||||
self.riding = None;
|
||||
}
|
||||
@@ -1453,6 +1456,22 @@ impl Game<'_> {
|
||||
}
|
||||
best
|
||||
}
|
||||
/// The slots of the cart `id`, for a command: `getChestVehicleItem` and
|
||||
/// `setChestVehicleItem` first unpack its loot table with no player, so
|
||||
/// no `player_generates_container_loot` fires later. While its menu is
|
||||
/// open the live slots are the placeholder copy (`open_cart_container`).
|
||||
pub(super) fn cart_slots(&mut self, id: u32) -> Option<&mut Vec<Option<ItemStack>>> {
|
||||
let c = self.carts.iter_mut().find(|c| c.id == id)?;
|
||||
c.loot = None;
|
||||
match self
|
||||
.world
|
||||
.block_entities
|
||||
.get_mut(&(i32::MAX - 15, CART_KEY_Y, id as i32))
|
||||
{
|
||||
Some(BlockEntity::Chest(open)) => Some(open),
|
||||
_ => Some(&mut c.items),
|
||||
}
|
||||
}
|
||||
/// `ContainerEntity.unpackChestVehicleLootTable` for a player who opens a
|
||||
/// chest minecart: a roll it still holds fires `player_generates_container_loot`
|
||||
/// for its table, and the cart keeps its items from then on.
|
||||
@@ -1561,6 +1580,19 @@ impl Game<'_> {
|
||||
self.store_cart_items(key);
|
||||
self.world.block_entities.remove(&key);
|
||||
}
|
||||
/// A chest or hopper minecart or a chest boat is about to be removed
|
||||
/// (`Containers.dropContents` / `AbstractChestBoat.remove`): what an open
|
||||
/// menu edited goes back to the vehicle, so it is what drops or travels
|
||||
/// with it, and the menu's stand-in leaves the level. A menu that is
|
||||
/// still open over it then has nothing left to take.
|
||||
pub(super) fn release_vehicle_container(&mut self, id: u32) {
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, id as i32);
|
||||
self.store_cart_items(key);
|
||||
self.world.block_entities.remove(&key);
|
||||
if self.container == Some(key) {
|
||||
self.container = None;
|
||||
}
|
||||
}
|
||||
/// The menu of a chest or hopper minecart or a chest boat works on the
|
||||
/// vehicle's own items (`ContainerEntity`): the placeholder under `key`
|
||||
/// that the menu edits is copied back to the vehicle.
|
||||
@@ -1628,6 +1660,8 @@ impl Game<'_> {
|
||||
}
|
||||
if burning && self.carts[i].kind == CartKind::Tnt {
|
||||
self.eject_cart_mobs(i);
|
||||
let id = self.carts[i].id;
|
||||
self.release_vehicle_container(id);
|
||||
let c = self.carts.remove(i);
|
||||
self.explode_cart(&c, arrow_velocity.length_squared());
|
||||
return;
|
||||
@@ -1653,6 +1687,10 @@ impl Game<'_> {
|
||||
if !tnt_prime {
|
||||
self.eject_cart_mobs(i);
|
||||
}
|
||||
let id = self.carts[i].id;
|
||||
if !tnt_prime {
|
||||
self.release_vehicle_container(id);
|
||||
}
|
||||
let mut c = self.carts.remove(i);
|
||||
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
|
||||
// MinecartTNT.destroy: prime instead of breaking.
|
||||
|
||||
@@ -18,6 +18,28 @@ fn carried_block_matrix(base: glam::Mat4) -> glam::Mat4 {
|
||||
* glam::Mat4::from_rotation_y(90.0f32.to_radians())
|
||||
}
|
||||
|
||||
/// Drops the quads of `out[first..]` whose face normal points away from the eye, which
|
||||
/// is the origin of camera-relative vertices. Each quad is four vertices carrying its
|
||||
/// face normal in `Vertex::normal`, as `block_model_vertices` writes for diffuse faces.
|
||||
fn cull_back_quads(out: &mut Vec<Vertex>, first: usize) {
|
||||
let mut kept = first;
|
||||
for quad in (first..out.len()).step_by(4) {
|
||||
let vertices = &out[quad..quad + 4];
|
||||
let n = vertices[0].normal;
|
||||
let normal = glam::Vec3::new(f32::from(n[0]), f32::from(n[1]), f32::from(n[2]));
|
||||
let centre = vertices.iter().fold(glam::Vec3::ZERO, |sum, v| {
|
||||
sum + glam::Vec3::from_array(v.pos)
|
||||
}) * 0.25;
|
||||
// The eye is at the origin: the face is seen from its front when the eye is on the
|
||||
// side its normal points to, that is when `normal . (eye - centre) > 0`.
|
||||
if normal.dot(centre) < 0.0 {
|
||||
out.copy_within(quad..quad + 4, kept);
|
||||
kept += 4;
|
||||
}
|
||||
}
|
||||
out.truncate(kept);
|
||||
}
|
||||
|
||||
/// `CustomHeadLayer.translateToHead` and the banner's `head` display.
|
||||
fn banner_matrix(head: glam::Mat4) -> glam::Mat4 {
|
||||
head * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
|
||||
@@ -30,6 +52,24 @@ fn banner_matrix(head: glam::Mat4) -> glam::Mat4 {
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(2.0 / 3.0, -2.0 / 3.0, -2.0 / 3.0))
|
||||
}
|
||||
|
||||
/// `BlockDecorationLayer.submit` and `CopperGolemModel.applyBlockOnAntennaTransform`, then the
|
||||
/// block model's own display transformation, which `BlockModelRenderState.submitModel` post-multiplies.
|
||||
fn antenna_block_matrix(head: glam::Mat4, flowerbed_shift: bool) -> glam::Mat4 {
|
||||
// UNIT_CUBE_BOTTOM_CENTER_TO_ANTENNA_CENTER: translation(-0.5, 0, -0.5)
|
||||
// then rotateAround(Z 180, 0.5, 0.5, 0.5), which JOML post-multiplies.
|
||||
let unit = glam::Mat4::from_translation(glam::Vec3::new(-0.5, 0.0, -0.5))
|
||||
* glam::Mat4::from_translation(glam::Vec3::splat(0.5))
|
||||
* glam::Mat4::from_rotation_z(180.0f32.to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::splat(-0.5));
|
||||
let t = head * glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.75, 0.0)) * unit;
|
||||
if flowerbed_shift {
|
||||
// `BuiltInBlockModels.createFlowerBedModel`'s Transformation(0.25, 0, 0.25).
|
||||
t * glam::Mat4::from_translation(glam::Vec3::new(0.25, 0.0, 0.25))
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
/// `ElderGuardianParticle.lifetime`.
|
||||
pub(super) const APPARITION_TICKS: i32 = 30;
|
||||
|
||||
@@ -206,7 +246,13 @@ impl Game<'_> {
|
||||
let base = render::base_transform(mob, alpha as f32, camera);
|
||||
let t = carried_block_matrix(base);
|
||||
// Preserve cross models (flowers, fungi) and state properties (facing).
|
||||
let first = out.len();
|
||||
self.block_model_vertices(state, 42, t, light, true, out);
|
||||
// The carried block uses the item cutout render type, which culls back faces
|
||||
// (RenderPipelines.ITEM_CUTOUT). Without that, both coplanar sides of a cross
|
||||
// model are drawn and flicker. CULL_BACK would drop them too, but it turns off
|
||||
// the normal lighting this model needs.
|
||||
cull_back_quads(out, first);
|
||||
}
|
||||
}
|
||||
/// `BlockDecorationLayer.submit`: the block item in a copper golem's
|
||||
@@ -222,7 +268,6 @@ impl Game<'_> {
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use glam::{Mat4, Vec3};
|
||||
if mob.species() != Species::CopperGolem || horizon_client::mobs::trader::is_invisible(mob)
|
||||
{
|
||||
return;
|
||||
@@ -260,13 +305,12 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// UNIT_CUBE_BOTTOM_CENTER_TO_ANTENNA_CENTER: translation(-0.5, 0, -0.5)
|
||||
// then rotateAround(Z 180, 0.5, 0.5, 0.5), which JOML post-multiplies.
|
||||
let unit = Mat4::from_translation(Vec3::new(-0.5, 0.0, -0.5))
|
||||
* Mat4::from_translation(Vec3::splat(0.5))
|
||||
* Mat4::from_rotation_z(180.0f32.to_radians())
|
||||
* Mat4::from_translation(Vec3::splat(-0.5));
|
||||
let t = head * Mat4::from_translation(Vec3::new(0.0, -1.75, 0.0)) * unit;
|
||||
// Only wildflowers and pink petals carry the flowerbed display shift.
|
||||
let flowerbeds = matches!(
|
||||
&*stack.item,
|
||||
"minecraft:wildflowers" | "minecraft:pink_petals"
|
||||
);
|
||||
let t = antenna_block_matrix(head, flowerbeds);
|
||||
self.block_model_vertices(state, 42, t, light, true, out);
|
||||
}
|
||||
/// `IronGolemFlowerLayer`: the poppy in the right arm while the golem
|
||||
@@ -658,12 +702,13 @@ mod tests {
|
||||
(Vec3::ONE, Vec3::new(-0.3535534, 0.3938461, -0.85688126)),
|
||||
(Vec3::splat(0.5), Vec3::new(0.0, 0.6287693, -0.77137625)),
|
||||
];
|
||||
for base in [
|
||||
let bases = [
|
||||
Mat4::IDENTITY,
|
||||
Mat4::from_translation(Vec3::new(3.0, 2.0, -7.0))
|
||||
* Mat4::from_rotation_y(1.2)
|
||||
* Mat4::from_scale(Vec3::new(-1.0, -1.0, 1.0)),
|
||||
] {
|
||||
];
|
||||
for base in bases {
|
||||
let block = carried_block_matrix(base);
|
||||
for (point, expected) in samples {
|
||||
let actual = block.transform_point3(point);
|
||||
@@ -674,5 +719,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
// `antenna_block_matrix` with an identity head, from the 26.3 expressions:
|
||||
// `UNIT_CUBE_BOTTOM_CENTER_TO_ANTENNA_CENTER` (`translate(-0.5, 0, -0.5)`
|
||||
// then `rotateAround(Z 180, 0.5, 0.5, 0.5)`) sends (x, y, z) to
|
||||
// (0.5 - x, 1 - y, z - 0.5), the antenna drops it 1.75, and
|
||||
// `createFlowerBedModel`'s quarter block lands on (-0.25, 0, 0.25) because
|
||||
// the Z-180 turn flips it.
|
||||
let antenna = [
|
||||
(Vec3::ZERO, Vec3::new(0.5, -0.75, -0.5)),
|
||||
(Vec3::X, Vec3::new(-0.5, -0.75, -0.5)),
|
||||
(Vec3::Y, Vec3::new(0.5, -1.75, -0.5)),
|
||||
(Vec3::Z, Vec3::new(0.5, -0.75, 0.5)),
|
||||
(Vec3::ONE, Vec3::new(-0.5, -1.75, 0.5)),
|
||||
(Vec3::splat(0.5), Vec3::new(0.0, -1.25, 0.0)),
|
||||
];
|
||||
for (shifted, shift) in [(false, Vec3::ZERO), (true, Vec3::new(-0.25, 0.0, 0.25))] {
|
||||
for base in bases {
|
||||
let m = antenna_block_matrix(base, shifted);
|
||||
for (point, expected) in antenna {
|
||||
let actual = m.transform_point3(point);
|
||||
let expected = base.transform_point3(expected + shift);
|
||||
assert!(
|
||||
actual.abs_diff_eq(expected, 1e-6),
|
||||
"{actual:?} != {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,7 @@ use horizon_client::{
|
||||
mobs::{
|
||||
Mob, Species,
|
||||
equip::{self, Facts, ItemTags, Slot},
|
||||
finalize::{
|
||||
self, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
|
||||
stored_drop_chance,
|
||||
},
|
||||
finalize::{self, set_drop_chance, set_item_stack, stored_drop_chance},
|
||||
},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
@@ -92,27 +89,29 @@ impl Game<'_> {
|
||||
if !replace {
|
||||
return None;
|
||||
}
|
||||
// shortcut: a mob's equipment is one item per slot here, so a hand
|
||||
// takes one item of a stack where vanilla takes the whole stack;
|
||||
// upgrade with a per-slot count in `Extra`.
|
||||
let taken = 1;
|
||||
// `EquipmentSlot.limit`: an armour slot takes one item, a hand the
|
||||
// whole stack.
|
||||
let taken = if slot.is_armor() {
|
||||
1
|
||||
} else {
|
||||
stack.count.max(1)
|
||||
};
|
||||
let m = &mut self.mobs[index];
|
||||
let old = finalize::item(m, slot.index());
|
||||
let old = finalize::item_stack(m, slot.index());
|
||||
let chance = stored_drop_chance(m, slot.index());
|
||||
let old_enchantments = slot_enchantments(m, slot.index());
|
||||
// `DropChances.byEquipment` against the roll the equip makes.
|
||||
let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance;
|
||||
set_item(m, slot.index(), item);
|
||||
// The stack's enchantments come with it: a Sharpness V sword stays sharp.
|
||||
set_slot_enchantments(m, slot.index(), stack.enchantments().to_vec());
|
||||
// The whole stack comes with it: its enchantments (a Sharpness V sword
|
||||
// stays sharp), damage, name and components.
|
||||
let mut equipped = stack.clone();
|
||||
equipped.count = taken;
|
||||
set_item_stack(m, slot.index(), equipped);
|
||||
// `setItemSlotAndDropWhenKilled`, then `persistenceRequired`.
|
||||
set_drop_chance(m, slot.index(), 2.0);
|
||||
m.persistent = true;
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if drop_old && let Some(old) = old {
|
||||
let mut dropped = ItemStack::new(&old, 1);
|
||||
dropped.set_crafting_enchantments(old_enchantments);
|
||||
self.spawn_item(dropped, at, super::dispenser::new_item_motion(), 10);
|
||||
self.spawn_item(old, at, super::dispenser::new_item_motion(), 10);
|
||||
}
|
||||
Some(taken)
|
||||
}
|
||||
@@ -120,9 +119,10 @@ impl Game<'_> {
|
||||
/// `Mob.canReplaceCurrentItem` for what `m` wears or holds in `slot`.
|
||||
fn can_replace(&self, index: usize, stack: &ItemStack, slot: Slot) -> bool {
|
||||
let m = &self.mobs[index];
|
||||
let Some(current) = finalize::item(m, slot.index()) else {
|
||||
let Some(worn_stack) = finalize::item_stack(m, slot.index()) else {
|
||||
return true;
|
||||
};
|
||||
let current = worn_stack.item.clone();
|
||||
let species = m.species();
|
||||
// `Drowned.canReplaceCurrentItem` never parts with a nautilus shell.
|
||||
if species == Species::Drowned && &*current == "minecraft:nautilus_shell" {
|
||||
@@ -140,21 +140,15 @@ impl Game<'_> {
|
||||
// Binding's, the only enchantment that carries one.
|
||||
prevents_armor_change: stack.enchantment_level("minecraft:binding_curse") > 0,
|
||||
};
|
||||
let worn_enchantments = slot_enchantments(m, slot.index());
|
||||
let (armor, toughness, attack) = self.attributes(m, ¤t, slot);
|
||||
// shortcut: what a mob already wears keeps no damage value or name here
|
||||
// (`Extra` records neither), so a worn piece always compares as fresh;
|
||||
// upgrade with a stack per equipment slot.
|
||||
let worn = Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
attack_damage: attack,
|
||||
enchantments: worn_enchantments.len(),
|
||||
damage: 0,
|
||||
custom_name: false,
|
||||
prevents_armor_change: worn_enchantments
|
||||
.iter()
|
||||
.any(|(id, level)| &**id == "minecraft:binding_curse" && *level > 0),
|
||||
enchantments: worn_stack.enchantments().len(),
|
||||
damage: worn_stack.damage as i32,
|
||||
custom_name: worn_stack.custom_name().is_some(),
|
||||
prevents_armor_change: worn_stack.enchantment_level("minecraft:binding_curse") > 0,
|
||||
};
|
||||
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
|
||||
(
|
||||
|
||||
@@ -16,6 +16,27 @@ pub(super) struct ModsState {
|
||||
stopped: Vec<String>,
|
||||
}
|
||||
|
||||
/// Freeze native component IDs before reading content or opening a connection.
|
||||
/// With no mod registering any, nothing is installed, which every reader
|
||||
/// treats as no custom components, and `registries.json` isn't read.
|
||||
pub(super) fn register_components(root: &std::path::Path) -> horizon_client::Result<()> {
|
||||
let mut names = Vec::new();
|
||||
Game::native_component_types(&mut names);
|
||||
if names.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let path =
|
||||
horizon_protocol::registry::Builtin::find(root).ok_or("registries.json not found")?;
|
||||
let builtin = horizon_protocol::registry::Builtin::load(&path)?;
|
||||
let types = builtin
|
||||
.get("minecraft:data_component_type")
|
||||
.ok_or("missing data component registry")?;
|
||||
let first = i32::try_from(types.names.len())?;
|
||||
horizon_protocol::item::custom::install(first, names)
|
||||
.map_err(|error| format!("invalid native component registration: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `root` with the content mods that are on layered over it
|
||||
/// (docs/modpacks.md): their blocks, items, data and assets. Read once at
|
||||
/// start, before the resources load; a mod whose content is refused is left
|
||||
@@ -31,6 +52,7 @@ pub(super) fn content_root(
|
||||
let mut paths = horizon_mods::find(&here.join(mod_list::DIR));
|
||||
paths.retain(|p| !dev.contains(p));
|
||||
paths.extend(dev.iter().cloned());
|
||||
let mut known = None;
|
||||
let mut mods: Vec<ContentMod> = Vec::new();
|
||||
for path in &paths {
|
||||
let Ok(package) = Package::read(path) else {
|
||||
@@ -44,23 +66,30 @@ pub(super) fn content_root(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let content = Content::read(path, m).and_then(|content| {
|
||||
// The first of two mods owning one namespace stays on.
|
||||
let own = (m.id.as_str(), content.namespaces.as_slice());
|
||||
match mods
|
||||
.iter()
|
||||
.find_map(|c| horizon_mods::content::clash((&c.id, &c.content.namespaces), own))
|
||||
{
|
||||
Some(e) => Err(e),
|
||||
None => Ok(content),
|
||||
let content = match Content::read(path, m) {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: mod {} content not loaded: {e}", m.id);
|
||||
continue;
|
||||
}
|
||||
});
|
||||
match content {
|
||||
Ok(content) => mods.push(ContentMod {
|
||||
id: m.id.clone(),
|
||||
content,
|
||||
}),
|
||||
Err(e) => log::warn!("Horizon: mod {} content not loaded: {e}", m.id),
|
||||
};
|
||||
let known = match &mut known {
|
||||
Some(types) => types,
|
||||
None => match mod_content::known_components(root) {
|
||||
Ok(types) => known.insert(types),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: content mods not loaded: {e}");
|
||||
register_mob_effects(&[]);
|
||||
return root.to_owned();
|
||||
}
|
||||
},
|
||||
};
|
||||
let candidate = ContentMod {
|
||||
id: m.id.clone(),
|
||||
content,
|
||||
};
|
||||
if let Err(e) = mod_content::accept_mod(&mut mods, candidate, known) {
|
||||
log::warn!("Horizon: mod {} content not loaded: {e}", m.id);
|
||||
}
|
||||
}
|
||||
match mod_content::resource_root(root, &mods, &here.join(".horizon-cache")) {
|
||||
@@ -73,11 +102,13 @@ pub(super) fn content_root(
|
||||
);
|
||||
}
|
||||
register_mob_effects(&mods);
|
||||
register_entity_types(&mods);
|
||||
out
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: content mods not loaded: {e}");
|
||||
register_mob_effects(&[]);
|
||||
register_entity_types(&[]);
|
||||
root.to_owned()
|
||||
}
|
||||
}
|
||||
@@ -110,6 +141,26 @@ fn register_mob_effects(mods: &[horizon_client::mod_content::ContentMod]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// the mods' thrown entity types, as their mob effects: set once, from the
|
||||
/// startup root.
|
||||
fn register_entity_types(mods: &[horizon_client::mod_content::ContentMod]) {
|
||||
use horizon_client::projectiles;
|
||||
let types = horizon_client::mod_content::entity_types(mods);
|
||||
let set = projectiles::register_thrown_types(
|
||||
types
|
||||
.iter()
|
||||
.map(|t| (t.id.clone(), t.item.clone(), t.damage)),
|
||||
);
|
||||
if !set
|
||||
&& projectiles::thrown_types()
|
||||
.iter()
|
||||
.map(|t| t.id)
|
||||
.ne(types.iter().map(|t| t.id.as_str()))
|
||||
{
|
||||
log::warn!("Horizon: the mods' entity 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";
|
||||
|
||||
|
||||
@@ -319,6 +319,16 @@ impl Game<'_> {
|
||||
v.extend((0..open.columns * 3).map(|i| CHEST + i));
|
||||
Some(v)
|
||||
}
|
||||
/// How many slots `mount_slots` lists, counted without building it
|
||||
/// (`Game::slot_count` asks every frame).
|
||||
pub(super) fn mount_slot_count(&self) -> Option<usize> {
|
||||
let open = self
|
||||
.mount_menu
|
||||
.open
|
||||
.as_ref()
|
||||
.filter(|_| self.mount_active())?;
|
||||
Some(36 + usize::from(open.show_saddle) + usize::from(open.show_armor) + open.columns * 3)
|
||||
}
|
||||
pub(super) fn mount_slot_position(&self, i: usize) -> Option<(f32, f32)> {
|
||||
let open = self
|
||||
.mount_menu
|
||||
|
||||
@@ -17,6 +17,15 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
};
|
||||
|
||||
fn steering_item(species: Species, main: Option<&str>, offhand: Option<&str>) -> bool {
|
||||
let item = match species {
|
||||
Species::Pig => "minecraft:carrot_on_a_stick",
|
||||
Species::Strider => "minecraft:warped_fungus_on_a_stick",
|
||||
_ => return false,
|
||||
};
|
||||
main == Some(item) || offhand == Some(item)
|
||||
}
|
||||
|
||||
/// `passengerAttachments` minus the player's 0.6 vehicle attachment.
|
||||
fn seat(s: Species) -> f64 {
|
||||
let attach = match s {
|
||||
@@ -367,12 +376,11 @@ impl Game<'_> {
|
||||
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
|
||||
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
|
||||
// LocalPlayer.aiStep: the riding jump charges while space is held.
|
||||
let held = self.held().map(|s| s.item.clone());
|
||||
let steer = match self.mobs[i].species() {
|
||||
Species::Pig => held.as_deref() == Some("minecraft:carrot_on_a_stick"),
|
||||
Species::Strider => held.as_deref() == Some("minecraft:warped_fungus_on_a_stick"),
|
||||
_ => false,
|
||||
};
|
||||
let steer = steering_item(
|
||||
self.mobs[i].species(),
|
||||
self.held().map(|s| s.item.as_ref()),
|
||||
self.offhand.as_ref().map(|s| s.item.as_ref()),
|
||||
);
|
||||
let jump = self.charge_mount_jump(i, keys);
|
||||
let m = &mut self.mobs[i];
|
||||
if jump > 0.0 {
|
||||
@@ -839,6 +847,17 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn steering_accepts_the_correct_item_in_either_hand() {
|
||||
let carrot = "minecraft:carrot_on_a_stick";
|
||||
let fungus = "minecraft:warped_fungus_on_a_stick";
|
||||
assert!(steering_item(Species::Pig, Some(carrot), None));
|
||||
assert!(steering_item(Species::Pig, None, Some(carrot)));
|
||||
assert!(steering_item(Species::Strider, None, Some(fungus)));
|
||||
assert!(!steering_item(Species::Pig, Some(fungus), None));
|
||||
assert!(!steering_item(Species::Horse, Some(carrot), Some(fungus)));
|
||||
}
|
||||
|
||||
/// A lava sea up to y 30 with full blocks placed in it; air above.
|
||||
struct Sea {
|
||||
blocks: HashMap<[i32; 3], ()>,
|
||||
|
||||
@@ -10,6 +10,10 @@ use horizon_client::{
|
||||
impl Game<'_> {
|
||||
/// `Minecraft.tick` -> `MusicManager.tick`, once per client tick.
|
||||
pub(super) fn tick_music(&mut self) {
|
||||
// Splitscreen: one music, P1's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
let (music, volume) = self.situational_music();
|
||||
// Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs.
|
||||
let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency"));
|
||||
|
||||
@@ -287,13 +287,15 @@ impl Game<'_> {
|
||||
lift = f64::from(9.0f32 * 1.15 * 0.025);
|
||||
}
|
||||
lines.push((shown, lift));
|
||||
// The tag rides with the body, between last tick's position and this one's.
|
||||
let at = a.render_position(alpha);
|
||||
let l = self.world.light(
|
||||
a.position.x.floor() as i32,
|
||||
(a.position.y + height).floor() as i32,
|
||||
a.position.z.floor() as i32,
|
||||
at.x.floor() as i32,
|
||||
(at.y + height).floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
);
|
||||
tags.push(NameTag {
|
||||
at: glam::DVec3::new(a.position.x, a.position.y + height + 0.5, a.position.z),
|
||||
at: glam::DVec3::new(at.x, at.y + height + 0.5, at.z),
|
||||
lines,
|
||||
light: l,
|
||||
discrete: false,
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
//! Mod block entities drawn on the client: `native_block_entity_submit`
|
||||
//! (vanilla's `BlockEntityRenderer.submit`) gets a [`BlockEntityDraw`], whose
|
||||
//! pose stack (`PoseStack`) places what the helpers here add to the entity
|
||||
//! batch, as vanilla's block entity renderers draw.
|
||||
|
||||
use super::{Dim, Game};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_assets::{
|
||||
bake::{bake_flat_face, default_uv},
|
||||
model::{Direction, Material},
|
||||
};
|
||||
use horizon_client::{
|
||||
block_entity_render::{diffuse, diffuse_lights, vertex_light},
|
||||
containers::BlockEntity,
|
||||
gfx::Cull,
|
||||
hud::Rect,
|
||||
mesher::Vertex,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// `BlockEntityRenderer.getViewDistance`'s default, in blocks.
|
||||
const VIEW_DISTANCE: f64 = 64.0;
|
||||
/// A mod block entity's bounds until its mod says otherwise: the block with
|
||||
/// the block of margin Horizon culls vanilla's block entities with.
|
||||
const DEFAULT_BOUNDS: [f32; 6] = [-1.0, -1.0, -1.0, 2.0, 2.0, 2.0];
|
||||
|
||||
/// Whether `bounds` (min then max, in blocks from `origin`, the block's corner
|
||||
/// relative to the camera) meet the frustum; an infinite bound always does
|
||||
/// (NeoForge's `INFINITE_EXTENT_AABB`), as does a frame without a frustum.
|
||||
fn in_frustum(frustum: Option<&impl Cull>, origin: Vec3, bounds: [f32; 6]) -> bool {
|
||||
if bounds.iter().any(|b| !b.is_finite()) {
|
||||
return true;
|
||||
}
|
||||
let min = origin + Vec3::new(bounds[0], bounds[1], bounds[2]);
|
||||
let max = origin + Vec3::new(bounds[3], bounds[4], bounds[5]);
|
||||
frustum.is_none_or(|f| f.intersects(min, max))
|
||||
}
|
||||
|
||||
/// One mod block entity's frame, as `submit` gets it: the render state's
|
||||
/// partial tick, game time and light, and the pose stack the helpers draw
|
||||
/// under (`native_draw_quad`, `native_draw_cube`, `native_draw_block`).
|
||||
// Native mods read and drive it; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
pub struct BlockEntityDraw {
|
||||
/// The frame's partial tick, 0 to 1 (`BlockEntityRenderState`).
|
||||
pub partial: f32,
|
||||
/// The level's game time, for animations.
|
||||
pub game_time: i64,
|
||||
/// The light the helpers draw with: the block's own
|
||||
/// (`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,
|
||||
poses: Vec<Mat4>,
|
||||
lights: (Vec3, Vec3),
|
||||
vertices: Vec<Vertex>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl BlockEntityDraw {
|
||||
/// `PoseStack.pushPose`: `pop` comes back to the pose as it is now.
|
||||
pub fn push(&mut self) {
|
||||
let pose = self.pose();
|
||||
self.poses.push(pose);
|
||||
}
|
||||
/// `PoseStack.popPose`; the block's own pose is never popped.
|
||||
pub fn pop(&mut self) {
|
||||
if self.poses.len() > 1 {
|
||||
self.poses.pop();
|
||||
}
|
||||
}
|
||||
/// Moves what is drawn next by `by`, in blocks.
|
||||
pub fn translate(&mut self, by: [f32; 3]) {
|
||||
self.apply(Mat4::from_translation(Vec3::from_array(by)));
|
||||
}
|
||||
/// Turns what is drawn next by `degrees` about `axis` (counterclockwise
|
||||
/// seen from the axis' positive end, as `Axis.rotationDegrees`) through
|
||||
/// `pivot`, in blocks from the pose's origin: `[0.5; 3]` is the block's centre.
|
||||
pub fn rotate_about(&mut self, axis: [f32; 3], degrees: f32, pivot: [f32; 3]) {
|
||||
let axis = Vec3::from_array(axis).normalize_or(Vec3::Y);
|
||||
let pivot = Vec3::from_array(pivot);
|
||||
self.apply(
|
||||
Mat4::from_translation(pivot)
|
||||
* Mat4::from_quat(Quat::from_axis_angle(axis, degrees.to_radians()))
|
||||
* Mat4::from_translation(-pivot),
|
||||
);
|
||||
}
|
||||
/// Scales what is drawn next by `by` about the pose's origin.
|
||||
pub fn scale(&mut self, by: [f32; 3]) {
|
||||
self.apply(Mat4::from_scale(Vec3::from_array(by)));
|
||||
}
|
||||
fn apply(&mut self, m: Mat4) {
|
||||
if let Some(pose) = self.poses.last_mut() {
|
||||
*pose *= m;
|
||||
}
|
||||
}
|
||||
fn pose(&self) -> Mat4 {
|
||||
self.poses.last().copied().unwrap_or(Mat4::IDENTITY)
|
||||
}
|
||||
/// A quad of `corners` (blocks, under the pose) with unit UVs, shaded as
|
||||
/// vanilla's block entity models are (`Lighting`'s two lights).
|
||||
fn quad(&mut self, corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], sprite: Rect, tint: u32) {
|
||||
let pose = self.pose();
|
||||
let at = corners.map(|c| pose.transform_point3(Vec3::from_array(c)));
|
||||
let normal = (at[1] - at[0]).cross(at[2] - at[0]).normalize_or_zero();
|
||||
let normal = if normal == Vec3::ZERO {
|
||||
(at[2] - at[1]).cross(at[3] - at[1]).normalize_or(Vec3::Y)
|
||||
} else {
|
||||
normal
|
||||
};
|
||||
let color = shaded(tint, diffuse(normal, self.lights));
|
||||
for (p, uv) in at.into_iter().zip(uv) {
|
||||
self.vertices.push(Vertex {
|
||||
pos: p.to_array(),
|
||||
uv: [
|
||||
sprite.u0 + (sprite.u1 - sprite.u0) * uv[0],
|
||||
sprite.v0 + (sprite.v1 - sprite.v0) * uv[1],
|
||||
],
|
||||
layer: 0,
|
||||
color,
|
||||
light: self.light,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One face of a `native_draw_cube` box: its sprite (`mymod:entity/cog`,
|
||||
/// `mymod:block/casing`) and its UVs in texels of 16 per side, as a model
|
||||
/// face's `uv` (none takes the box's own, as a model's default).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CubeFace<'a> {
|
||||
pub sprite: &'a str,
|
||||
pub uv: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// 0xRRGGBB `tint` times `brightness`, as an opaque vertex colour.
|
||||
fn shaded(tint: u32, brightness: f32) -> u32 {
|
||||
let c = |shift: u32| (((tint >> shift) & 255) as f32 * brightness) as u32;
|
||||
0xFF00_0000 | c(0) << 16 | c(8) << 8 | c(16)
|
||||
}
|
||||
|
||||
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
|
||||
/// `BlockEntityRenderer.getViewDistance` to the camera (`shouldRender`)
|
||||
/// and with its bounds (`native_block_entity_render_bounding_box`) in the
|
||||
/// frustum.
|
||||
pub(super) fn mod_block_entity_geometry<'a>(
|
||||
&self,
|
||||
blocks: impl Iterator<Item = &'a ([i32; 3], u32)>,
|
||||
camera: DVec3,
|
||||
partial: f32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let mut draw = BlockEntityDraw {
|
||||
partial,
|
||||
game_time: self.world.game_time,
|
||||
light: 0,
|
||||
poses: Vec::new(),
|
||||
lights: diffuse_lights(self.dim == Dim::Nether),
|
||||
vertices: std::mem::take(out),
|
||||
};
|
||||
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.
|
||||
if (corner + 0.5).distance_squared(camera) >= VIEW_DISTANCE * VIEW_DISTANCE {
|
||||
continue;
|
||||
}
|
||||
let Some(BlockEntity::Mod(entity)) =
|
||||
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
|
||||
else {
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
let start = draw.vertices.len();
|
||||
self.mark_block_entity(start, state);
|
||||
draw.poses.clear();
|
||||
draw.poses.push(Mat4::from_translation(origin));
|
||||
draw.light = vertex_light(self.world.light(pos[0], pos[1], pos[2]));
|
||||
self.native_block_entity_submit(pos, block, id, entity, &mut draw);
|
||||
self.cull_entity_vertices(&mut draw.vertices, start);
|
||||
}
|
||||
*out = draw.vertices;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers for drawing mod block entities, kept as stable as the hook points.
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Draws a quad with `corners` in blocks under `draw`'s pose, in a model
|
||||
/// face's order (top left, bottom left, bottom right, top right as seen
|
||||
/// from its front), textured with the `uv` rectangle (u0, v0, u1, v1 in
|
||||
/// texels of 16 per side) of `sprite` (`mymod:entity/screen`,
|
||||
/// `mymod:block/casing`, `block/stone`) times the 0xRRGGBB `tint`
|
||||
/// (0xFFFFFF for none). False for an unknown sprite. API @API:block_entity_submit@.
|
||||
pub(super) fn native_draw_quad(
|
||||
&self,
|
||||
draw: &mut BlockEntityDraw,
|
||||
sprite: &str,
|
||||
corners: [[f32; 3]; 4],
|
||||
uv: [f32; 4],
|
||||
tint: u32,
|
||||
) -> bool {
|
||||
let Some(rect) = self.gui.atlas.sprites.get(sprite).copied() else {
|
||||
return false;
|
||||
};
|
||||
let [u0, v0, u1, v1] = uv.map(|t| t / 16.0);
|
||||
draw.quad(
|
||||
corners,
|
||||
[[u0, v0], [u0, v1], [u1, v1], [u1, v0]],
|
||||
rect,
|
||||
tint,
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws a box from `from` to `to` (texels of 16 per block, as a block
|
||||
/// model's element) under `draw`'s pose, with the faces given, by
|
||||
/// `Direction` (down, up, north, south, west, east), baked as a model
|
||||
/// element's are (`FaceBakery`), times the 0xRRGGBB `tint`. A turned box
|
||||
/// is drawn under a turned pose (`BlockEntityDraw::rotate_about`). False
|
||||
/// when a face's sprite is unknown; that face is left out. API @API:block_entity_submit@.
|
||||
pub(super) fn native_draw_cube(
|
||||
&self,
|
||||
draw: &mut BlockEntityDraw,
|
||||
from: [f32; 3],
|
||||
to: [f32; 3],
|
||||
faces: [Option<CubeFace<'_>>; 6],
|
||||
tint: u32,
|
||||
) -> bool {
|
||||
let mut all = true;
|
||||
for (direction, face) in Direction::ALL.into_iter().zip(faces) {
|
||||
let Some(face) = face else { continue };
|
||||
let Some(rect) = self.gui.atlas.sprites.get(face.sprite).copied() else {
|
||||
all = false;
|
||||
continue;
|
||||
};
|
||||
let uv = face.uv.unwrap_or_else(|| default_uv(from, to, direction));
|
||||
let material = Material {
|
||||
sprite: String::new(),
|
||||
force_translucent: false,
|
||||
};
|
||||
if let Ok(quad) = bake_flat_face(from, to, uv, direction, material, -1) {
|
||||
draw.quad(quad.positions, quad.uv, rect, tint);
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
/// Draws block `block`'s model (`mymod:cog`, any block with a model) in
|
||||
/// the state with `properties` (`[("axis", "x")]`, the rest default)
|
||||
/// under `draw`'s pose, as vanilla's `submitBlockModel` draws a block in
|
||||
/// an entity's place: a block's baked model, with its default tints. A
|
||||
/// moving part is often a block of the mod's own, never placed. False for
|
||||
/// an unknown block or property. API @API:block_entity_submit@.
|
||||
pub(super) fn native_draw_block(
|
||||
&self,
|
||||
draw: &mut BlockEntityDraw,
|
||||
block: &str,
|
||||
properties: &[(&str, &str)],
|
||||
) -> bool {
|
||||
let registry = &self.pack.assets.registry;
|
||||
let Some(mut state) = registry
|
||||
.default_state(block)
|
||||
.and_then(|id| registry.state(id))
|
||||
.cloned()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
for (key, value) in properties {
|
||||
match state.properties.get_mut(*key) {
|
||||
Some(v) => *v = (*value).to_owned(),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
let Some(id) = registry.id(&state) else {
|
||||
return false;
|
||||
};
|
||||
let pose = draw.pose();
|
||||
self.block_model_vertices(id, 42, pose, draw.light, true, &mut draw.vertices);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn draw() -> BlockEntityDraw {
|
||||
BlockEntityDraw {
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
light: 0xF0F0,
|
||||
poses: vec![Mat4::from_translation(Vec3::new(10.0, 0.0, 0.0))],
|
||||
lights: diffuse_lights(false),
|
||||
vertices: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sprite() -> Rect {
|
||||
Rect {
|
||||
u0: 0.5,
|
||||
v0: 0.25,
|
||||
u1: 0.75,
|
||||
v1: 0.5,
|
||||
w: 16,
|
||||
h: 16,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pose_stack_turns_about_its_pivot_and_pops_back() {
|
||||
let mut d = draw();
|
||||
d.push();
|
||||
d.rotate_about([0.0, 1.0, 0.0], 90.0, [0.5, 0.5, 0.5]);
|
||||
// (1, 0, 0.5) turns a quarter about the block's centre to (0.5, 0, 0).
|
||||
let p = d.pose().transform_point3(Vec3::new(1.0, 0.0, 0.5));
|
||||
assert!(p.abs_diff_eq(Vec3::new(10.5, 0.0, 0.0), 1e-5), "{p}");
|
||||
d.pop();
|
||||
d.pop();
|
||||
// Popping past the block's own pose keeps it.
|
||||
let p = d.pose().transform_point3(Vec3::ZERO);
|
||||
assert_eq!(p, Vec3::new(10.0, 0.0, 0.0));
|
||||
d.translate([0.0, 2.0, 0.0]);
|
||||
d.scale([2.0, 2.0, 2.0]);
|
||||
let p = d.pose().transform_point3(Vec3::ONE);
|
||||
assert_eq!(p, Vec3::new(12.0, 4.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quad_lands_in_its_sprite_lit_and_shaded() {
|
||||
let mut d = draw();
|
||||
// An upward face: full brightness under vanilla's level lights is
|
||||
// below 1 (two lights), so the colour is shaded but opaque.
|
||||
d.quad(
|
||||
[
|
||||
[0.0, 1.0, 0.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
],
|
||||
[[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
|
||||
sprite(),
|
||||
0xFFFFFF,
|
||||
);
|
||||
assert_eq!(d.vertices.len(), 4);
|
||||
let v = d.vertices[2];
|
||||
assert_eq!(v.pos, [11.0, 1.0, 1.0]);
|
||||
assert_eq!(v.uv, [0.75, 0.5]);
|
||||
assert_eq!(v.light, 0xF0F0);
|
||||
let up = diffuse(Vec3::Y, diffuse_lights(false));
|
||||
assert_eq!(v.color, shaded(0xFFFFFF, up));
|
||||
assert_eq!(v.color >> 24, 0xFF);
|
||||
}
|
||||
|
||||
/// A frustum that sees nothing.
|
||||
struct Blind;
|
||||
impl Cull for Blind {
|
||||
fn intersects(&self, _: Vec3, _: Vec3) -> bool {
|
||||
false
|
||||
}
|
||||
fn contains(&self, _: Vec3, _: Vec3) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infinite_bounds_and_frames_without_a_frustum_are_never_culled() {
|
||||
let origin = Vec3::new(1.0e6, 0.0, 0.0);
|
||||
let infinite = [f32::NEG_INFINITY, 0.0, 0.0, f32::INFINITY, 1.0, 1.0];
|
||||
assert!(!in_frustum(Some(&Blind), origin, DEFAULT_BOUNDS));
|
||||
assert!(in_frustum(Some(&Blind), origin, infinite));
|
||||
assert!(in_frustum(None::<&Blind>, origin, DEFAULT_BOUNDS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tint_keeps_its_channels_in_vertex_order() {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Native item data component helpers.
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// Failure to access a native item component.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum NativeComponentError {
|
||||
UnknownType,
|
||||
InvalidValue(horizon_protocol::Error),
|
||||
}
|
||||
impl std::fmt::Display for NativeComponentError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::UnknownType => f.write_str("unregistered component type"),
|
||||
Self::InvalidValue(error) => write!(f, "invalid component value: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for NativeComponentError {}
|
||||
|
||||
// Native mods use these helpers.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Read a native component, including the item's default when not patched.
|
||||
pub(super) fn native_component<'a>(
|
||||
&'a self,
|
||||
stack: &'a ItemStack,
|
||||
id: &str,
|
||||
) -> Result<Option<&'a Tag>, NativeComponentError> {
|
||||
if horizon_protocol::item::custom::id(id).is_none() {
|
||||
return Err(NativeComponentError::UnknownType);
|
||||
}
|
||||
if let Some(data) = stack.data() {
|
||||
if data.other(&format!("!{id}")).is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(value) = data.other(id) {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
}
|
||||
Ok(self
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|item| item.custom_components.get(id)))
|
||||
}
|
||||
|
||||
/// Set a registered native component after checking the network NBT limits.
|
||||
pub(super) fn native_set_component(
|
||||
&self,
|
||||
stack: &mut ItemStack,
|
||||
id: &str,
|
||||
value: Tag,
|
||||
) -> Result<(), NativeComponentError> {
|
||||
if horizon_protocol::item::custom::id(id).is_none() {
|
||||
return Err(NativeComponentError::UnknownType);
|
||||
}
|
||||
horizon_protocol::item::custom::validate(&value)
|
||||
.map_err(NativeComponentError::InvalidValue)?;
|
||||
let default = self
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|item| item.custom_components.get(id));
|
||||
let value = (default != Some(&value)).then_some(value);
|
||||
let removed = format!("!{id}");
|
||||
let data = stack.data_mut();
|
||||
data.set_other(&removed, None);
|
||||
data.set_other(id, value);
|
||||
stack.tidy();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a native component, including its item default.
|
||||
pub(super) fn native_remove_component(
|
||||
&self,
|
||||
stack: &mut ItemStack,
|
||||
id: &str,
|
||||
) -> Result<(), NativeComponentError> {
|
||||
if horizon_protocol::item::custom::id(id).is_none() {
|
||||
return Err(NativeComponentError::UnknownType);
|
||||
}
|
||||
let default = self
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|item| item.custom_components.contains_key(id));
|
||||
let removed = format!("!{id}");
|
||||
let data = stack.data_mut();
|
||||
data.set_other(id, None);
|
||||
data.set_other(&removed, default.then(|| Tag::Compound(Default::default())));
|
||||
stack.tidy();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! A mod's own advancement criteria triggers (docs/native-mods.md,
|
||||
//! "Advancement triggers"). An advancement of a content mod or a world's data
|
||||
//! pack names a trigger of the mod's (`mymod:pipe_connected`); the tree keeps
|
||||
//! it as it keeps vanilla's, and the mod fires it where its event happens, as
|
||||
//! a NeoForge mod calls its own `SimpleCriterionTrigger.trigger`.
|
||||
use super::{Game, advancements::item_view, commands::Target};
|
||||
use horizon_client::{
|
||||
advancements::{self as adv, EntityView},
|
||||
items::ItemStack,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
/// One open criterion of a mod's trigger, as the mod's test sees it.
|
||||
pub(crate) struct NativeCriterion<'a> {
|
||||
/// The criterion's `conditions`, `Null` when it has none. Its `player`
|
||||
/// predicate is the game's to test, as vanilla's `SimpleInstance.player`.
|
||||
pub(crate) conditions: &'a Value,
|
||||
world: &'a dyn adv::World,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl NativeCriterion<'_> {
|
||||
/// Whether `stack` passes the item predicate under `key` in the
|
||||
/// conditions, as the game's own triggers read an `ItemPredicate`:
|
||||
/// `items` (an id, a list or a `#tag`), `count`, the `potion_contents`
|
||||
/// and `damage` components, and the `enchantments` and `damage`
|
||||
/// predicates; other components and predicates aren't checked. A missing
|
||||
/// key passes, as an absent `Optional<ItemPredicate>` does.
|
||||
pub(crate) fn item(&self, key: &str, stack: &ItemStack) -> bool {
|
||||
adv::item(&self.conditions[key], &item_view(stack), self.world)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a mod may fire `trigger`: a namespaced id outside `minecraft`,
|
||||
/// whose triggers the game fires from its own events.
|
||||
fn mod_trigger(trigger: &str) -> bool {
|
||||
trigger
|
||||
.split_once(':')
|
||||
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty() && ns != "minecraft")
|
||||
}
|
||||
|
||||
/// Helpers for server mods, kept as stable as the hook points.
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Fires the mod's criteria trigger `trigger` (`mymod:pipe_connected`)
|
||||
/// for the player with that UUID: vanilla's
|
||||
/// `SimpleCriterionTrigger.trigger`. Each criterion of theirs with that
|
||||
/// trigger, not yet granted, of an advancement not yet done, is tested
|
||||
/// by `test` and then by its `player` predicate; those that pass are
|
||||
/// granted, and an advancement they complete is awarded with its toast,
|
||||
/// chat line and rewards, as the game's own triggers award.
|
||||
///
|
||||
/// The acting player is reached inside their hook; any player on the
|
||||
/// server, in their own level, only where no player's state is swapped in
|
||||
/// (`native_tick`, `native_menu_clicked`), as `native_run_command`. True
|
||||
/// when the player was reached, whether or not anything was granted;
|
||||
/// false off a server, when the player isn't on or can't be reached from
|
||||
/// here, and for a `minecraft:` trigger or a name without a namespace.
|
||||
/// API @API:native_criteria@.
|
||||
pub(super) fn native_trigger_criterion(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
trigger: &str,
|
||||
test: impl Fn(&NativeCriterion<'_>) -> bool,
|
||||
) -> bool {
|
||||
if !mod_trigger(trigger) || self.remote.is_some() {
|
||||
return false;
|
||||
}
|
||||
if self.lan_acting_uuid == Some(uuid) {
|
||||
self.trigger_mod_criteria(trigger, &test);
|
||||
return true;
|
||||
}
|
||||
if self.lan_acting.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(conn) = self.lan.as_ref().and_then(|host| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.uuid == uuid)
|
||||
.map(|g| g.conn)
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
// In their level: the predicates read where they are, and a reward
|
||||
// that doesn't fit drops there.
|
||||
self.for_player(Target::Guest(conn), |g| {
|
||||
g.trigger_mod_criteria(trigger, &test)
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// `trigger` for a mod's trigger, for the player in place.
|
||||
fn trigger_mod_criteria(&mut self, trigger: &str, test: &dyn Fn(&NativeCriterion<'_>) -> bool) {
|
||||
let me = self.player_entity();
|
||||
self.trigger(trigger, |conditions, world| {
|
||||
passes(conditions, &me, world, test)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `SimpleCriterionTrigger.trigger`'s test of one criterion for the player
|
||||
/// `me`: the mod's matcher, then the `player` predicate, in the player's own
|
||||
/// context (`EntityPredicate.createContext(player, player)`).
|
||||
fn passes(
|
||||
conditions: &Value,
|
||||
me: &EntityView,
|
||||
world: &dyn adv::World,
|
||||
test: &dyn Fn(&NativeCriterion<'_>) -> bool,
|
||||
) -> bool {
|
||||
let player = adv::Entities {
|
||||
this: Some(me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
test(&NativeCriterion { conditions, world })
|
||||
&& adv::conditions(&conditions["player"], &player, world)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Plains;
|
||||
impl adv::World for Plains {
|
||||
fn dimension(&self) -> &str {
|
||||
"minecraft:overworld"
|
||||
}
|
||||
fn biome(&self, _: [i32; 3]) -> String {
|
||||
"minecraft:plains".into()
|
||||
}
|
||||
fn structures(&self, _: [i32; 3]) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
fn block(&self, _: [i32; 3]) -> (String, BTreeMap<String, String>) {
|
||||
("minecraft:air".into(), BTreeMap::new())
|
||||
}
|
||||
fn fluid(&self, _: [i32; 3]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn can_see_sky(&self, _: [i32; 3]) -> bool {
|
||||
true
|
||||
}
|
||||
fn light(&self, _: [i32; 3]) -> i32 {
|
||||
15
|
||||
}
|
||||
fn smokey(&self, _: [i32; 3]) -> bool {
|
||||
false
|
||||
}
|
||||
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool {
|
||||
registry == "item" && tag == "c:ingots/copper" && id == "minecraft:copper_ingot"
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod's `pipe_connected` criterion: the pipe's item by a convention
|
||||
/// tag, and only for a player on fire (a loot condition list, as
|
||||
/// vanilla's advancements write `player`).
|
||||
fn criterion() -> Value {
|
||||
json!({
|
||||
"pipe": {"items": "#c:ingots/copper", "count": {"min": 2}},
|
||||
"player": [{
|
||||
"condition": "minecraft:entity_properties",
|
||||
"entity": "this",
|
||||
"predicate": {"flags": {"is_on_fire": true}}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mods_criterion_takes_its_own_test_and_the_player_predicate() {
|
||||
let burning = EntityView {
|
||||
kind: "minecraft:player".into(),
|
||||
on_fire: true,
|
||||
..EntityView::default()
|
||||
};
|
||||
let calm = EntityView {
|
||||
on_fire: false,
|
||||
..burning.clone()
|
||||
};
|
||||
let copper = ItemStack::new("minecraft:copper_ingot", 2);
|
||||
let pipe = |stack: &ItemStack| {
|
||||
let stack = stack.clone();
|
||||
move |c: &NativeCriterion<'_>| c.item("pipe", &stack)
|
||||
};
|
||||
let c = criterion();
|
||||
assert!(passes(&c, &burning, &Plains, &pipe(&copper)));
|
||||
assert!(
|
||||
!passes(&c, &calm, &Plains, &pipe(&copper)),
|
||||
"the player predicate"
|
||||
);
|
||||
let one = ItemStack::new("minecraft:copper_ingot", 1);
|
||||
assert!(
|
||||
!passes(&c, &burning, &Plains, &pipe(&one)),
|
||||
"the item's count"
|
||||
);
|
||||
let iron = ItemStack::new("minecraft:iron_ingot", 2);
|
||||
assert!(!passes(&c, &burning, &Plains, &pipe(&iron)), "the tag");
|
||||
// A criterion without the key or any conditions passes the item test.
|
||||
let any = |c: &NativeCriterion<'_>| c.item("filter", &iron);
|
||||
assert!(passes(&c, &burning, &Plains, &any));
|
||||
assert!(passes(&Value::Null, &calm, &Plains, &any));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mods_fire_only_their_own_namespaced_triggers() {
|
||||
assert!(mod_trigger("mymod:pipe_connected"));
|
||||
assert!(!mod_trigger("minecraft:inventory_changed"));
|
||||
assert!(!mod_trigger("inventory_changed"));
|
||||
assert!(!mod_trigger(":pipe_connected"));
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ impl Game<'_> {
|
||||
/// client's copy of the world, for another dimension, a position outside
|
||||
/// `/summon`'s bounds, a chunk that isn't loaded, a kind Horizon can't
|
||||
/// load, or one a mod kept out (`native_entity_join_level`). API
|
||||
/// @API:native_entities@.
|
||||
/// 59.
|
||||
pub(super) fn native_summon(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -139,15 +139,25 @@ impl Game<'_> {
|
||||
Tag::List(pos.map(|v| Tag::Double(v.to_bits())).to_vec()),
|
||||
);
|
||||
let from = self.mobs.len();
|
||||
let deferred = (self.tnts.len(), self.falling.len(), self.rockets.len());
|
||||
let target = self.spawn_entity_from_nbt(Tag::Compound(tag));
|
||||
// These roots load with id 0 and have no command Target variant.
|
||||
// Select the newly appended root, never an older waiting entity.
|
||||
let root = match kind {
|
||||
"minecraft:tnt" => self.tnts.get_mut(deferred.0).map(|e| &mut e.id),
|
||||
"minecraft:falling_block" => self.falling.get_mut(deferred.1).map(|e| &mut e.id),
|
||||
"minecraft:firework_rocket" => self.rockets.get_mut(deferred.2).map(|e| &mut e.id),
|
||||
_ => None,
|
||||
};
|
||||
let root = root.map(|id| super::lan_entities::assign_entity_id(&mut self.next_entity, id));
|
||||
self.native_mobs_joined(from, false);
|
||||
target.and_then(target_id)
|
||||
root.or_else(|| target.and_then(target_id))
|
||||
}
|
||||
/// Moves entity `id`, one Horizon keeps without simulating (a display, a
|
||||
/// marker, an interaction), to `pos` facing `yaw` and `pitch` in degrees;
|
||||
/// a display eases there over its `teleport` ticks. False for no such
|
||||
/// entity, another dimension or a position outside `/summon`'s bounds.
|
||||
/// Mobs move with `native_teleport_mob`. API @API:native_entities@.
|
||||
/// Mobs move with `native_teleport_mob`. API 59.
|
||||
pub(super) fn native_move_entity(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -175,7 +185,7 @@ impl Game<'_> {
|
||||
/// `block_display`, `item_display` or `text_display`): players see it
|
||||
/// ease from what it showed over `interpolation` ticks. False for no such
|
||||
/// display, another dimension, or a value that isn't finite, a zero
|
||||
/// quaternion, or a duration out of range. API @API:native_entities@.
|
||||
/// quaternion, or a duration out of range. API 59.
|
||||
pub(super) fn native_set_display(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -209,7 +219,7 @@ impl Game<'_> {
|
||||
/// Removes entity `id`, one Horizon keeps without simulating, as `/kill`
|
||||
/// does (it drops nothing and isn't saved). False for no such entity or
|
||||
/// another dimension. Mobs go with `native_remove_mob`. API
|
||||
/// @API:native_entities@.
|
||||
/// 59.
|
||||
pub(super) fn native_remove_entity(&mut self, dimension: &str, id: u32) -> bool {
|
||||
if !self.native_entity_level(dimension) || !self.others.iter().any(|o| o.id == id) {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Explosions for native server mods (docs/native-mods.md, "Explosions"): a mod
|
||||
//! sets one off as `Level.explode` does, in the level in place, which it names
|
||||
//! by its `dimension` (`minecraft:overworld`) as `native_set_block` does.
|
||||
use super::{Game, explosion::BlockInteraction};
|
||||
|
||||
/// The largest radius `native_explode` takes. A blast's cost grows with its
|
||||
/// radius (its 1352 rays walk `radius * 1.3` blocks at most); vanilla's
|
||||
/// largest is the wither's charged spawn, 7.
|
||||
pub(super) const NATIVE_EXPLOSION_MAX_RADIUS: f32 = 32.0;
|
||||
|
||||
/// What a mod's blast does to blocks (`Level.ExplosionInteraction`). Built by
|
||||
/// mods only.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum NativeBlast {
|
||||
/// Blocks stay; entities are hurt and pushed (`ExplosionInteraction.NONE`).
|
||||
Keep,
|
||||
/// Blocks break and drop by the `block_explosion_drop_decay` rule
|
||||
/// (`ExplosionInteraction.BLOCK`).
|
||||
#[default]
|
||||
Block,
|
||||
/// Blocks break and drop by the `tnt_explosion_drop_decay` rule
|
||||
/// (`ExplosionInteraction.TNT`).
|
||||
Tnt,
|
||||
}
|
||||
|
||||
/// Whether `native_explode` takes this centre and radius.
|
||||
fn blast_ok(center: [f64; 3], radius: f32) -> bool {
|
||||
center
|
||||
.into_iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= 30_000_000.0)
|
||||
&& radius.is_finite()
|
||||
&& radius > 0.0
|
||||
&& radius <= NATIVE_EXPLOSION_MAX_RADIUS
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Explodes at `center` in the level in place, `dimension`, as
|
||||
/// `Level.explode(null, x, y, z, radius, fire, interaction)` does: the
|
||||
/// blocks it reaches break (`native_explosion_detonate` sees them first),
|
||||
/// entities are hurt and pushed, `fire` lights a third of the air above
|
||||
/// solid blocks, and everyone near hears and sees it. False, with nothing
|
||||
/// done, on a client's copy of the world, for another dimension, a chunk
|
||||
/// that isn't loaded, or a radius that isn't in `0 < radius <= 32`. Call
|
||||
/// it from a server hook; a blast inside `native_explosion_detonate`
|
||||
/// starts that hook again. API @API:native_explode@.
|
||||
pub(super) fn native_explode(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
fire: bool,
|
||||
blast: NativeBlast,
|
||||
) -> bool {
|
||||
if (self.serving.is_none() && self.options.dedicated.is_none())
|
||||
|| dimension != self.dim.id()
|
||||
|| !blast_ok(center, radius)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let chunk = (
|
||||
(center[0].floor() as i32) >> 4,
|
||||
(center[2].floor() as i32) >> 4,
|
||||
);
|
||||
if !self.world.chunks.contains_key(&chunk) {
|
||||
return false;
|
||||
}
|
||||
let interaction = match blast {
|
||||
NativeBlast::Keep => BlockInteraction::Keep,
|
||||
NativeBlast::Block => self.destroy_type("block_explosion_drop_decay"),
|
||||
NativeBlast::Tnt => self.destroy_type("tnt_explosion_drop_decay"),
|
||||
};
|
||||
// `Level.explode(null, ...)`: no source. Called from inside another
|
||||
// blast (`native_explosion_detonate`), it must not take that blast's
|
||||
// source, owner or flags, and that blast gets them back after.
|
||||
let outer = self.take_blast_state();
|
||||
let done = self
|
||||
.server_explode(center, radius, interaction, fire)
|
||||
.is_ok();
|
||||
self.restore_blast_state(outer);
|
||||
done
|
||||
}
|
||||
|
||||
/// The blast going off now, cleared to a sourceless one.
|
||||
fn take_blast_state(&mut self) -> BlastState {
|
||||
let c = &mut self.combat;
|
||||
BlastState {
|
||||
source: c.explosion_source.take(),
|
||||
cause: c.explosion_cause.take(),
|
||||
source_named: std::mem::take(&mut c.explosion_source_named),
|
||||
in_water: std::mem::take(&mut c.explosion_in_water),
|
||||
hive_emptying: std::mem::take(&mut c.hive_emptying_blast),
|
||||
rail_proof: std::mem::take(&mut c.rail_proof_blast),
|
||||
owner: c.explosion_owner.take(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts back what `take_blast_state` cleared.
|
||||
fn restore_blast_state(&mut self, s: BlastState) {
|
||||
let c = &mut self.combat;
|
||||
c.explosion_source = s.source;
|
||||
c.explosion_cause = s.cause;
|
||||
c.explosion_source_named = s.source_named;
|
||||
c.explosion_in_water = s.in_water;
|
||||
c.hive_emptying_blast = s.hive_emptying;
|
||||
c.rail_proof_blast = s.rail_proof;
|
||||
c.explosion_owner = s.owner;
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of `CombatTracker` that describe the blast going off now.
|
||||
struct BlastState {
|
||||
source: Option<u32>,
|
||||
cause: Option<horizon_client::advancements::EntityView>,
|
||||
source_named: bool,
|
||||
in_water: bool,
|
||||
hive_emptying: bool,
|
||||
rail_proof: bool,
|
||||
owner: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_blast_needs_a_finite_centre_and_a_radius_in_range() {
|
||||
assert!(blast_ok([0.5, 64.0, 0.5], 4.0));
|
||||
assert!(blast_ok([0.5, 64.0, 0.5], NATIVE_EXPLOSION_MAX_RADIUS));
|
||||
assert!(!blast_ok([0.5, 64.0, 0.5], 0.0));
|
||||
assert!(!blast_ok([0.5, 64.0, 0.5], -1.0));
|
||||
assert!(!blast_ok([0.5, 64.0, 0.5], 33.0));
|
||||
assert!(!blast_ok([0.5, 64.0, 0.5], f32::NAN));
|
||||
assert!(!blast_ok([f64::INFINITY, 64.0, 0.5], 4.0));
|
||||
assert!(!blast_ok([0.5, 64.0, 4.0e7], 4.0));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,19 @@
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! API 62 adds nicknames for server mods: `native_set_nickname` shows a name in place of a player's in chat and
|
||||
//! the Tab list (the real one on hover), and `native_text_allowed` asks the server's chat filter, in
|
||||
//! `native_server.rs`.
|
||||
//!
|
||||
//! API 61 adds mods' own sound events in `native_play_sound`: another
|
||||
//! namespace's event is sent by name, as vanilla sends an unregistered sound.
|
||||
//!
|
||||
//! API 60 adds the small bar under an item (`native_item_bar`): `GuiGraphicsExtractor.itemDecorations`
|
||||
//! hands it vanilla's choice (the damage bar, or a bundle's fullness already applied), to replace or hide.
|
||||
//!
|
||||
//! API 59 adds entities for server mods (`native_summon`, `native_move_entity`,
|
||||
//! `native_set_display`, `native_remove_entity`, in `native_entities.rs`).
|
||||
//!
|
||||
//! API 58 adds the renderer's hook points, in `horizon-client-render`'s
|
||||
//! `gfx/native_render.rs`: `native_world_drawn` (draw with the world's colour,
|
||||
//! depth, matrices and entity geometry before the hand and the HUD) and
|
||||
@@ -205,6 +218,7 @@ use super::native_lifecycle::{NativeLifecycleContext, ServerTickPhase, WorldPhas
|
||||
use super::{
|
||||
Game, GameLevel, LevelDimension, Screen,
|
||||
execution_role::ExecutionRole,
|
||||
native_block_entity_draw::BlockEntityDraw,
|
||||
native_panel_recipes::RecipePage,
|
||||
native_server::{NativeCommand, NativeServerPlayer},
|
||||
};
|
||||
@@ -232,8 +246,8 @@ use horizon_core::{
|
||||
ticks::{ScheduleResult, TickPriority},
|
||||
};
|
||||
use horizon_world::{
|
||||
fluid::FluidKind,
|
||||
level::{BlockId, LevelAccess, Ticks},
|
||||
fluid::{FluidKind, FluidState},
|
||||
level::{BlockId, LevelAccess, PlacementContext, Ticks},
|
||||
nbt::Compound,
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
@@ -255,7 +269,7 @@ pub(super) use horizon_world::level::flags;
|
||||
/// The hook points' API version. The launcher reads this line from the
|
||||
/// source it builds; keep its form.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 58;
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 62;
|
||||
|
||||
impl GameLevel<'_> {
|
||||
/// An authoritative server committed a changed state through `Level.setBlock`.
|
||||
@@ -351,6 +365,39 @@ impl GameLevel<'_> {
|
||||
let _ = (state, source);
|
||||
}
|
||||
|
||||
/// `BlockState.hasAnalogOutputSignal`: set `output` for a block of yours
|
||||
/// that comparators read (how full or far along it is, as a composter's
|
||||
/// level), so they ask `native_analog_output`. Asked from inside the
|
||||
/// level's update: answer from `state` alone. Authoritative servers only.
|
||||
/// API @API:analog_output@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_has_analog_output(&self, state: BlockStateId, output: &mut bool) {
|
||||
let _ = (state, output);
|
||||
}
|
||||
|
||||
/// `BlockState.getAnalogOutputSignal`: the signal a comparator reads from
|
||||
/// the block `state` at `pos`, `face` the side that touches the comparator
|
||||
/// (or the conductor in front of it). `output` holds vanilla's answer:
|
||||
/// change it when the block is yours (0 to 15; anything else is clamped).
|
||||
/// Asked only for blocks `native_has_analog_output` answered for, from
|
||||
/// inside the level's update: don't change the world here. When the
|
||||
/// answer changes without the block's state changing, call
|
||||
/// `native_update_output_neighbors`; a change of state through
|
||||
/// `native_set_block` or `native_set_block_property` (neighbour updates
|
||||
/// on) tells the comparators itself, as vanilla's `Level.setBlock` does.
|
||||
/// Authoritative servers only. API @API:analog_output@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_analog_output(
|
||||
&self,
|
||||
dimension: LevelDimension,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
face: Direction,
|
||||
output: &mut i32,
|
||||
) {
|
||||
let _ = (dimension, pos, state, face, output);
|
||||
}
|
||||
|
||||
/// An authoritative server's `ServerLevel.tickChunk` is about to
|
||||
/// random-tick the block `state` at `pos` (`BlockState.randomTick`):
|
||||
/// only blocks that tick randomly, a mod's as its vanilla template block
|
||||
@@ -410,6 +457,45 @@ impl GameLevel<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
/// On an authoritative server, `BlockState.canSurvive` for `state` at
|
||||
/// `pos` (`BlockBehaviour.canSurvive`), before the block answers it: as
|
||||
/// it is placed (`BlockItem.canPlace`), from a shape update of a block
|
||||
/// whose template checks it (a plant, a torch, a sign: false breaks the
|
||||
/// block with its drops), and wherever else vanilla asks. `state` may not
|
||||
/// be in the world yet, so decide from `state`. Answer only for your own
|
||||
/// blocks: `Some(false)` refuses the spot, `Some(true)` allows it, and
|
||||
/// the block's own rule doesn't run. The level is read-only. Asked on
|
||||
/// every shape update of such a block: keep it short. API @API:can_survive@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_can_survive(
|
||||
&self,
|
||||
dimension: LevelDimension,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
survives: &mut Option<bool>,
|
||||
) {
|
||||
let _ = (dimension, pos, state, survives);
|
||||
}
|
||||
|
||||
/// On an authoritative server, `VegetationBlock.mayPlaceOn` for the
|
||||
/// plant `state` on `ground` at `ground_pos`, before the plant answers
|
||||
/// it: the blocks a mod plant (one whose template is a vanilla plant,
|
||||
/// such as a crop) can stand on. Its template's `canSurvive` asks it, so
|
||||
/// the plant is refused there and pops off when its ground changes to
|
||||
/// one it can't stand on. Answer only for your own blocks. The level is
|
||||
/// read-only. API @API:can_survive@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_may_place_on(
|
||||
&self,
|
||||
dimension: LevelDimension,
|
||||
state: BlockStateId,
|
||||
ground: BlockStateId,
|
||||
ground_pos: BlockPos,
|
||||
may: &mut Option<bool>,
|
||||
) {
|
||||
let _ = (dimension, state, ground, ground_pos, may);
|
||||
}
|
||||
|
||||
/// An authoritative server's `ServerLevel.tickBlock` is about to run a
|
||||
/// due scheduled tick of the block `state` at `pos` (`BlockState.tick`):
|
||||
/// the block is still the one the tick was scheduled for. Change the
|
||||
@@ -427,6 +513,121 @@ impl GameLevel<'_> {
|
||||
) {
|
||||
let _ = (ticks, dimension, pos, state, handled);
|
||||
}
|
||||
|
||||
/// A block is about to take the state it is placed in
|
||||
/// (`Block.getStateForPlacement(BlockPlaceContext)`), `state` the block's
|
||||
/// own default state. Answer only for your blocks: put the state you want
|
||||
/// in `answer` as `Some(Some(state))` — a state of that block, as vanilla
|
||||
/// sets properties on its own `defaultBlockState` (another block's is
|
||||
/// ignored) — or refuse the placement with `Some(None)`: the block goes
|
||||
/// nowhere and the stack is kept, as vanilla's `null`. Left empty, the
|
||||
/// block's own placement runs. `context` is
|
||||
/// vanilla's `BlockPlaceContext` (the clicked face, the placer's rotation,
|
||||
/// the clicked position, whether the clicked block is being replaced, the
|
||||
/// item in hand) and `fluid` the fluid in the position the block would go
|
||||
/// in, which a waterlogged block reads. Build the state from `state` with
|
||||
/// `native_state_with_property`. It is asked for every placer that goes
|
||||
/// through vanilla's placement (a player's `BlockItem.place`, a
|
||||
/// dispenser's `DirectionalPlaceContext`), and the answer still takes its
|
||||
/// survival and entity obstruction checks. Authoritative servers only.
|
||||
/// @API:placement_state@.
|
||||
#[inline(always)]
|
||||
#[allow(clippy::option_option)]
|
||||
pub(super) fn native_state_for_placement(
|
||||
&self,
|
||||
dimension: LevelDimension,
|
||||
state: BlockStateId,
|
||||
context: &PlacementContext,
|
||||
fluid: FluidState,
|
||||
answer: &mut Option<Option<BlockStateId>>,
|
||||
) {
|
||||
let _ = (dimension, state, context, fluid, answer);
|
||||
}
|
||||
|
||||
/// A block nothing in Horizon knows a `BonemealableBlock` for is about to
|
||||
/// take bone meal: vanilla's `BonemealableBlock.isValidBonemealTarget`,
|
||||
/// `state` the block's own state at `pos`. Answer `valid` for your own
|
||||
/// blocks: the meal is used up on one as vanilla uses it up (the growth
|
||||
/// particles and the sound follow, whether or not the roll below grows
|
||||
/// it), from a player's `BoneMealItem.useOn`, from a dispenser and, on a
|
||||
/// crop (a villager bonemeals only a `CropBlock`), from a villager. Asked
|
||||
/// on both sides, as vanilla asks it on both, so a client predicts the
|
||||
/// use with your answer: say no where you can only grow on a server
|
||||
/// (`is_client_side`). `Some(false)` refuses the meal, even on a block
|
||||
/// whose template takes it (vanilla's `growCrop` then keeps the meal);
|
||||
/// left `None`, the block keeps the `BonemealableBlock` its template gives
|
||||
/// it, or takes no bone meal.
|
||||
/// @API:bonemeal@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_is_valid_bonemeal_target(
|
||||
&self,
|
||||
dimension: LevelDimension,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
valid: &mut Option<bool>,
|
||||
) {
|
||||
let _ = (dimension, pos, state, valid);
|
||||
}
|
||||
|
||||
/// `BonemealableBlock.isBonemealSuccess` for a block that answered
|
||||
/// `native_is_valid_bonemeal_target`, after it did: set `success` to grow
|
||||
/// it this time. Vanilla draws the level's random here
|
||||
/// (`isBonemealSuccess(Level, RandomSource, BlockPos, BlockState)`), so
|
||||
/// draw from `native_level_random` to keep vanilla's order.
|
||||
/// Authoritative servers only. @API:bonemeal@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_is_bonemeal_success(
|
||||
&mut self,
|
||||
dimension: LevelDimension,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
success: &mut bool,
|
||||
) {
|
||||
let _ = (dimension, pos, state, success);
|
||||
}
|
||||
|
||||
/// `BonemealableBlock.performBonemeal` for a block that answered
|
||||
/// `native_is_valid_bonemeal_target` and whose `native_is_bonemeal_success`
|
||||
/// said yes: grow it here, `state` its state at `pos`. The meal is used up
|
||||
/// either way. Change the block with the level's
|
||||
/// `native_set_block_property`, and schedule with `native_schedule_block_tick`
|
||||
/// (`ticks` is the hook's). Authoritative servers only. @API:bonemeal@.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_perform_bonemeal(
|
||||
&mut self,
|
||||
ticks: &mut Ticks,
|
||||
dimension: LevelDimension,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
) {
|
||||
let _ = (ticks, dimension, pos, state);
|
||||
}
|
||||
}
|
||||
|
||||
/// What dropped the items [`Game::native_loot_drops`] hears.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct NativeLootDrops<'a> {
|
||||
/// The block's or mob's own loot table (`minecraft:blocks/stone`, `minecraft:entities/zombie`);
|
||||
/// empty when none rolled: the block or mob has none, the `mob_drops` rule is off, a baby
|
||||
/// animal. A mob's drops may hold more: a charged creeper's head, a fox's mouth item.
|
||||
pub(crate) table: &'a str,
|
||||
/// The level they drop in (`minecraft:overworld`).
|
||||
pub(crate) dimension: &'a str,
|
||||
/// `LootContextParams.ORIGIN`: a broken block's centre, a mob's position.
|
||||
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`).
|
||||
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
|
||||
/// that dies to lava long after names none.
|
||||
pub(crate) player: Option<u128>,
|
||||
/// What broke the block (`LootContextParams.TOOL`).
|
||||
pub(crate) tool: Option<&'a ItemStack>,
|
||||
/// The radius of the explosion that broke the block.
|
||||
pub(crate) explosion: Option<f32>,
|
||||
}
|
||||
|
||||
/// A depth-tested outline of the client's block selection shape.
|
||||
@@ -488,6 +689,49 @@ 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.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeScreenView<'a> {
|
||||
/// One id per `Screen` (`Screen::id`): `inventory`, `creative`, `paused`,
|
||||
/// `stats`, `options`, `win`… Stable across releases, so a mod can match
|
||||
/// on it. Every container, station and mod window draws on the inventory
|
||||
/// screen and so reports `inventory`: tell them apart by `menu` and
|
||||
/// `title` (both None on a window the client opened itself) and by
|
||||
/// `slots`.
|
||||
pub id: &'static str,
|
||||
/// The type id of the menu the server opened for this screen
|
||||
/// (`minecraft:generic_9x3`, `minecraft:furnace`, a mod's own id; a mount's
|
||||
/// inventory is `horizon:mount<n>`). None on a screen without one (the
|
||||
/// client's own inventory, creative, the pause menu, stats…) and for a
|
||||
/// menu type the client does not know.
|
||||
pub menu: Option<&'a str>,
|
||||
/// The title the server gave the open menu, if it has one: the component
|
||||
/// itself, not a copy (`.plain()` it to draw, `chars()` for the styles).
|
||||
/// None when the server gave none, on a screen the client opened, and
|
||||
/// while playing.
|
||||
pub title: Option<&'a Text>,
|
||||
/// The screen's container panel in GUI pixels, `[x, y, width, height]`;
|
||||
/// the whole window for a screen with none. The panel is where the game
|
||||
/// draws the window: 176 wide for most, wider for the merchant and the
|
||||
/// beacon, a mod screen's own size for a mod's.
|
||||
pub rect: [f32; 4],
|
||||
/// The slots the open menu has, as `Game::slots` counts them: the player
|
||||
/// inventory's 36, then the container's, the station's, the mod screen's
|
||||
/// or the crafting grid's (a screen with no menu of its own still counts
|
||||
/// the inventory menu's). Counted, not listed.
|
||||
pub slots: usize,
|
||||
/// The pointer in GUI pixels (`Screen.mouseX`, `mouseY`, from the
|
||||
/// window's top left), as `native_cursor` gives it: for hover highlights.
|
||||
pub mouse: [f32; 2],
|
||||
/// This frame's partial tick (`getGameTimeDeltaPartialTick(false)`, 0 to
|
||||
/// 1): what the screen draws animation against.
|
||||
pub partial_tick: f32,
|
||||
}
|
||||
|
||||
/// The mod screen open on this client: the block it is for, the key its
|
||||
/// slots are under, the window id of its menu, and the menu's data slots as
|
||||
/// the server sent them.
|
||||
@@ -899,6 +1143,10 @@ impl Game<'_> {
|
||||
pub(super) fn native_minimap(&self, minimap: &mut Option<NativeMinimap>) {
|
||||
let _ = minimap;
|
||||
}
|
||||
/// Register namespaced NBT component types before content loads.
|
||||
pub(super) fn native_component_types(types: &mut Vec<String>) {
|
||||
let _ = types;
|
||||
}
|
||||
/// Options > Mods lists the native mods: push yours.
|
||||
pub(super) fn native_mods(&mut self, mods: &mut Vec<NativeMod>) {
|
||||
let _ = mods;
|
||||
@@ -936,6 +1184,25 @@ impl Game<'_> {
|
||||
pub(super) fn native_block_entity_ticks(&self, block: &str, ticks: &mut bool) {
|
||||
let _ = (block, ticks);
|
||||
}
|
||||
/// A `minecraft:post_attack` effect of a mod's own kind, on a mob a
|
||||
/// player's weapon hit: the entry says `enchanted` attacker and `affected`
|
||||
/// victim, its `requirements` passed, and its `effect` names a `type` in a
|
||||
/// namespace outside `minecraft:`. Told the enchantment id, the kind, the
|
||||
/// `effect` JSON, the level and the mob's id (it may have just died). The
|
||||
/// acting player is the one whose weapon carries the enchantment. Each
|
||||
/// such entry is told once a hit, and once for each mob a sweep lands on.
|
||||
/// A hit on a player, and an entry inside `minecraft:all_of`, aren't told
|
||||
/// yet. An unclaimed kind does nothing. API @API:enchant_effect@.
|
||||
pub(super) fn native_enchant_effect(
|
||||
&mut self,
|
||||
enchantment: &str,
|
||||
kind: &str,
|
||||
data: &serde_json::Value,
|
||||
level: u32,
|
||||
victim: u32,
|
||||
) {
|
||||
let _ = (enchantment, kind, data, level, victim);
|
||||
}
|
||||
/// Block `block`'s block entity has item slots (a `WorldlyContainer`):
|
||||
/// 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
|
||||
@@ -978,6 +1245,15 @@ impl Game<'_> {
|
||||
pub(super) fn native_block_entity_tick(&mut self, pos: [i32; 3], entity: &mut ModBlockEntity) {
|
||||
let _ = (pos, entity);
|
||||
}
|
||||
/// A consumable's effect of a mod's own kind: the item's `on_consume_effects`
|
||||
/// entry is of a type in a namespace of its own, and the loader kept it
|
||||
/// for this call. The kind and the JSON the file wrote are here; the acting
|
||||
/// player is the one consuming, and the game's own effects,
|
||||
/// sounds, teleports and drops are open. An unclaimed kind does
|
||||
/// nothing. API @API:consume_effect@.
|
||||
pub(super) fn native_consume_effect(&mut self, kind: &str, data: &serde_json::Value) {
|
||||
let _ = (kind, data);
|
||||
}
|
||||
/// What clients get of the block entity at `pos` when its `id` is
|
||||
/// yours (`BlockEntity.getUpdateTag`): set `tag` to it, read from
|
||||
/// `entity` alone. Asked on the server whenever a chunk holding it is
|
||||
@@ -992,6 +1268,44 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (pos, entity, tag);
|
||||
}
|
||||
/// Draw the mod block entity at `pos`, of block `block`
|
||||
/// (`mymod:machine`), on this client: once a frame while it is in view
|
||||
/// (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
|
||||
/// (`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 @API:block_entity_submit@.
|
||||
pub(super) fn native_block_entity_submit(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
block: &str,
|
||||
state: BlockStateId,
|
||||
entity: &ModBlockEntity,
|
||||
draw: &mut BlockEntityDraw,
|
||||
) {
|
||||
let _ = (pos, block, state, entity, draw);
|
||||
}
|
||||
/// Where the mod block entity at `pos` draws, for culling
|
||||
/// (NeoForge's `getRenderBoundingBox`): `bounds` is min x, y, z then max
|
||||
/// x, y, z, in blocks from the block's lower north-west corner. It starts
|
||||
/// as the block with a block of margin around it; widen it when your
|
||||
/// `native_block_entity_submit` draws further (a tall shaft, a beam), or
|
||||
/// set an infinite value for one never culled. Asked once a frame for each
|
||||
/// within 64 blocks whose chunk is drawn. API @API:block_entity_submit@.
|
||||
pub(super) fn native_block_entity_render_bounding_box(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
block: &str,
|
||||
state: BlockStateId,
|
||||
entity: &ModBlockEntity,
|
||||
bounds: &mut [f32; 6],
|
||||
) {
|
||||
let _ = (pos, block, state, entity, bounds);
|
||||
}
|
||||
/// A menu opens on this client for block `block`, a mod's: set `screen`
|
||||
/// when the block is yours, to lay its screen out (`native_screen_draw`
|
||||
/// draws it). The server asks it too: a slot it hides is not in the menu
|
||||
@@ -1015,6 +1329,136 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (block, data, batch, origin);
|
||||
}
|
||||
/// The open screen has drawn itself and its tooltips (`Screen.render`,
|
||||
/// the point of `ScreenEvent.Render.Post`): draw over it with
|
||||
/// `native_fill`, `native_sprite` and `native_text`, `screen.rect` being
|
||||
/// its container panel (or the whole window) and `screen.id` the screen
|
||||
/// it is. Once a frame on the window, never while playing with no screen;
|
||||
/// `&self`, so it reads and draws only. The Win and Options screens call
|
||||
/// it on their own path, after their drawing.
|
||||
///
|
||||
/// It does not fire on the title screen, which returns before its point,
|
||||
/// nor for the screens drawn over gameplay after it (a server dialog, the
|
||||
/// server pack, link and code-of-conduct prompts, a book, a sign editor):
|
||||
/// those keep `Screen::Playing`, which it skips, and draw over what it
|
||||
/// drew, while vanilla's `ScreenEvent.Render.Post` fires for them.
|
||||
/// API @API:screen_render_post@.
|
||||
pub(super) fn native_screen_render_post(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
screen: &NativeScreenView<'_>,
|
||||
) {
|
||||
let _ = (batch, screen);
|
||||
}
|
||||
/// A mouse button went down over the open screen
|
||||
/// (`ScreenEvent.MouseButtonPressed.Pre`, vanilla's
|
||||
/// `Screen.mouseClicked`): `screen` is the screen it is, `screen.mouse`
|
||||
/// where the pointer is (so there is no position of its own), `button` the
|
||||
/// button's GLFW index (0 left, 1 right, 2 middle, 3 back, 4 forward; a
|
||||
/// button past those is the platform's own number, which differs between
|
||||
/// systems) and `modifiers` GLFW's bits (shift 1, control 2,
|
||||
/// alt 4, super 8). Set `canceled` and the press goes no further: no slot,
|
||||
/// no screen button, no drag, no hotbar key, no sort or header button.
|
||||
///
|
||||
/// It fires before anything the screen does with the press, on the window
|
||||
/// only, 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
|
||||
/// click there stays the game's own. A gamepad click or a click the
|
||||
/// keyboard's focus sends is not the mouse and fires nothing.
|
||||
/// API @API:screen_mouse_button@.
|
||||
pub(super) fn native_screen_mouse_button_pressed_pre(
|
||||
&self,
|
||||
screen: &NativeScreenView<'_>,
|
||||
button: i32,
|
||||
modifiers: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (screen, button, modifiers, canceled);
|
||||
}
|
||||
/// A mouse button went up over the open screen
|
||||
/// (`ScreenEvent.MouseButtonReleased.Pre`, vanilla's
|
||||
/// `Screen.mouseReleased`), with the same `screen`, `button` and
|
||||
/// `modifiers` as `native_screen_mouse_button_pressed_pre` and fired at
|
||||
/// the same point. Set `canceled` and the release goes no further: a
|
||||
/// pressed slot keeps what it took and no drag, slider or scrollbar lets
|
||||
/// go.
|
||||
///
|
||||
/// Cancel a release only when the press it ends was cancelled too: a
|
||||
/// release the screen took ends the drag or the scroll it began, as it
|
||||
/// does in vanilla, and no guard here un-cancels it. Same screens as the
|
||||
/// press; a press that opened another screen ends on that one.
|
||||
/// API @API:screen_mouse_button@.
|
||||
pub(super) fn native_screen_mouse_button_released_pre(
|
||||
&self,
|
||||
screen: &NativeScreenView<'_>,
|
||||
button: i32,
|
||||
modifiers: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (screen, button, 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
|
||||
/// drawn this frame, which `NativeScreenView.id` comes from. Borrowed:
|
||||
/// nothing is copied per frame.
|
||||
pub(super) fn screen_view(&self, batch: &HudBatch, screen: Screen) -> NativeScreenView<'_> {
|
||||
NativeScreenView {
|
||||
id: screen.id(),
|
||||
menu: self.remote_menu_type().filter(|m| !m.is_empty()),
|
||||
title: self.remote.as_ref().and_then(|r| r.menu_title.as_ref()),
|
||||
rect: self.screen_rect(batch, screen),
|
||||
slots: self.slot_count(),
|
||||
mouse: self.native_cursor(),
|
||||
partial_tick: self.clock.fraction() as f32,
|
||||
}
|
||||
}
|
||||
/// The open screen's container panel in GUI pixels (`screen`'s, the one
|
||||
/// this frame draws), or the whole window for a screen with none
|
||||
/// (`NativeScreenView.rect`).
|
||||
pub(super) fn screen_rect(&self, batch: &HudBatch, screen: Screen) -> [f32; 4] {
|
||||
if screen != Screen::Inventory && screen != Screen::Creative {
|
||||
return [0.0, 0.0, batch.width, batch.height];
|
||||
}
|
||||
// The window the game itself draws (`container_image`): the merchant
|
||||
// and the beacon are wider and shifted, creative is its own size.
|
||||
let image = self.container_image(batch);
|
||||
[
|
||||
image.left as f32,
|
||||
image.top as f32,
|
||||
image.width as f32,
|
||||
image.height as f32,
|
||||
]
|
||||
}
|
||||
/// A screen the native mods' input hooks are for is open: the window has a
|
||||
/// renderer, `Screen::Playing` is behind it (so not the title screen, nor a
|
||||
/// book, a sign editor, a server dialog or a prompt drawn over gameplay)
|
||||
/// and no connect or world load is in progress. The one gate they all ask
|
||||
/// before they fire.
|
||||
pub(super) fn screen_hooks_open(&self) -> bool {
|
||||
self.renderer.is_some()
|
||||
&& self.screen != Screen::Playing
|
||||
&& !self.screen_over_playing()
|
||||
&& !self.remote_loading()
|
||||
}
|
||||
/// The open screen as an input hook sees it (`NativeScreenView`), or None
|
||||
/// when `screen_hooks_open` says no screen is. `screen.mouse` is the
|
||||
/// pointer the hook's event is at, so a hook takes no position, and
|
||||
/// `partial_tick` is the clock's fraction at this event, not a frame's.
|
||||
/// The `HudBatch` built here is only what `screen.rect` needs: the
|
||||
/// window's GUI scale, no allocation.
|
||||
pub(super) fn screen_view_now(&self) -> Option<NativeScreenView<'_>> {
|
||||
if !self.screen_hooks_open() {
|
||||
return None;
|
||||
}
|
||||
let renderer = self.renderer.as_ref()?;
|
||||
let batch = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
Some(self.screen_view(&batch, self.screen))
|
||||
}
|
||||
/// 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.
|
||||
@@ -1120,11 +1564,30 @@ impl Game<'_> {
|
||||
pub(super) fn native_tooltip(&self, stack: &ItemStack, lines: &mut Vec<(Text, u32)>) {
|
||||
let _ = (stack, lines);
|
||||
}
|
||||
/// Client-side image component for this stack's tooltip. Answer its width/height
|
||||
/// in GUI pixels; finite positive dimensions reserve space after text and any
|
||||
/// vanilla bundle component. None keeps the vanilla tooltip unchanged.
|
||||
pub(super) fn native_tooltip_component(&self, stack: &ItemStack, size: &mut Option<[f32; 2]>) {
|
||||
let _ = (stack, size);
|
||||
}
|
||||
/// Render the measured component at origin, inside the tooltip background.
|
||||
/// Stack is the component's data. Use native_sprite/native_fill/native_text;
|
||||
/// size is the measurement returned by native_tooltip_component this frame.
|
||||
pub(super) fn native_tooltip_component_draw(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
batch: &mut HudBatch,
|
||||
origin: [f32; 2],
|
||||
size: [f32; 2],
|
||||
) {
|
||||
let _ = (stack, batch, origin, size);
|
||||
}
|
||||
|
||||
/// The small bar `GuiGraphicsExtractor.itemDecorations` draws under an
|
||||
/// item, wherever the game shows one: `bar` arrives holding vanilla's value
|
||||
/// (the damage bar, or a bundle's fullness already applied) and may be
|
||||
/// replaced (width `0..=13`, colour `rgb`) or set to `None` to hide it.
|
||||
/// Asked each frame a stack is drawn. Client-side. @API:item_bar@.
|
||||
/// Asked each frame a stack is drawn. Client-side. 60.
|
||||
pub(super) fn native_item_bar(&self, stack: &ItemStack, bar: &mut Option<(i32, u32)>) {
|
||||
let _ = (stack, bar);
|
||||
}
|
||||
@@ -1144,6 +1607,25 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (stack, pos, face, claimed);
|
||||
}
|
||||
/// `DispenserBlock.getDispenseMethod`: choose a behaviour for any item,
|
||||
/// including vanilla items, on the authoritative server (not droppers).
|
||||
/// Leave answer None for vanilla. Otherwise answer `(remainder, event)`:
|
||||
/// a remainder of None empties the slot, `Some(stack)` replaces it (the hook
|
||||
/// owns consumption: no extra item is removed afterwards). `event` is the
|
||||
/// level event the dispenser plays with its smoke: 1000 the click, 1001 the
|
||||
/// fail sound (`OptionalDispenseItemBehavior` without success), or 0 for
|
||||
/// nothing at all (`DispenseItemBehavior.NOOP`). To drop one normally,
|
||||
/// answer `(native_dispense_default(..), 1000)`. API @API:dispenser_behaviour@.
|
||||
pub(super) fn native_dispense_item(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
facing: horizon_core::geometry::Direction,
|
||||
stack: &ItemStack,
|
||||
answer: &mut Option<super::dispenser::NativeDispensed>,
|
||||
) {
|
||||
let _ = (dimension, pos, facing, stack, answer);
|
||||
}
|
||||
/// A player uses `stack` (right click) with `self.hand`: in the air, or
|
||||
/// on a block once `native_use_item_on` let it through, where `Item.use`
|
||||
/// starts. Each click reaches it once. Set `claimed` to take the use: the
|
||||
@@ -1348,6 +1830,29 @@ impl Game<'_> {
|
||||
pub(super) fn native_mob_killed(&mut self, player: u128, entity: u32, kind: &str) {
|
||||
let _ = (player, entity, kind);
|
||||
}
|
||||
/// A block broke or a mob died, and `drops` are the items about to drop, in
|
||||
/// the order they drop: what the loot tables rolled (after
|
||||
/// `native_loot_table_load`'s changes), and for a mob what it wore, held or
|
||||
/// carried (a fox's mouth item, an allay's, piglin's or horse's inventory),
|
||||
/// but not an enderman's carried block or a copper golem's preserved
|
||||
/// equipment, which drop after. A charged creeper's head and a fox's mouth
|
||||
/// item come before the mob's own table. A block's stacks get its block
|
||||
/// 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
|
||||
/// 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.
|
||||
/// Server-side, for every player. API
|
||||
/// @API:loot_tables@.
|
||||
pub(super) fn native_loot_drops(
|
||||
&mut self,
|
||||
context: &NativeLootDrops<'_>,
|
||||
drops: &mut Vec<ItemStack>,
|
||||
) {
|
||||
let _ = (context, drops);
|
||||
}
|
||||
/// `player` threw `count` of `item` (`Player.drop`: the drop key, a
|
||||
/// menu's throw...), not what scatters when they die. It only hears.
|
||||
/// Server-side. API 40.
|
||||
@@ -1476,6 +1981,76 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (entity, kind, damage_type, causing, direct, amount, canceled);
|
||||
}
|
||||
|
||||
/// `Block.stepOn` for a mod block, on its authoritative server level.
|
||||
/// No self: the simulation owns the entity during this callback.
|
||||
pub(super) fn native_block_step_on(
|
||||
dimension: &str,
|
||||
entity: i32,
|
||||
kind: &str,
|
||||
state: BlockStateId,
|
||||
position: [i32; 3],
|
||||
) {
|
||||
let _ = (dimension, entity, kind, state, position);
|
||||
}
|
||||
/// `Block.entityInside` for each mod block touched along the entity's movement.
|
||||
/// Repeated sweep hits in one contact pass are suppressed; idle overlap still fires.
|
||||
pub(super) fn native_block_entity_inside(
|
||||
dimension: &str,
|
||||
entity: i32,
|
||||
kind: &str,
|
||||
state: BlockStateId,
|
||||
position: [i32; 3],
|
||||
) {
|
||||
let _ = (dimension, entity, kind, state, position);
|
||||
}
|
||||
/// `Block.fallOn` for a mod block. Change calculated damage before generic fall sounds
|
||||
/// and hurt handling; zero prevents both. Creative and fall-immune entities
|
||||
/// notify with zero damage and retain their immunity. Server-side only.
|
||||
pub(super) fn native_block_fall_on(
|
||||
dimension: &str,
|
||||
entity: i32,
|
||||
kind: &str,
|
||||
state: BlockStateId,
|
||||
position: [i32; 3],
|
||||
distance: f64,
|
||||
damage: &mut f32,
|
||||
) {
|
||||
let _ = (dimension, entity, kind, state, position, distance, damage);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn block_contact(
|
||||
dimension: &str,
|
||||
event: horizon_client::block_contact::Event<'_>,
|
||||
damage: &mut f32,
|
||||
) {
|
||||
use horizon_client::block_contact::Event;
|
||||
match event {
|
||||
Event::StepOn(c) => Game::native_block_step_on(
|
||||
dimension,
|
||||
c.entity,
|
||||
c.kind,
|
||||
c.state,
|
||||
[c.position.x, c.position.y, c.position.z],
|
||||
),
|
||||
Event::Inside(c) => Game::native_block_entity_inside(
|
||||
dimension,
|
||||
c.entity,
|
||||
c.kind,
|
||||
c.state,
|
||||
[c.position.x, c.position.y, c.position.z],
|
||||
),
|
||||
Event::FallOn(c, distance) => Game::native_block_fall_on(
|
||||
dimension,
|
||||
c.entity,
|
||||
c.kind,
|
||||
c.state,
|
||||
[c.position.x, c.position.y, c.position.z],
|
||||
distance,
|
||||
damage,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `native_living_incoming_damage` for the damage code (`health::HURT_SERVER`): whether
|
||||
@@ -1554,7 +2129,7 @@ impl GameLevel<'_> {
|
||||
/// mod's, `mymod:machine_hum`) is sent by name, as vanilla sends a sound
|
||||
/// that isn't in its registry (`Holder.direct`, a variable range): players
|
||||
/// hear it if their resources define it (`assets/mymod/sounds.json`, a
|
||||
/// content mod's). API @API:mod_sounds@.
|
||||
/// content mod's). API 61.
|
||||
pub(super) fn native_play_sound(
|
||||
&mut self,
|
||||
sound: &str,
|
||||
@@ -1712,6 +2287,31 @@ impl GameLevel<'_> {
|
||||
let time = self.game_time();
|
||||
ticks.schedule_fluid(time, at, kind, delay) != ScheduleResult::MissingChunk
|
||||
}
|
||||
/// The state `state` with its property `key` set to `value`, as a mod block
|
||||
/// builds the state it is placed in: `native_state_with_property(cable,
|
||||
/// "facing", "east")` (`BlockState.setValue`). A block's own declared
|
||||
/// properties (`state_properties` in its block file) are set the same way.
|
||||
/// `state` itself when the property already has that value; None when the
|
||||
/// block has no such property, or the property no such value. API
|
||||
/// @API:placement_state@.
|
||||
pub(super) fn native_state_with_property(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Option<BlockStateId> {
|
||||
with_property(self.registry, state, key, value)
|
||||
}
|
||||
/// This level's own random (vanilla's `Level.random`): the stream
|
||||
/// `native_level_random(dimension)` hands a server hook, for a world hook
|
||||
/// that draws as a vanilla block does (`isBonemealSuccess`), so the mod's
|
||||
/// draws shift vanilla's as a Java mod's would. `None` where this level
|
||||
/// isn't the server's copy. API @API:bonemeal@.
|
||||
pub(super) fn native_level_random(
|
||||
&mut self,
|
||||
) -> Option<&mut horizon_core::random::LegacyRandom> {
|
||||
self.native_authoritative.then_some(&mut self.world.random)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers for native mods, kept as stable as the hook points.
|
||||
@@ -1920,6 +2520,26 @@ impl Game<'_> {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, id, flags))
|
||||
}
|
||||
/// The block at `pos` changed its comparator output
|
||||
/// (`native_analog_output`) without changing its state: the comparators
|
||||
/// beside it, or behind a conductor there, read it again
|
||||
/// (`Level.updateNeighbourForOutputSignal`). Acts on the level in place:
|
||||
/// false (and nothing happens) when `dimension` is another, or this
|
||||
/// window isn't a server. API @API:analog_output@.
|
||||
pub(super) fn native_update_output_neighbors(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
) -> bool {
|
||||
if (self.serving.is_none() && self.options.dedicated.is_none())
|
||||
|| dimension != self.dim.id()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
|
||||
true
|
||||
}
|
||||
/// The block at `pos` changed the signal it gives: its neighbours, and
|
||||
/// theirs (a block it powers passes the signal on), read it again, as
|
||||
/// around a lever (`Level.updateNeighborsAt`). Call it when the answer
|
||||
@@ -2354,6 +2974,34 @@ impl Game<'_> {
|
||||
pub(super) fn native_acting_id(&mut self) -> u128 {
|
||||
horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid())
|
||||
}
|
||||
/// The mods' say on a broken block's drops (`native_loot_drops`).
|
||||
pub(super) fn native_block_drops(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
player: Option<u128>,
|
||||
tool: Option<&ItemStack>,
|
||||
explosion: Option<f32>,
|
||||
drops: &mut Vec<ItemStack>,
|
||||
) {
|
||||
let items = match &self.world_tables {
|
||||
Some(tables) => &tables.items,
|
||||
None => self.res.items(),
|
||||
};
|
||||
let table = items.block_loot_table(self.pack.assets.registry, state);
|
||||
let context = NativeLootDrops {
|
||||
table: table.as_deref().unwrap_or_default(),
|
||||
dimension: self.dim.id(),
|
||||
origin: pos.map(|c| f64::from(c) + 0.5),
|
||||
block: Some((pos, state)),
|
||||
entity: None,
|
||||
player,
|
||||
tool,
|
||||
explosion,
|
||||
};
|
||||
self.native_loot_drops(&context, drops);
|
||||
drops.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
|
||||
}
|
||||
/// Whether a native mod keeps the acting player from taking `stack`.
|
||||
pub(super) fn native_pickup_refused(&mut self, stack: &ItemStack) -> bool {
|
||||
let player = self.native_acting_id();
|
||||
@@ -2768,6 +3416,12 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
/// How many of those slots `native_screen_slots` lists, without building
|
||||
/// it (`Game::slot_count` asks every frame).
|
||||
pub(super) fn native_screen_slot_count(&self) -> Option<usize> {
|
||||
let s = self.shown_native_screen()?;
|
||||
Some(s.screen.slots.iter().filter(|p| p.is_some()).count())
|
||||
}
|
||||
/// The first row of the player's inventory on the open mod screen.
|
||||
pub(super) fn native_screen_inventory_y(&self) -> Option<f32> {
|
||||
self.shown_native_screen().map(|s| s.screen.inventory_y)
|
||||
@@ -3244,6 +3898,37 @@ mod tests {
|
||||
assert_eq!(super::property_change(®istry, age0, "age", "9"), None);
|
||||
assert_eq!(super::property_change(®istry, age0, "lit", "true"), None);
|
||||
}
|
||||
|
||||
/// The state a mod block is placed in is built from its own default state
|
||||
/// and one of its properties, its declared ones (`state_properties` in its
|
||||
/// block file) included, and keeps the properties it already had.
|
||||
#[test]
|
||||
fn a_placement_state_is_built_from_a_blocks_own_properties() {
|
||||
let facts = br#"{"defaults":{"minecraft:air":0,"test:pipe":1},"states":[
|
||||
["air",true,false,"","minecraft:air"],
|
||||
["north_gas",false,false,"",{"id":"test:pipe","properties":{"carries":"gas","facing":"north"}}],
|
||||
["east_gas",false,false,"",{"id":"test:pipe","properties":{"carries":"gas","facing":"east"}}],
|
||||
["north_items",false,false,"",{"id":"test:pipe","properties":{"carries":"items","facing":"north"}}],
|
||||
["east_items",false,false,"",{"id":"test:pipe","properties":{"carries":"items","facing":"east"}}]
|
||||
]}"#;
|
||||
let registry = horizon_world::registry::BlockRegistry::from_facts_json(&facts[..]).unwrap();
|
||||
use horizon_world::registry::BlockStateId;
|
||||
let (north_gas, east_gas, east_items) = (BlockStateId(1), BlockStateId(2), BlockStateId(4));
|
||||
// The declared property changes, and `carries` stays as it was.
|
||||
assert_eq!(
|
||||
super::with_property(®istry, north_gas, "facing", "east"),
|
||||
Some(east_gas)
|
||||
);
|
||||
assert_eq!(
|
||||
super::with_property(®istry, east_gas, "carries", "items"),
|
||||
Some(east_items)
|
||||
);
|
||||
// A value the property does not have builds nothing.
|
||||
assert_eq!(
|
||||
super::with_property(®istry, east_gas, "facing", "up"),
|
||||
None
|
||||
);
|
||||
}
|
||||
/// Both property setters return false for a write that changes nothing:
|
||||
/// each goes through `property_change`, none returns true on its own.
|
||||
#[test]
|
||||
@@ -3293,6 +3978,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_table_load_stays_hookable() {
|
||||
let source = include_str!("../../../horizon-world/src/loot.rs");
|
||||
// From its doc comment's last line, so an attribute above it counts.
|
||||
let hook = source.find("pub fn native_loot_table_load(").unwrap();
|
||||
let start = source[..hook].rfind("///").unwrap();
|
||||
let body = &source[start..start + source[start..].find("\n}\n").unwrap()];
|
||||
for line in body.lines() {
|
||||
assert!(
|
||||
!line.contains("cfg"),
|
||||
"a cfg in the loot hook point: {line}"
|
||||
);
|
||||
for mac in ["!(", "![", "! {", "!{"] {
|
||||
assert!(
|
||||
!line.contains(mac),
|
||||
"a macro in the loot hook point: {line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_source_stays_hookable() {
|
||||
let source = include_str!("native_lifecycle.rs");
|
||||
@@ -3351,12 +4057,21 @@ mod tests {
|
||||
.next()
|
||||
})
|
||||
.collect();
|
||||
// The renderer's hook points (`horizon_client_render::gfx::native_render`).
|
||||
let render_hooks: BTreeSet<_> =
|
||||
include_str!("../../../horizon-client-render/src/gfx/native_render.rs")
|
||||
// Hook points that are free functions in other crates.
|
||||
let free_hooks = |source: &'static str| -> BTreeSet<&'static str> {
|
||||
source
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("pub fn native_")?.split('(').next())
|
||||
.collect();
|
||||
.collect()
|
||||
};
|
||||
// The renderer's (`horizon_client_render::gfx::native_render`).
|
||||
let render_hooks = free_hooks(include_str!(
|
||||
"../../../horizon-client-render/src/gfx/native_render.rs"
|
||||
));
|
||||
// Items' default components (`horizon_world::item_defaults`).
|
||||
let item_hooks = free_hooks(include_str!("../../../horizon-world/src/item_defaults.rs"));
|
||||
// The loot tables' hook point (`horizon_world::loot`).
|
||||
let loot_hooks = free_hooks(include_str!("../../../horizon-world/src/loot.rs"));
|
||||
let declared: BTreeSet<_> = section
|
||||
.lines()
|
||||
.chain(level_section.lines())
|
||||
@@ -3367,6 +4082,8 @@ mod tests {
|
||||
.next()
|
||||
})
|
||||
.chain(render_hooks.iter().copied())
|
||||
.chain(item_hooks.iter().copied())
|
||||
.chain(loot_hooks.iter().copied())
|
||||
.collect();
|
||||
let mut names = BTreeSet::new();
|
||||
let mut contracts = BTreeSet::new();
|
||||
@@ -3380,6 +4097,10 @@ mod tests {
|
||||
names.insert(name);
|
||||
let target = if render_hooks.contains(name) {
|
||||
format!("horizon_client_render::gfx::native_render::native_{name}")
|
||||
} else if item_hooks.contains(name) {
|
||||
format!("horizon_world::item_defaults::native_{name}")
|
||||
} else if loot_hooks.contains(name) {
|
||||
format!("horizon_world::loot::native_{name}")
|
||||
} else if level_hooks.contains(name) {
|
||||
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
|
||||
} else {
|
||||
|
||||
@@ -129,9 +129,14 @@ impl Game<'_> {
|
||||
items: Vec<Option<ItemStack>>,
|
||||
) -> Option<i32> {
|
||||
let conn = self.native_conn(uuid)?;
|
||||
let host = self.lan.as_mut()?;
|
||||
host.hub
|
||||
.open_native_menu(conn, rows, component_nbt(&title.component()), items)
|
||||
let mut host = self.lan.take()?;
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(&mut host, conn);
|
||||
let window =
|
||||
host.hub
|
||||
.open_native_menu(conn, rows, component_nbt(&title.component()), items);
|
||||
self.lan = Some(host);
|
||||
window
|
||||
}
|
||||
/// Replaces what the player's open native menu shows; sent with the
|
||||
/// tick's container sync.
|
||||
|
||||
@@ -403,6 +403,37 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Shows `nickname` in place of that player's name in chat (their
|
||||
/// messages, `/me`, `/msg`) and in the Tab list; None shows their name
|
||||
/// again. Hovering the shown name gives the real one, which stays in the
|
||||
/// log, commands, selectors and the server's events. Kept until a mod
|
||||
/// sets it again or the server stops (also if they leave and come back).
|
||||
/// Plain text: the mod checks it (`native_text_allowed`, its length,
|
||||
/// other players' names). Only where no player's state is swapped in,
|
||||
/// as `native_run_command`; false there and when they aren't on. API
|
||||
/// 62.
|
||||
pub(super) fn native_set_nickname(&mut self, uuid: u128, nickname: Option<&str>) -> bool {
|
||||
let Some(host) = &mut self.lan else {
|
||||
return false;
|
||||
};
|
||||
if !host.hub.guests.iter().any(|g| g.placed && g.uuid == uuid) {
|
||||
return false;
|
||||
}
|
||||
host.hub.set_nickname(uuid, nickname.map(str::to_owned));
|
||||
true
|
||||
}
|
||||
/// Whether the server's chat filter (`server.toml` `[chat_filter]`)
|
||||
/// lets `text` through: true without a filter, None inside a player's
|
||||
/// hook (where the server is out of reach). API 62.
|
||||
pub(super) fn native_text_allowed(&self, text: &str) -> Option<bool> {
|
||||
let host = self.lan.as_ref()?;
|
||||
Some(
|
||||
host.hub
|
||||
.chat_filter
|
||||
.as_ref()
|
||||
.is_none_or(|g| !g.filter.matches(text)),
|
||||
)
|
||||
}
|
||||
/// A system line in every player's chat and the server's log.
|
||||
pub(super) fn native_broadcast(&self, line: Text) {
|
||||
let plain = line.plain();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Horizon's own shader packs in the game (docs/shaders.md): the pick in `shaderpacks/` (a
|
||||
//! folder with `horizon-shaders.toml`, or a `.hshader`; `game/shader_packs.rs`) runs once it
|
||||
//! passes `horizon_shader_api::check`. The pack is looked at twice a second: a saved file is checked and the pack rebuilt, and a
|
||||
//! passes `horizon_shader_api::check`. The pack is looked at twice a second: a saved file (or
|
||||
//! saved option values) is checked and the pack rebuilt, and a
|
||||
//! pack that fails is refused with its problems in the chat while the last good one keeps
|
||||
//! drawing.
|
||||
|
||||
@@ -57,12 +58,10 @@ impl Game<'_> {
|
||||
let result = Pack::read(path).map_err(|e| vec![e]).and_then(|pack| {
|
||||
let report = check(&pack)
|
||||
.map_err(|p| p.iter().map(ToString::to_string).collect::<Vec<String>>())?;
|
||||
let constants = pack
|
||||
.manifest
|
||||
.options
|
||||
.iter()
|
||||
.map(|o| (o.id.clone(), o.kind.default_constant()))
|
||||
.collect();
|
||||
// The player's values (the pack's Shader Options page), else the defaults.
|
||||
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
|
||||
@@ -97,7 +96,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A hash of the pack's file names, sizes and modification times (`None` when unreadable).
|
||||
/// A hash of the names, sizes and modification times of the pack's files and of its saved
|
||||
/// option values (`None` when the pack is unreadable).
|
||||
fn fingerprint(path: &Path) -> Option<u64> {
|
||||
fn walk(path: &Path, hasher: &mut std::hash::DefaultHasher) -> Option<()> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
@@ -120,5 +120,8 @@ fn fingerprint(path: &Path) -> Option<u64> {
|
||||
}
|
||||
let mut hasher = std::hash::DefaultHasher::new();
|
||||
walk(path, &mut hasher)?;
|
||||
// The player's option values, when there are any.
|
||||
let values = horizon_client::shader_pack_list::values_path(path);
|
||||
let _ = walk(&values, &mut hasher);
|
||||
Some(hasher.finish())
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
//! Read-only block queries for a native server tick hook.
|
||||
//! Read-only block and tag queries for native server hooks.
|
||||
use super::{Dim, Game, native_lifecycle::NativeLifecycleContext};
|
||||
use horizon_client::{items::ItemStack, world::World};
|
||||
use horizon_client::{
|
||||
items::{ItemStack, Items},
|
||||
world::World,
|
||||
};
|
||||
use horizon_world::{chunk::BlockState, registry::BlockRegistry};
|
||||
|
||||
/// Why a server block cannot be read.
|
||||
/// Why a server block or tag cannot be read.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum NativeBlockReadError {
|
||||
/// The context is stale or the call is outside its server tick hook.
|
||||
InactiveContext,
|
||||
/// Tag queries require an authoritative server.
|
||||
NotServer,
|
||||
/// The world has not loaded its tag tables yet.
|
||||
TagsUnavailable,
|
||||
/// The dimension is unknown or is not currently open on the server.
|
||||
UnavailableDimension,
|
||||
OutOfBounds,
|
||||
@@ -17,7 +24,18 @@ pub(super) enum NativeBlockReadError {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Test a block state's name against the world's merged block tags. API 28.
|
||||
/// The world lock is installed after its tag tables are built.
|
||||
fn native_tag_items(&self) -> Result<&Items, NativeBlockReadError> {
|
||||
if self.options.execution_role.is_client() {
|
||||
return Err(NativeBlockReadError::NotServer);
|
||||
}
|
||||
if self.session_lock.is_none() {
|
||||
return Err(NativeBlockReadError::TagsUnavailable);
|
||||
}
|
||||
Ok(self.items())
|
||||
}
|
||||
|
||||
/// Test a block state's name in an active server tick hook. API 28.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_block_in_tag(
|
||||
&self,
|
||||
@@ -28,10 +46,10 @@ impl Game<'_> {
|
||||
if !self.native_lifecycle.permits_read(context) {
|
||||
return Err(NativeBlockReadError::InactiveContext);
|
||||
}
|
||||
Ok(self.items().block_in_tag(tag, &state.name))
|
||||
self.native_server_block_in_tag(state, tag)
|
||||
}
|
||||
|
||||
/// Test an item stack against the world's merged item tags. API 28.
|
||||
/// Test an item stack in an active server tick hook. API 28.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_item_in_tag(
|
||||
&self,
|
||||
@@ -42,12 +60,33 @@ impl Game<'_> {
|
||||
if !self.native_lifecycle.permits_read(context) {
|
||||
return Err(NativeBlockReadError::InactiveContext);
|
||||
}
|
||||
self.native_server_item_in_tag(stack, tag)
|
||||
}
|
||||
|
||||
/// Test a block state's name against the server's merged block tags.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_server_block_in_tag(
|
||||
&self,
|
||||
state: &BlockState,
|
||||
tag: &str,
|
||||
) -> Result<bool, NativeBlockReadError> {
|
||||
Ok(self.native_tag_items()?.block_in_tag(tag, &state.name))
|
||||
}
|
||||
|
||||
/// Test an item stack against the server's merged item tags.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_server_item_in_tag(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
tag: &str,
|
||||
) -> Result<bool, NativeBlockReadError> {
|
||||
let items = self.native_tag_items()?;
|
||||
let item = if stack.count == 0 {
|
||||
"minecraft:air"
|
||||
} else {
|
||||
&stack.item
|
||||
};
|
||||
Ok(self.items().item_in_tag(tag, item))
|
||||
Ok(items.item_in_tag(tag, item))
|
||||
}
|
||||
|
||||
/// Read a loaded block in a server tick hook, without loading a chunk.
|
||||
|
||||
@@ -614,6 +614,10 @@ impl<'a> Game<'a> {
|
||||
let Some(terrain) = self.end.locator() else {
|
||||
return;
|
||||
};
|
||||
// `FlatLevelSource.spawnOriginalMobs` is empty: a superflat world places no packs.
|
||||
if terrain.is_flat() {
|
||||
return;
|
||||
}
|
||||
let mut region = terrain.region_random(cx, cz);
|
||||
let any = region.as_any();
|
||||
let added = if let Some(r) = any.downcast_mut::<LegacyRandom>() {
|
||||
|
||||
Loaded 100 of 300 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user