mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d139f9bee6 | ||
|
|
03d93d79b2 | ||
|
|
f8e4e31b12 | ||
|
|
67e07f3faa | ||
|
|
2bbbc70620 | ||
|
|
9c134f54ac | ||
|
|
a00dc868ec | ||
|
|
08306d1586 | ||
|
|
3b70dc9c16 | ||
|
|
99b058442c | ||
|
|
8cce2e2f8a | ||
|
|
582856aac2 | ||
|
|
0a95a78e03 | ||
|
|
5ec40c8004 | ||
|
|
82bba24f43 | ||
|
|
7b0316e6a4 | ||
|
|
a29e10ef9b | ||
|
|
92050712fa | ||
|
|
122e3bf287 | ||
|
|
65031a7739 | ||
|
|
59f018eabe | ||
|
|
ca0637da80 | ||
|
|
47459b0b19 | ||
|
|
9b16d37e80 | ||
|
|
03be14bb59 | ||
|
|
45e5b64520 | ||
|
|
d65be4183f | ||
|
|
e307f03684 | ||
|
|
26a91e3a08 | ||
|
|
8c81cc63f7 | ||
|
|
221545e92b | ||
|
|
88b27070bd | ||
|
|
499f8bddfc | ||
|
|
5904922865 | ||
|
|
49e706e8be | ||
|
|
5e63b0512d | ||
|
|
22b2711cf9 | ||
|
|
ea424ebd52 | ||
|
|
a4a101ca9c | ||
|
|
c6ed3112e7 | ||
|
|
f96101cd47 | ||
|
|
fa4ea80d32 | ||
|
|
0bbe50c6e8 | ||
|
|
c924894624 | ||
|
|
287d7a7eb5 | ||
|
|
c86996c234 | ||
|
|
4daa91cf52 | ||
|
|
5595db9077 | ||
|
|
fe70ff90b4 | ||
|
|
4a442f7250 | ||
|
|
f47f6ee472 | ||
|
|
85c6103d45 | ||
|
|
60611ed5a3 | ||
|
|
4fa5966338 | ||
|
|
eb79cfdb01 | ||
|
|
717f54bf28 | ||
|
|
ad76f911bd | ||
|
|
5ed8181adc | ||
|
|
5617d702cc | ||
|
|
52d8ed6721 | ||
|
|
de358cd721 | ||
|
|
9acc68f09f | ||
|
|
a8c21c27df | ||
|
|
0f3a92ac2b | ||
|
|
a79077b30e | ||
|
|
ac811d4708 | ||
|
|
5858cb113e | ||
|
|
fe6ae937ef | ||
|
|
c344a26e80 | ||
|
|
6a58fc20da |
No files matched your search
@@ -87,3 +87,7 @@ rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
@@ -425,6 +425,29 @@ fn split_id(id: &str) -> (String, String) {
|
||||
None => ("minecraft".to_owned(), id.to_owned()),
|
||||
}
|
||||
}
|
||||
/// The slot under the cursor, for the search tab's keys.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Hover {
|
||||
Nothing,
|
||||
/// A display slot (`isCreativeSlot`), showing an item or past the end.
|
||||
Display {
|
||||
filled: bool,
|
||||
},
|
||||
/// Any other slot: the hotbar row, or the inventory tab's slots.
|
||||
Slot,
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: whether a
|
||||
/// key is a hotbar swap on the hovered slot instead of text for the search
|
||||
/// box. `numeric` is `getNumericKeyValue().isPresent()` (the digit and keypad
|
||||
/// keys); `hotbar_key` is a `keyHotbarSlots` key. Over an empty display slot
|
||||
/// the key types (`doQuickSwap` is false), and `checkHotbarKeyPressed` needs
|
||||
/// a hovered slot and an empty cursor.
|
||||
pub fn search_key_swaps(numeric: bool, hotbar_key: bool, hover: Hover, carrying: bool) -> bool {
|
||||
let quick_swap = hover != Hover::Display { filled: false };
|
||||
quick_swap && numeric && hotbar_key && !carrying && hover != Hover::Nothing
|
||||
}
|
||||
|
||||
/// What a click on an item tab's display slot does besides changing the
|
||||
/// carried stack.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
@@ -529,6 +552,22 @@ mod tests {
|
||||
(carried, effect)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tab_number_keys_swap_over_a_slot_before_typing() {
|
||||
let filled = Hover::Display { filled: true };
|
||||
let empty = Hover::Display { filled: false };
|
||||
// Over an item, the hotbar row or another slot: the hotbar swap.
|
||||
assert!(search_key_swaps(true, true, filled, false));
|
||||
assert!(search_key_swaps(true, true, Hover::Slot, false));
|
||||
// Over an empty display slot, nothing or with a carried stack: text.
|
||||
assert!(!search_key_swaps(true, true, empty, false));
|
||||
assert!(!search_key_swaps(true, true, Hover::Nothing, false));
|
||||
assert!(!search_key_swaps(true, true, filled, true));
|
||||
// Letters always type; so do the digits no hotbar key is bound to.
|
||||
assert!(!search_key_swaps(false, false, filled, false));
|
||||
assert!(!search_key_swaps(true, false, filled, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_slot_clicks_follow_slot_clicked() {
|
||||
use crate::menu::Input::*;
|
||||
|
||||
@@ -102,7 +102,8 @@ enum Provider {
|
||||
/// One font (`FontSet`): its providers and the glyphs resolved so far.
|
||||
struct FontSet {
|
||||
providers: Vec<Provider>,
|
||||
glyphs: HashMap<char, Glyph>,
|
||||
/// Every char drawn looks up here, so the hasher is Fx, not SipHash.
|
||||
glyphs: crate::fast_hash::FxHashMap<char, Glyph>,
|
||||
/// `glyphsByWidth` for obfuscated text (built on first use).
|
||||
by_width: Option<HashMap<u32, Vec<char>>>,
|
||||
}
|
||||
@@ -647,7 +648,7 @@ impl Font {
|
||||
.collect();
|
||||
FontSet {
|
||||
providers,
|
||||
glyphs: HashMap::new(),
|
||||
glyphs: Default::default(),
|
||||
by_width: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,11 +285,9 @@ struct Options {
|
||||
benchmark: Option<String>,
|
||||
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
|
||||
dev_mods: Vec<PathBuf>,
|
||||
/// `--server-thread` or `HORIZON_SERVER_THREAD=1`: single player runs on an
|
||||
/// integrated server thread (game/integrated.rs), as vanilla's does. Off by
|
||||
/// default again until a server's players reach parity with the in-frame
|
||||
/// world (signs, books, totems, keep_inventory...); `--no-server-thread`
|
||||
/// keeps it off.
|
||||
/// Single player runs on an integrated server thread (game/integrated.rs),
|
||||
/// as vanilla's does: the default. `--in-frame` (or `--no-server-thread`,
|
||||
/// or `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead.
|
||||
server_thread: bool,
|
||||
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
|
||||
integrated: bool,
|
||||
@@ -347,7 +345,7 @@ impl Options {
|
||||
dev_mods: Vec::new(),
|
||||
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
|
||||
// itself, without the event loop: it stays in the frame.
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").is_ok_and(|v| v == "1")
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
|
||||
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
|
||||
integrated: false,
|
||||
};
|
||||
@@ -357,7 +355,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --server-thread Run single player's world on its server thread (not at parity yet)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -372,7 +370,7 @@ impl Options {
|
||||
"--offscreen" => o.offscreen = true,
|
||||
"--control" => o.control = Some(horizon_client::control::default_path()),
|
||||
"--server-thread" => o.server_thread = true,
|
||||
"--no-server-thread" => o.server_thread = false,
|
||||
"--in-frame" | "--no-server-thread" => o.server_thread = false,
|
||||
_ => {
|
||||
let value = args
|
||||
.next()
|
||||
@@ -1446,6 +1444,8 @@ struct Game<'a> {
|
||||
mount_menu: mount_menu::MountMenu,
|
||||
/// The player's fishing hook.
|
||||
hook: Option<fishing::Hook>,
|
||||
/// The other players' hooks a server tracks to this client.
|
||||
remote_hooks: Vec<fishing::RemoteHook>,
|
||||
tridents: Vec<trident::ThrownTrident>,
|
||||
rockets: Vec<fireworks::Rocket>,
|
||||
/// Ominous trial spawners' item spawners.
|
||||
@@ -1817,6 +1817,65 @@ impl Game<'_> {
|
||||
self.stack_in_mut(self.held_slot())
|
||||
}
|
||||
/// The slot of the hand that acts: the selected one, or `OFFHAND_SLOT`.
|
||||
/// `LivingEntity.canGlideUsing`: a glider in the chest slot that will
|
||||
/// not break with its next point of wear.
|
||||
fn glider_worn(&self) -> bool {
|
||||
self.armor[1].as_ref().is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
|
||||
})
|
||||
}
|
||||
/// What the window decides for its player's body every tick, in frame and
|
||||
/// on a server alike: the look (Player.travel steers sprint-swimming by
|
||||
/// the pitch), Auto Jump (LocalPlayer.sendPosition), the slowdown of an
|
||||
/// item in use (LocalPlayer.modifyInput), the helmet's Respiration
|
||||
/// (decreaseAirSupply), the wings, leather boots on powder snow
|
||||
/// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear
|
||||
/// (LivingEntity.canFreeze) and the dimension's FAST_LAVA.
|
||||
pub(crate) fn sync_player_body(&mut self) {
|
||||
let glider = self.glider_worn();
|
||||
let item_use_speed = self
|
||||
.using
|
||||
.and_then(|(slot, _)| {
|
||||
if slot == shield::OFFHAND_SLOT {
|
||||
self.offhand.as_ref()
|
||||
} else {
|
||||
self.inventory.get(slot)?.as_ref()
|
||||
}
|
||||
})
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
.map(|i| i.use_speed);
|
||||
let respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
let leather_boots = self.armor[3]
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
|
||||
let immune = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:freeze_immune_wearables");
|
||||
let freeze_immune = self
|
||||
.armor
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)));
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
player.yaw = self.yaw;
|
||||
player.pitch = self.pitch;
|
||||
player.auto_jump_enabled = self.settings.auto_jump;
|
||||
player.item_use_speed = item_use_speed;
|
||||
player.respiration = respiration;
|
||||
player.glider = glider;
|
||||
player.leather_boots = leather_boots;
|
||||
player.freeze_immune = freeze_immune;
|
||||
player.fluids.fast_lava = self.dim == Dim::Nether;
|
||||
}
|
||||
fn held_slot(&self) -> usize {
|
||||
match self.hand {
|
||||
Hand::Main => self.selected,
|
||||
@@ -1994,6 +2053,28 @@ impl Game<'_> {
|
||||
particle.scale(0.6);
|
||||
self.particles.push(particle);
|
||||
}
|
||||
/// `Player.dropEquipment` for the player who died: unless keep_inventory
|
||||
/// is on, `destroyVanishingCursedItems`, then `Inventory.dropAll` from
|
||||
/// `at` (the eyes less 0.3): slots 0..36, armor feet to head (36..39),
|
||||
/// the off hand (40), each thrown in a random direction.
|
||||
fn drop_inventory(&mut self, at: [f64; 3]) {
|
||||
if self.ui.rules.bool("keep_inventory") {
|
||||
return;
|
||||
}
|
||||
let mut stacks: Vec<ItemStack> =
|
||||
self.inventory.iter_mut().filter_map(Option::take).collect();
|
||||
stacks.extend(self.armor.iter_mut().rev().filter_map(Option::take));
|
||||
stacks.extend(self.offhand.take());
|
||||
stacks.retain(|s| s.enchantment_level("minecraft:vanishing_curse") == 0);
|
||||
self.apply_equipment();
|
||||
for stack in stacks {
|
||||
let r = &mut self.world.random;
|
||||
let strength = f64::from(r.next_float()) * 0.5;
|
||||
let angle = f64::from(r.next_float()) * std::f64::consts::TAU;
|
||||
let v = [-angle.sin() * strength, 0.2, angle.cos() * strength];
|
||||
self.spawn_item(stack, at, v, 40);
|
||||
}
|
||||
}
|
||||
fn spawn_item(
|
||||
&mut self,
|
||||
stack: ItemStack,
|
||||
@@ -3649,16 +3730,8 @@ impl Game<'_> {
|
||||
// Switching hotbar slots only ends a main-hand use.
|
||||
let hand_changed = slot != self.selected && slot != shield::OFFHAND_SLOT;
|
||||
if !self.right || hand_changed || self.screen != Screen::Playing {
|
||||
if &*stack.item == "minecraft:bow" && !hand_changed {
|
||||
self.release_bow(ticks);
|
||||
}
|
||||
if &*stack.item == "minecraft:spyglass" {
|
||||
self.stop_spyglass();
|
||||
}
|
||||
if &*stack.item == "minecraft:trident" && !hand_changed {
|
||||
self.release_trident(ticks);
|
||||
}
|
||||
self.using = None;
|
||||
self.release_used_item(&stack, ticks, hand_changed);
|
||||
return;
|
||||
}
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
@@ -3714,6 +3787,17 @@ impl Game<'_> {
|
||||
self.using = Some((slot, ticks));
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.releaseUsingItem` (`Item.releaseUsing`) for `stack`,
|
||||
/// used for `ticks`, its hand acting: a bow or a trident lets go (not
|
||||
/// when its hand changed), a spyglass stops scoping.
|
||||
pub(super) fn release_used_item(&mut self, stack: &ItemStack, ticks: u32, hand_changed: bool) {
|
||||
match &*stack.item {
|
||||
"minecraft:bow" if !hand_changed => self.release_bow(ticks),
|
||||
"minecraft:trident" if !hand_changed => self.release_trident(ticks),
|
||||
"minecraft:spyglass" => self.stop_spyglass(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn tick_mining(&mut self) -> Result<()> {
|
||||
if self.destroy_delay > 0 {
|
||||
self.destroy_delay -= 1;
|
||||
@@ -3803,7 +3887,12 @@ impl Game<'_> {
|
||||
}
|
||||
self.crack_particle(crack.0, crack.1, state);
|
||||
let m = self.mining.as_mut().ok_or("mining")?;
|
||||
if m.progress >= 1.0 {
|
||||
// ServerPlayerGameMode breaks a player's block at once only when it is instant (START's
|
||||
// insta-mine); otherwise it waits for the client's STOP_DESTROY_BLOCK
|
||||
// (`guest_stop_digging`), never breaking it on its own count. Breaking it here would
|
||||
// take the block from under the player's crack before the player's client predicts the
|
||||
// break, so it gets neither particles nor sound.
|
||||
if m.progress >= 1.0 && (self.lan_acting.is_none() || m.ticks == 1) {
|
||||
let pos = m.pos;
|
||||
self.mining = None;
|
||||
self.break_block(pos)?;
|
||||
@@ -3937,13 +4026,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
player.pitch = self.pitch;
|
||||
// LivingEntity.canGlideUsing: a glider in its slot that will not break next.
|
||||
player.glider = self.armor[1].as_ref().is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
|
||||
});
|
||||
player.glider = self.glider_worn();
|
||||
// A saved death already dropped inventory/XP and counted the death.
|
||||
if player.health.dead_or_dying() {
|
||||
self.screen = Screen::Dead;
|
||||
@@ -4021,6 +4104,7 @@ impl Game<'_> {
|
||||
let riding = self.riding.is_some();
|
||||
let ghast_collisions = self.happy_ghast_player_collisions();
|
||||
let spin_targets = self.spin_targets();
|
||||
self.sync_player_body();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
// A dedicated server has no local player: the world still runs.
|
||||
if self.options.dedicated.is_some() {
|
||||
@@ -4052,52 +4136,10 @@ impl Game<'_> {
|
||||
if !riding {
|
||||
self.previous_position = player.position;
|
||||
}
|
||||
player.yaw = self.yaw;
|
||||
let spectator = self.mode == horizon_client::command::args::GameMode::Spectator;
|
||||
player.may_fly = self.creative || spectator;
|
||||
// LocalPlayer.sendPosition: autoJumpEnabled follows the option.
|
||||
player.auto_jump_enabled = self.settings.auto_jump;
|
||||
player.invulnerable = self.creative || spectator;
|
||||
player.spectator = spectator;
|
||||
player.respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
player.pitch = self.pitch;
|
||||
// LivingEntity.canGlideUsing: a glider in its slot that will not break next.
|
||||
player.glider = self.armor[1].as_ref().is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
|
||||
});
|
||||
// LocalPlayer.modifyInput: walking while using an item.
|
||||
player.item_use_speed = self
|
||||
.using
|
||||
.and_then(|(slot, _)| {
|
||||
if slot == shield::OFFHAND_SLOT {
|
||||
self.offhand.as_ref()
|
||||
} else {
|
||||
self.inventory.get(slot)?.as_ref()
|
||||
}
|
||||
})
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
.map(|i| i.use_speed);
|
||||
// PowderSnowBlock.canEntityWalkOnPowderSnow and LivingEntity.canFreeze.
|
||||
player.leather_boots = self.armor[3]
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
|
||||
let immune = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:freeze_immune_wearables");
|
||||
player.freeze_immune = self
|
||||
.armor
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)));
|
||||
// The dimension's FAST_LAVA: the Nether's lava pushes harder.
|
||||
player.fluids.fast_lava = self.dim == Dim::Nether;
|
||||
player.spin_targets = spin_targets;
|
||||
let grounded = player.ground.on_ground;
|
||||
let before = (
|
||||
@@ -4306,20 +4348,8 @@ impl Game<'_> {
|
||||
.experience
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || self.spectator());
|
||||
self.spawn_orbs(feet, reward);
|
||||
if !self.creative && !self.ui.rules.bool("keep_inventory") {
|
||||
// Inventory.dropAll: slots 0..36, armor feet to head (36..39), off hand (40).
|
||||
let mut stacks: Vec<ItemStack> =
|
||||
self.inventory.iter_mut().filter_map(Option::take).collect();
|
||||
stacks.extend(self.armor.iter_mut().rev().filter_map(Option::take));
|
||||
stacks.extend(self.offhand.take());
|
||||
self.apply_equipment();
|
||||
for stack in stacks {
|
||||
let r = &mut self.world.random;
|
||||
let strength = f64::from(r.next_float()) * 0.5;
|
||||
let angle = f64::from(r.next_float()) * std::f64::consts::TAU;
|
||||
let v = [-angle.sin() * strength, 0.2, angle.cos() * strength];
|
||||
self.spawn_item(stack, at, v, 40);
|
||||
}
|
||||
if !self.spectator() {
|
||||
self.drop_inventory(at);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.player.death", Some(at), 1.0, 1.0);
|
||||
@@ -5992,6 +6022,37 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: a hotbar
|
||||
/// key over a slot swaps before the search box sees it.
|
||||
fn search_tab_hotbar_key(&mut self, code: KeyCode) -> bool {
|
||||
use horizon_client::creative::{Hover, search_key_swaps};
|
||||
let hotbar_key = HOTBAR_DIGITS.contains(&code);
|
||||
let numeric = hotbar_key || NUMERIC_KEYS.contains(&code);
|
||||
if !numeric {
|
||||
return false;
|
||||
}
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let hover = match self.slot_at(&batch, self.cursor.0, self.cursor.1) {
|
||||
None => Hover::Nothing,
|
||||
Some(slot) if slot >= Creative::DISPLAY => {
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
let shown = self.creative_screen.items(&self.pack.names).len();
|
||||
Hover::Display {
|
||||
filled: first * 9 + slot - Creative::DISPLAY < shown,
|
||||
}
|
||||
}
|
||||
Some(_) => Hover::Slot,
|
||||
};
|
||||
search_key_swaps(numeric, hotbar_key, hover, self.carried.is_some())
|
||||
&& self.inventory_hotkey(code)
|
||||
}
|
||||
/// Number keys, F and Q over a slot of the open menu: SWAP with a hotbar
|
||||
/// slot or the off hand, THROW one or the whole stack.
|
||||
fn inventory_hotkey(&mut self, key: KeyCode) -> bool {
|
||||
@@ -6007,19 +6068,7 @@ impl Game<'_> {
|
||||
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
|
||||
return false;
|
||||
};
|
||||
let digit = [
|
||||
KeyCode::Digit1,
|
||||
KeyCode::Digit2,
|
||||
KeyCode::Digit3,
|
||||
KeyCode::Digit4,
|
||||
KeyCode::Digit5,
|
||||
KeyCode::Digit6,
|
||||
KeyCode::Digit7,
|
||||
KeyCode::Digit8,
|
||||
KeyCode::Digit9,
|
||||
]
|
||||
.iter()
|
||||
.position(|k| *k == key);
|
||||
let digit = HOTBAR_DIGITS.iter().position(|k| *k == key);
|
||||
let done = match (digit, key) {
|
||||
(Some(n), _) => self.menu_click(Some(slot), n as i32, Input::Swap),
|
||||
(None, KeyCode::KeyF) => self.menu_click(Some(slot), 40, Input::Swap),
|
||||
@@ -10330,6 +10379,11 @@ impl Game<'_> {
|
||||
if (self.screen == Screen::Inventory
|
||||
|| (self.screen == Screen::Creative && !self.creative_screen.tab().search))
|
||||
&& self.inventory_hotkey(key) => {}
|
||||
// The search tab's number keys (scripted presses; the window's
|
||||
// keys reach search_tab_hotbar_key before the search box).
|
||||
_ if self.screen == Screen::Creative
|
||||
&& self.creative_screen.tab().search
|
||||
&& self.search_tab_hotbar_key(key) => {}
|
||||
KeyCode::KeyT if self.screen == Screen::Inventory => self.recipe_focus_search(),
|
||||
KeyCode::KeyT if self.screen == Screen::Playing => self.open_chat(""),
|
||||
// key.advancements.
|
||||
@@ -11201,7 +11255,11 @@ impl ApplicationHandler for Game<'_> {
|
||||
&& event.state == ElementState::Pressed
|
||||
&& !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) =>
|
||||
{
|
||||
if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
if let PhysicalKey::Code(key) = event.physical_key
|
||||
&& self.search_tab_hotbar_key(key)
|
||||
{
|
||||
// The hovered slot took the key (`ignoreTextInput`).
|
||||
} else if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
self.creative_search_shortcut(shortcut);
|
||||
} else {
|
||||
let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace);
|
||||
@@ -11862,6 +11920,7 @@ impl<'a> Game<'a> {
|
||||
recipe_ui: Default::default(),
|
||||
mount_menu: Default::default(),
|
||||
hook: None,
|
||||
remote_hooks: Vec::new(),
|
||||
tridents: Vec::new(),
|
||||
rockets: Vec::new(),
|
||||
ominous_spawners: Vec::new(),
|
||||
@@ -12139,6 +12198,33 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `key.hotbar.1` to `key.hotbar.9` in a menu.
|
||||
const HOTBAR_DIGITS: [KeyCode; 9] = [
|
||||
KeyCode::Digit1,
|
||||
KeyCode::Digit2,
|
||||
KeyCode::Digit3,
|
||||
KeyCode::Digit4,
|
||||
KeyCode::Digit5,
|
||||
KeyCode::Digit6,
|
||||
KeyCode::Digit7,
|
||||
KeyCode::Digit8,
|
||||
KeyCode::Digit9,
|
||||
];
|
||||
/// The other keys `InputConstants.Key.getNumericKeyValue` gives a digit.
|
||||
const NUMERIC_KEYS: [KeyCode; 11] = [
|
||||
KeyCode::Digit0,
|
||||
KeyCode::Numpad0,
|
||||
KeyCode::Numpad1,
|
||||
KeyCode::Numpad2,
|
||||
KeyCode::Numpad3,
|
||||
KeyCode::Numpad4,
|
||||
KeyCode::Numpad5,
|
||||
KeyCode::Numpad6,
|
||||
KeyCode::Numpad7,
|
||||
KeyCode::Numpad8,
|
||||
KeyCode::Numpad9,
|
||||
];
|
||||
|
||||
const STAGES: u32 = 10;
|
||||
const STAGE_LINES: [&str; 10] = [
|
||||
"Loading menu",
|
||||
|
||||
@@ -335,6 +335,12 @@ impl Game<'_> {
|
||||
Some(Ticker::Shulker { .. }) => v["lid"] = json!(self.block_anim(pos, 1.0).shulker.0),
|
||||
_ => {}
|
||||
}
|
||||
// The lines of a sign, as this client has them.
|
||||
if let Some(horizon_client::containers::BlockEntity::Sign(sign)) =
|
||||
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
v["sign"] = json!({ "front": sign.front.lines, "back": sign.back.lines });
|
||||
}
|
||||
v
|
||||
}
|
||||
pub(super) fn stack_value(&self, s: &ItemStack) -> Value {
|
||||
@@ -457,6 +463,7 @@ impl Game<'_> {
|
||||
"velocity": [p.velocity.x, p.velocity.y, p.velocity.z],
|
||||
"on_ground": p.ground.on_ground,
|
||||
"flying": p.flying,
|
||||
"fall_flying": p.fall_flying,
|
||||
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"health": p.health.health(),
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
//! pixels, 1024 characters, 100 pages), signing it into a written book,
|
||||
//! reading written books, and lecterns (placing a book, turning its pages
|
||||
//! with a redstone pulse, the comparator output and taking the book).
|
||||
use super::Game;
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Lectern},
|
||||
first_person::Hand,
|
||||
hud::{HudBatch, rgba},
|
||||
item_data::WrittenBook,
|
||||
items::ItemStack,
|
||||
@@ -14,6 +15,7 @@ use horizon_client::{
|
||||
ui,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
@@ -48,9 +50,32 @@ impl Game<'_> {
|
||||
fn book_lines(&self, page: &str) -> Vec<Text> {
|
||||
self.gui.atlas.font.split(&Text::literal(page), 114.0)
|
||||
}
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use`.
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
|
||||
/// A server's player writes in its own client's editor
|
||||
/// (`LocalPlayer.openItemGui`), and reads on its own client once told to
|
||||
/// (`ServerPlayer.openItemGui`'s ClientboundOpenBookPacket).
|
||||
pub(super) fn use_book(&mut self, stack: &ItemStack) -> bool {
|
||||
let screen = match &*stack.item {
|
||||
let written = match &*stack.item {
|
||||
"minecraft:writable_book" => false,
|
||||
"minecraft:written_book" => true,
|
||||
_ => return false,
|
||||
};
|
||||
if self.lan_acting.is_some() {
|
||||
if written {
|
||||
let hand = i32::from(self.hand == Hand::Off);
|
||||
self.guest_packets
|
||||
.push(C::OpenBook(play::OpenBook { hand }));
|
||||
}
|
||||
} else if let Some(screen) = self.book_screen(stack) {
|
||||
self.open_book(screen);
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// The screen a book in hand opens: `BookEditScreen` for a book and
|
||||
/// quill, `BookViewScreen` for a written book.
|
||||
fn book_screen(&self, stack: &ItemStack) -> Option<BookScreen> {
|
||||
Some(match &*stack.item {
|
||||
"minecraft:writable_book" => {
|
||||
let mut pages = stack
|
||||
.data()
|
||||
@@ -77,12 +102,31 @@ impl Game<'_> {
|
||||
page: 0,
|
||||
lectern: None,
|
||||
},
|
||||
_ => return false,
|
||||
};
|
||||
self.open_book(screen);
|
||||
self.swing_arm();
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// `MultiPlayerGameMode.useItem`'s own use of a book and quill on a
|
||||
/// server's client: its editor opens here at once.
|
||||
pub(super) fn remote_use_book(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:writable_book" {
|
||||
return false;
|
||||
}
|
||||
if let Some(screen) = self.book_screen(stack) {
|
||||
self.open_book(screen);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `ClientPacketListener.handleOpenBook`: the written book in that hand.
|
||||
pub(super) fn remote_open_book(&mut self, hand: i32) {
|
||||
let hand = if hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let screen = self.with_hand(hand, |g| {
|
||||
let stack = g.held().filter(|s| &*s.item == "minecraft:written_book")?;
|
||||
g.book_screen(&stack.clone())
|
||||
});
|
||||
if let Some(screen) = screen {
|
||||
self.open_book(screen);
|
||||
}
|
||||
}
|
||||
fn open_book(&mut self, screen: BookScreen) {
|
||||
self.book = Some(screen);
|
||||
self.keys.clear();
|
||||
@@ -104,7 +148,9 @@ impl Game<'_> {
|
||||
self.book = None;
|
||||
self.capture(true);
|
||||
}
|
||||
/// `BookEditScreen.saveChanges`: trailing empty pages dropped.
|
||||
/// `BookEditScreen.saveChanges` and `BookSignScreen`'s Sign and Close:
|
||||
/// trailing empty pages dropped, the book in hand updated here and, on a
|
||||
/// server's client, sent (ServerboundEditBookPacket).
|
||||
fn save_book(&mut self, sign: bool) {
|
||||
let Some(BookScreen::Edit {
|
||||
slot, pages, title, ..
|
||||
@@ -116,29 +162,59 @@ impl Game<'_> {
|
||||
while pages.len() > 1 && pages.last().is_some_and(String::is_empty) {
|
||||
pages.pop();
|
||||
}
|
||||
if let Some(stack) = self.stack_in_mut(slot).as_mut()
|
||||
&& &*stack.item == "minecraft:writable_book"
|
||||
{
|
||||
if sign {
|
||||
let mut written = ItemStack::new("minecraft:written_book", 1);
|
||||
written.data_mut().written = Some(WrittenBook {
|
||||
title: title.trim().to_owned(),
|
||||
author: super::chat::PLAYER_NAME.to_owned(),
|
||||
generation: 0,
|
||||
pages,
|
||||
});
|
||||
*stack = written;
|
||||
} else {
|
||||
stack.data_mut().writable_pages = if pages.iter().all(String::is_empty) {
|
||||
Vec::new()
|
||||
} else {
|
||||
pages
|
||||
};
|
||||
stack.tidy();
|
||||
}
|
||||
let title = sign.then(|| title.trim().to_owned());
|
||||
if self.remote.is_some() {
|
||||
self.send(S::EditBook(play::EditBook {
|
||||
slot: slot as i32,
|
||||
pages: pages.clone(),
|
||||
title: title.clone(),
|
||||
}));
|
||||
}
|
||||
self.write_book(slot, pages, title);
|
||||
self.capture(true);
|
||||
}
|
||||
/// The book and quill at `slot` takes the pages, or with a title becomes
|
||||
/// the written book its writer signed.
|
||||
fn write_book(&mut self, slot: usize, pages: Vec<String>, title: Option<String>) {
|
||||
let author = self
|
||||
.lan_acting_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| super::chat::PLAYER_NAME.to_owned());
|
||||
let Some(stack) = self
|
||||
.stack_in_mut(slot)
|
||||
.as_mut()
|
||||
.filter(|s| &*s.item == "minecraft:writable_book")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(title) = title {
|
||||
let mut written = ItemStack::new("minecraft:written_book", 1);
|
||||
written.data_mut().written = Some(WrittenBook {
|
||||
title,
|
||||
author,
|
||||
generation: 0,
|
||||
pages,
|
||||
});
|
||||
*stack = written;
|
||||
} else {
|
||||
stack.data_mut().writable_pages = if pages.iter().all(String::is_empty) {
|
||||
Vec::new()
|
||||
} else {
|
||||
pages
|
||||
};
|
||||
stack.tidy();
|
||||
}
|
||||
}
|
||||
/// `ServerGamePacketListenerImpl.handleEditBook`: a guest's book in a
|
||||
/// hotbar slot or its off hand (`updateBookContents`, `signBook`).
|
||||
pub(super) fn lan_edit_book(&mut self, host: &mut LanHost, conn: u32, p: play::EditBook) {
|
||||
if !((0..9).contains(&p.slot) || p.slot == super::shield::OFFHAND_SLOT as i32) {
|
||||
return;
|
||||
}
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.write_book(p.slot as usize, p.pages, p.title)
|
||||
});
|
||||
}
|
||||
pub(super) fn book_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
@@ -519,7 +595,26 @@ impl Game<'_> {
|
||||
Some(key) => self.lectern_button(key, 3),
|
||||
None => return Err("not a lectern".into()),
|
||||
},
|
||||
Some("done") => self.close_book(),
|
||||
// The book and quill's buttons: Sign, then Sign and Close; Done
|
||||
// (which saves), then Cancel.
|
||||
Some("sign") => match self.book.as_mut() {
|
||||
Some(BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
..
|
||||
}) if !title.trim().is_empty() => self.save_book(true),
|
||||
Some(BookScreen::Edit { signing, .. }) => *signing = true,
|
||||
_ => return Err("not a book and quill".into()),
|
||||
},
|
||||
Some("done") => match self.book.as_mut() {
|
||||
Some(BookScreen::Edit { signing: true, .. }) => {
|
||||
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
|
||||
*signing = false;
|
||||
}
|
||||
}
|
||||
Some(BookScreen::Edit { .. }) => self.save_book(false),
|
||||
_ => self.close_book(),
|
||||
},
|
||||
Some(other) => return Err(format!("unknown book button {other}").into()),
|
||||
}
|
||||
Ok(match &self.book {
|
||||
|
||||
@@ -232,6 +232,13 @@ impl Game<'_> {
|
||||
"key" => {
|
||||
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
|
||||
self.require_world()?;
|
||||
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
|
||||
if self.sign_edit.is_some() {
|
||||
if arg(2)? != "up" {
|
||||
self.sign_input(Some(key), None);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
// A held edit modifier (`key super down`) and A/C/V/X: the
|
||||
// open book's Select All, Copy, Paste or Cut.
|
||||
if self.book.is_some() && arg(2)? != "up" {
|
||||
@@ -524,6 +531,10 @@ impl Game<'_> {
|
||||
self.book_type(text);
|
||||
return ok();
|
||||
}
|
||||
if self.sign_edit.is_some() {
|
||||
self.sign_input(None, Some(text));
|
||||
return ok();
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
@@ -549,7 +560,7 @@ impl Game<'_> {
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"type needs the chat screen (key t tap), the language search, the creative search, the anvil or World Options".into(),
|
||||
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -977,6 +988,7 @@ impl Game<'_> {
|
||||
"flashes": self.lightning_seen[1], "flash": self.flash});
|
||||
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
|
||||
status["projectiles"] = json!(self.projectiles_status());
|
||||
status["maps"] = json!(self.maps_status());
|
||||
// The particles made so far, by type.
|
||||
status["particles"] = json!(self.particles.made());
|
||||
// The world border this side knows of.
|
||||
|
||||
@@ -47,6 +47,8 @@ pub(super) struct Creatures {
|
||||
pub pearl_cooldown: u32,
|
||||
/// The wind charge's use cooldown (ticks).
|
||||
pub wind_cooldown: u32,
|
||||
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
|
||||
pub wind_bursts: Vec<super::wind::Burst>,
|
||||
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
|
||||
pub elder_apparition: i32,
|
||||
/// The wandering trader spawner's clock.
|
||||
@@ -1778,7 +1780,7 @@ impl Game<'_> {
|
||||
_ => self.projectile_hit(&p, hit)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
self.flush_wind_bursts()
|
||||
}
|
||||
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
|
||||
/// as the host's when they have left.
|
||||
|
||||
@@ -69,12 +69,18 @@ impl Game<'_> {
|
||||
let lo = Vec3::new(f.pos[0] - half, f.pos[1], f.pos[2] - half);
|
||||
let hi = Vec3::new(f.pos[0] + half, f.pos[1] + 0.8, f.pos[2] + half);
|
||||
let b = horizon_core::geometry::Aabb::new(lo, hi);
|
||||
if let Some(p) = self.player_view()
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(b)
|
||||
{
|
||||
self.mob_hits_player_kind(6.0, f.pos, "minecraft:indirect_magic");
|
||||
}
|
||||
// Every player in the box, the server's players too.
|
||||
let pos = f.pos;
|
||||
let bite = move |g: &mut Self| {
|
||||
if let Some(p) = g.player_view()
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(b)
|
||||
{
|
||||
g.mob_hits_player_kind(6.0, pos, "minecraft:indirect_magic");
|
||||
}
|
||||
};
|
||||
bite(self);
|
||||
self.each_guest_hit(bite);
|
||||
for m in &mut self.mobs {
|
||||
let ally = matches!(
|
||||
m.species(),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
|
||||
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::lan::{entity::States, hub::Hub};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
@@ -15,7 +16,10 @@ use horizon_core::{
|
||||
geometry::{Aabb, Vec3, trig},
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use super::lan_actions::GuestPlay;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum State {
|
||||
@@ -24,6 +28,8 @@ enum State {
|
||||
Hooked(u32),
|
||||
}
|
||||
pub(super) struct Hook {
|
||||
/// Its entity id, which a server's players track it under.
|
||||
id: u32,
|
||||
position: Vec3,
|
||||
previous: Vec3,
|
||||
velocity: Vec3,
|
||||
@@ -114,7 +120,9 @@ impl Game<'_> {
|
||||
);
|
||||
// A spawned hook's start position is its feet: centre the box on the eye.
|
||||
let start = Vec3::new(start.x, start.y - SIZE / 2.0, start.z);
|
||||
self.next_entity += 1;
|
||||
self.hook = Some(Hook {
|
||||
id: self.next_entity,
|
||||
position: start,
|
||||
previous: start,
|
||||
velocity: v,
|
||||
@@ -562,12 +570,17 @@ impl Game<'_> {
|
||||
lerp(h.previous.z, h.position.z),
|
||||
))
|
||||
}
|
||||
/// FishingHookRenderer: the bobber, a camera-facing half-block quad
|
||||
/// centred on the hook.
|
||||
/// FishingHookRenderer: the bobbers (this player's and the others' a
|
||||
/// server tracks), camera-facing half-block quads centred on the hooks.
|
||||
pub(super) fn fishing_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let Some(p) = self.hook_position(alpha) else {
|
||||
let hooks: Vec<glam::DVec3> = self
|
||||
.hook_position(alpha)
|
||||
.into_iter()
|
||||
.chain(self.remote_hook_ends(alpha).into_iter().map(|(h, _)| h))
|
||||
.collect();
|
||||
if hooks.is_empty() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
let Some(r) = self
|
||||
.gui
|
||||
.atlas
|
||||
@@ -577,56 +590,57 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let light = self
|
||||
.world
|
||||
.light(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let forward = glam::DVec3::from_array(view_vector(self.yaw, self.pitch));
|
||||
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
|
||||
let up = right.cross(forward).normalize_or_zero();
|
||||
let s = 0.25;
|
||||
let corners = [
|
||||
p - right * s - up * s,
|
||||
p + right * s - up * s,
|
||||
p + right * s + up * s,
|
||||
p - right * s + up * s,
|
||||
];
|
||||
let uv = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
|
||||
for (v, uv) in corners.iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (*v - camera).as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
});
|
||||
for p in hooks {
|
||||
let light =
|
||||
self.world
|
||||
.light(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let corners = [
|
||||
p - right * s - up * s,
|
||||
p + right * s - up * s,
|
||||
p + right * s + up * s,
|
||||
p - right * s + up * s,
|
||||
];
|
||||
for (v, uv) in corners.iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (*v - camera).as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/// FishingHookRenderer: the line, 16 black `lines` segments sagging from
|
||||
/// a quarter block above the hook to the rod in the player's hand,
|
||||
/// FishingHookRenderer: each line, 16 black `lines` segments sagging
|
||||
/// from a quarter block above the hook to the rod in its owner's hand,
|
||||
/// `width` pixels wide (`Window.getAppropriateLineWidth`).
|
||||
pub(super) fn fishing_line(&self, alpha: f64, size: [u32; 2]) -> Vec<Line> {
|
||||
let (Some(hook), Some(hand)) = (
|
||||
self.hook_position(alpha),
|
||||
self.rod_hand_position(alpha, size),
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let offset = (hand - (hook + glam::DVec3::new(0.0, 0.25, 0.0))).as_vec3();
|
||||
let point = |a: f32| {
|
||||
let y = offset.y * (a * a + a) * 0.5 + 0.25;
|
||||
hook + glam::Vec3::new(offset.x * a, y, offset.z * a).as_dvec3()
|
||||
};
|
||||
let own = self
|
||||
.hook_position(alpha)
|
||||
.zip(self.rod_hand_position(alpha, size));
|
||||
let width = horizon_client::gfx::outline_width(size[0]);
|
||||
(0..16)
|
||||
.map(|i| Line {
|
||||
let mut lines = Vec::new();
|
||||
for (hook, hand) in own.into_iter().chain(self.remote_hook_ends(alpha)) {
|
||||
let offset = (hand - (hook + glam::DVec3::new(0.0, 0.25, 0.0))).as_vec3();
|
||||
let point = |a: f32| {
|
||||
let y = offset.y * (a * a + a) * 0.5 + 0.25;
|
||||
hook + glam::Vec3::new(offset.x * a, y, offset.z * a).as_dvec3()
|
||||
};
|
||||
lines.extend((0..16).map(|i| Line {
|
||||
start: point(i as f32 / 16.0).to_array(),
|
||||
end: point((i + 1) as f32 / 16.0).to_array(),
|
||||
color: 0xFF00_0000,
|
||||
width,
|
||||
})
|
||||
.collect()
|
||||
}));
|
||||
}
|
||||
lines
|
||||
}
|
||||
/// `FishingHookRenderer.getPlayerHandPos`: on the near plane in first
|
||||
/// person, beside the body otherwise.
|
||||
@@ -694,6 +708,20 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// The hooks drawn here, with their owners' ids (0 for this player's own).
|
||||
pub(super) fn hooks_status(&self) -> Vec<serde_json::Value> {
|
||||
let at = |p: Vec3| [p.x, p.y, p.z];
|
||||
let own = self.hook.iter().map(|h| (h.id, 0, h.position));
|
||||
let others = self
|
||||
.remote_hooks
|
||||
.iter()
|
||||
.map(|h| (h.id, h.owner, h.position));
|
||||
own.chain(others)
|
||||
.map(|(id, owner, p)| {
|
||||
serde_json::json!({"id": id, "kind": "minecraft:fishing_bobber", "owner": owner, "position": at(p)})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The hook for `horizon-ctl status`.
|
||||
pub(super) fn hook_status(&self) -> Option<serde_json::Value> {
|
||||
let h = self.hook.as_ref()?;
|
||||
@@ -706,3 +734,175 @@ impl Game<'_> {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A hook a server tracks to this client (`FishingHook.recreateFromPacket`)
|
||||
/// that another player cast: drawn where the server puts it, its line
|
||||
/// running to that player's hand.
|
||||
pub(super) struct RemoteHook {
|
||||
id: u32,
|
||||
owner: u32,
|
||||
position: Vec3,
|
||||
previous: Vec3,
|
||||
}
|
||||
|
||||
impl Hook {
|
||||
/// This client's own hook as its server tracks it: drawn, never ticked here.
|
||||
fn remote(id: u32, at: Vec3) -> Self {
|
||||
Self {
|
||||
id,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
state: State::Flying,
|
||||
life: 0,
|
||||
on_ground: false,
|
||||
nibble: 0,
|
||||
time_until_lured: 0,
|
||||
time_until_hooked: 0,
|
||||
fish_angle: 0.0,
|
||||
out_of_water: 0,
|
||||
open_water: true,
|
||||
luck: 0,
|
||||
lure: 0,
|
||||
}
|
||||
}
|
||||
/// `FishingHook`'s entity for the players tracking it: the owner's id in
|
||||
/// its add packet, DATA_HOOKED_ENTITY (the hooked id plus one) and
|
||||
/// DATA_BITING when they differ from their defaults.
|
||||
fn track(&self, owner: i32, out: &mut States) {
|
||||
let p = self.position;
|
||||
let e = out.add(
|
||||
self.id as i32,
|
||||
super::lan::entity_uuid(self.id),
|
||||
"minecraft:fishing_bobber",
|
||||
);
|
||||
e.position = [p.x, p.y, p.z];
|
||||
e.velocity = [self.velocity.x, self.velocity.y, self.velocity.z];
|
||||
e.on_ground = self.on_ground;
|
||||
e.data = owner;
|
||||
if let State::Hooked(id) = self.state {
|
||||
e.meta.push((8, serializer::INT, Value::Int(id as i32 + 1)));
|
||||
}
|
||||
if self.nibble > 0 {
|
||||
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The hooks guests track: the host's and each guest's in the level.
|
||||
pub(super) fn lan_hook_states(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
play: &HashMap<u32, GuestPlay>,
|
||||
hub: &Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
if let (Some(h), Some(owner)) = (&self.hook, host_entity) {
|
||||
h.track(owner, out);
|
||||
}
|
||||
for g in hub.guests.iter().filter(|g| hub.here(g)) {
|
||||
if let Some(h) = play.get(&g.conn).and_then(|p| p.hook.as_ref()) {
|
||||
h.track(g.entity_id, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleAddEntity` for a fishing bobber: this player's own becomes its
|
||||
/// hook (`Player.fishing`), another player's is drawn on its line to them.
|
||||
pub(super) fn spawn_remote_hook(&mut self, id: u32, at: Vec3, owner: i32) {
|
||||
let own = self.remote.as_ref().map(|r| r.entity_id);
|
||||
if own == Some(owner) {
|
||||
self.hook = Some(Hook::remote(id, at));
|
||||
} else {
|
||||
self.remote_hooks.push(RemoteHook {
|
||||
id,
|
||||
owner: owner as u32,
|
||||
position: at,
|
||||
previous: at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One tick of interpolation for the hooks a server tracks.
|
||||
pub(super) fn step_remote_hooks(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
if self.remote.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(h) = self.hook.as_mut() {
|
||||
h.previous = h.position;
|
||||
h.position = step(h.id, h.position);
|
||||
}
|
||||
for h in &mut self.remote_hooks {
|
||||
h.previous = h.position;
|
||||
h.position = step(h.id, h.position);
|
||||
}
|
||||
}
|
||||
|
||||
/// `FishingHook.onSyncedDataUpdated`: this player's hook is biting
|
||||
/// (DATA_BITING), which `status` shows as a nibble.
|
||||
pub(super) fn remote_hook_data(&mut self, id: u32, values: &[(u8, i32, Value)]) {
|
||||
let Some(h) = self
|
||||
.hook
|
||||
.as_mut()
|
||||
.filter(|h| h.id == id && self.remote.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (index, _, value) in values {
|
||||
if let (9, Value::Bool(biting)) = (index, value) {
|
||||
h.nibble = i32::from(*biting);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_hook(&mut self, id: u32) {
|
||||
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
|
||||
self.hook = None;
|
||||
}
|
||||
self.remote_hooks.retain(|h| h.id != id);
|
||||
}
|
||||
|
||||
pub(super) fn clear_remote_hooks(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
self.hook = None;
|
||||
}
|
||||
self.remote_hooks.clear();
|
||||
}
|
||||
|
||||
/// Other players' hooks: the bobber and its line to the owner's hand
|
||||
/// (`FishingHookRenderer.getPlayerHandPos` in third person), as
|
||||
/// (hook, hand) this frame.
|
||||
fn remote_hook_ends(&self, alpha: f64) -> Vec<(glam::DVec3, glam::DVec3)> {
|
||||
let lerp = |a: Vec3, b: Vec3| {
|
||||
glam::DVec3::new(
|
||||
a.x + (b.x - a.x) * alpha,
|
||||
a.y + (b.y - a.y) * alpha,
|
||||
a.z + (b.z - a.z) * alpha,
|
||||
)
|
||||
};
|
||||
self.remote_hooks
|
||||
.iter()
|
||||
.filter_map(|h| {
|
||||
let owner = self.mobs.iter().find(|m| m.id == h.owner)?;
|
||||
let feet = lerp(owner.previous, owner.position);
|
||||
let body = horizon_client::camera::lerp(
|
||||
alpha as f32,
|
||||
owner.previous_body_yaw,
|
||||
owner.body_yaw,
|
||||
);
|
||||
let angle = f64::from(body * (std::f64::consts::PI / 180.0) as f32);
|
||||
let (sin, cos) = (f64::from(trig::sin(angle)), f64::from(trig::cos(angle)));
|
||||
// The right arm; a crouching owner's eyes are lower.
|
||||
let eye = owner.eye_y() - owner.position.y;
|
||||
let hand = feet
|
||||
+ glam::DVec3::new(
|
||||
-cos * 0.35 - sin * 0.8,
|
||||
eye - 0.45,
|
||||
-sin * 0.35 + cos * 0.8,
|
||||
);
|
||||
Some((lerp(h.previous, h.position), hand))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,10 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`) until a
|
||||
//! server's players reach parity with the in-frame world: on the server
|
||||
//! thread, signs open no editor, books don't open, a totem doesn't save its
|
||||
//! holder and death keeps the inventory (#1380, #1366, #1292, #1284 and the
|
||||
//! other `[server-thread]` issues). LAN guests and dedicated servers use the
|
||||
//! same code either way.
|
||||
//! On by default. `--in-frame` (or `--no-server-thread`, or
|
||||
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead, the
|
||||
//! escape hatch. LAN guests and dedicated servers use the same code either
|
||||
//! way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
lan::{LanSettings, Owner},
|
||||
|
||||
@@ -411,7 +411,12 @@ impl Game<'_> {
|
||||
"game_mode": g.game_mode,
|
||||
// ItemCooldowns: the ticks the shield an axe disabled stays down.
|
||||
"shield_cooldown": h.play.get(&g.conn).map_or(0, |p| p.shield_cooldown),
|
||||
// The item the server has this guest using: its slot and ticks.
|
||||
"using": h.play.get(&g.conn).and_then(|p| p.using())
|
||||
.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })),
|
||||
"sneaking": g.sneaking,
|
||||
// The server's Elytra glide (shared flag 7).
|
||||
"fall_flying": g.fall_flying,
|
||||
"sleeping": g.bed,
|
||||
"health": g.health,
|
||||
// The server's body: its air supply and golden hearts.
|
||||
@@ -870,6 +875,7 @@ impl Game<'_> {
|
||||
.collect();
|
||||
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
|
||||
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
|
||||
} else {
|
||||
states.clear();
|
||||
}
|
||||
@@ -1006,6 +1012,8 @@ impl Game<'_> {
|
||||
drop(holding);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
// A mace's wind burst from a guest's attack.
|
||||
let _ = self.flush_wind_bursts();
|
||||
}
|
||||
|
||||
fn lan_guest_joined(
|
||||
@@ -1061,6 +1069,7 @@ impl Game<'_> {
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
|
||||
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
entities
|
||||
|
||||
@@ -30,6 +30,8 @@ use winit::keyboard::KeyCode;
|
||||
/// Per-guest state of the gameplay code that lives outside `Guest`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct GuestPlay {
|
||||
/// The sign this guest may write on (`SignBlockEntity.playerWhoMayEdit`).
|
||||
pub(super) sign_editing: Option<[i32; 3]>,
|
||||
mining: Option<Mining>,
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
@@ -80,6 +82,8 @@ pub(super) struct GuestPlay {
|
||||
/// (`LivingEntity.collectEquipmentChanges`).
|
||||
modifiers: Vec<(String, String)>,
|
||||
equipped: Option<Vec<Option<ItemStack>>>,
|
||||
/// The guest's cast fishing hook (`Player.fishing`).
|
||||
pub(super) hook: Option<super::fishing::Hook>,
|
||||
}
|
||||
|
||||
/// A guest's command waiting for the hub (`LanHost::commands`).
|
||||
@@ -259,6 +263,7 @@ impl Game<'_> {
|
||||
motion.pitch = g.pitch;
|
||||
motion.sprinting = g.sprinting;
|
||||
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
|
||||
motion.flying = g.flying;
|
||||
motion.invulnerable = motion.may_fly;
|
||||
let _ = motion.set_health(g.health, false);
|
||||
motion.health.food.food = g.food;
|
||||
@@ -342,6 +347,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
|
||||
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
|
||||
std::mem::swap(&mut self.stats, &mut play.stats);
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
|
||||
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
|
||||
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
|
||||
@@ -513,7 +519,8 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.mount, &mut play.mount);
|
||||
self.container = container;
|
||||
self.screen = screen;
|
||||
self.sign_edit = host_sign;
|
||||
// A sign the guest's placement opened (SignBlock.openTextEdit).
|
||||
let guest_sign = std::mem::replace(&mut self.sign_edit, host_sign);
|
||||
// A lectern the guest's use opened (LecternBlock.openScreen).
|
||||
let guest_lectern = match std::mem::replace(&mut self.book, host_book) {
|
||||
Some(super::books::BookScreen::View {
|
||||
@@ -548,6 +555,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
|
||||
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
|
||||
std::mem::swap(&mut self.stats, &mut play.stats);
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
self.adv.loaded = adv_loaded;
|
||||
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
|
||||
let stat_events = std::mem::take(&mut play.stats.events);
|
||||
@@ -652,6 +660,9 @@ impl Game<'_> {
|
||||
if let Some(station) = opened_station {
|
||||
self.lan_station_opened(host, conn, station);
|
||||
}
|
||||
if let Some(edit) = guest_sign {
|
||||
self.lan_open_sign_editor(host, conn, edit);
|
||||
}
|
||||
if let Some(key) = guest_lectern {
|
||||
self.lan_open_lectern(host, conn, key);
|
||||
}
|
||||
@@ -865,14 +876,20 @@ impl Game<'_> {
|
||||
self.as_guest(host, conn, |g| g.drop_held(a.action == 4));
|
||||
}
|
||||
}
|
||||
// RELEASE_USE_ITEM: ServerPlayer.releaseUsingItem, with the
|
||||
// hand of the item in use acting.
|
||||
6 => {
|
||||
self.as_guest(host, conn, |g| {
|
||||
if let Some((slot, ticks)) = g.using.take()
|
||||
&& g.stack_in(slot)
|
||||
.is_some_and(|s| &*s.item == "minecraft:bow")
|
||||
{
|
||||
g.release_bow(ticks);
|
||||
}
|
||||
let Some(hand) = g.used_hand() else {
|
||||
return;
|
||||
};
|
||||
g.with_hand(hand, |g| {
|
||||
if let Some((slot, ticks)) = g.using.take()
|
||||
&& let Some(stack) = g.stack_in(slot).cloned()
|
||||
{
|
||||
g.release_used_item(&stack, ticks, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
|
||||
@@ -1029,30 +1046,27 @@ impl Game<'_> {
|
||||
g.open_ridden_mount_menu();
|
||||
});
|
||||
}
|
||||
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
|
||||
S::ContainerButtonClick(p) => {
|
||||
self.lan_station_button(host, conn, p.container, p.button);
|
||||
}
|
||||
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
|
||||
S::SignUpdate(p) => self.lan_sign_update(host, conn, p),
|
||||
S::EditBook(p) => self.lan_edit_book(host, conn, p),
|
||||
S::ContainerSlotStateChanged(p) => {
|
||||
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
|
||||
}
|
||||
S::SetBeacon(p) => self.lan_station_beacon(host, conn, p.primary, p.secondary),
|
||||
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
|
||||
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if let Some(c) = g.carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ContainerClose(p)
|
||||
if self.lan_crafting_closed(host, conn, Some(p.container))
|
||||
|| self.lan_station_closed(host, conn, Some(p.container))
|
||||
|| self.lan_mount_closed(host, conn, Some(p.container)) =>
|
||||
{
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if let Some(c) = g.carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
let rules = ItemRules(self.res.items());
|
||||
@@ -1068,10 +1082,7 @@ impl Game<'_> {
|
||||
self.guest_container_closed(host, conn);
|
||||
}
|
||||
host.hub.container_closed(conn, p.container);
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if let Some(c) = g.carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
|
||||
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
|
||||
@@ -1218,6 +1229,17 @@ impl Game<'_> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.removed`: what a closing player held on the
|
||||
/// cursor goes back into their inventory (`placeItemBackInInventory`,
|
||||
/// dropped only if it doesn't fit), and their client's cursor empties
|
||||
/// with the inventory it is sent.
|
||||
fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
|
||||
if let Some(c) = host.hub.guests[gi].carried.take() {
|
||||
self.lan_place_back(host, gi, vec![c]);
|
||||
host.hub.send_inventory(gi);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn drop_guest_items(&mut self, host: &LanHost, gi: usize, stacks: Vec<ItemStack>) {
|
||||
let g = &host.hub.guests[gi];
|
||||
let eye = [
|
||||
@@ -1704,16 +1726,23 @@ impl Game<'_> {
|
||||
// PlayerAdvancements.flushDirty.
|
||||
let sleeping = host.hub.guests[gi].sleeping;
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.tick_fishing();
|
||||
g.tick_maps();
|
||||
g.count_tick_stats(sleeping);
|
||||
g.tick_advancements();
|
||||
g.update_player_attributes();
|
||||
});
|
||||
self.lan_map_updates(host, conn);
|
||||
host.hub.flush_progress(conn);
|
||||
self.guest_dig_feedback(host, conn);
|
||||
self.guest_pickups(host, gi);
|
||||
self.guest_orbs(host, gi);
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
// FishingHook.shouldStopFishing: its owner is dead.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.hook = None;
|
||||
}
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
@@ -1727,6 +1756,9 @@ impl Game<'_> {
|
||||
}
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
let (name, uuid) = (g.name.clone(), g.uuid);
|
||||
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
|
||||
let drops = (g.game_mode != SPECTATOR)
|
||||
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
let message = match cause {
|
||||
// ServerPlayer.die: the death message, to everyone unless
|
||||
@@ -1760,6 +1792,9 @@ impl Game<'_> {
|
||||
);
|
||||
// LivingEntity.die: the death animation, the dying player included.
|
||||
host.hub.entity_event(id, 3, at);
|
||||
if let Some(eyes) = drops {
|
||||
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
|
||||
}
|
||||
self.spawn_orbs(at, reward);
|
||||
}
|
||||
}
|
||||
@@ -1846,6 +1881,13 @@ impl Game<'_> {
|
||||
/// sounds are heard at the guest.
|
||||
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
|
||||
self.guest_vehicle_tick(host, conn);
|
||||
// The last move's fall stands in for the server's delta movement.
|
||||
let fell = host
|
||||
.hub
|
||||
.guest_mut(conn)
|
||||
.and_then(|g| g.moves.last().map(|m| m.0[1]))
|
||||
.unwrap_or(0.0);
|
||||
self.guest_glide_tick(host, conn, fell);
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
@@ -1963,6 +2005,87 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.handlePlayerCommand START_FALL_FLYING:
|
||||
/// `Player.tryToStartFallFlying`, or else `stopFallFlying` (a second
|
||||
/// start while gliding ends the glide, as in vanilla).
|
||||
fn lan_start_gliding(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
let start = !g.fall_flying && self.guest_can_glide(host, conn) == Some(true);
|
||||
let in_liquid = self
|
||||
.as_guest(host, conn, |game| {
|
||||
game.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.fluids.was_touching_water || p.is_in_lava())
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if let Some(g) = host.hub.guest_mut(conn) {
|
||||
g.fall_flying = start && !in_liquid;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.canGlide`: not flying, airborne, not riding, no levitation, and
|
||||
/// a glider that its next point of wear won't break.
|
||||
fn guest_can_glide(&mut self, host: &mut LanHost, conn: u32) -> Option<bool> {
|
||||
let g = host.hub.guest_mut(conn)?;
|
||||
if g.flying || g.on_ground || g.vehicle.is_some() {
|
||||
return Some(false);
|
||||
}
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.glider_worn() && game.potions.effects.get("levitation").is_none()
|
||||
})
|
||||
}
|
||||
|
||||
/// `LivingEntity.updateFallFlying` on the server, once a tick for a
|
||||
/// gliding guest: the glide ends once `canGlide` fails; otherwise every
|
||||
/// 20th tick wears the glider by one, and every 10th is an
|
||||
/// `elytra_glide` game event. Entity.checkFallDistanceAccumulation runs
|
||||
/// first: a glide that isn't diving keeps the fall distance at 1.
|
||||
fn guest_glide_tick(&mut self, host: &mut LanHost, conn: u32, fell: f64) {
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
if !g.fall_flying {
|
||||
if let Some(m) = g.motion.as_mut() {
|
||||
m.fall_fly_ticks = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let creative = g.creative();
|
||||
if let Some(m) = g.motion.as_mut()
|
||||
&& fell > -0.5
|
||||
&& m.fall_distance > 1.0
|
||||
{
|
||||
m.fall_distance = 1.0;
|
||||
}
|
||||
if self.guest_can_glide(host, conn) != Some(true) {
|
||||
if let Some(g) = host.hub.guest_mut(conn) {
|
||||
g.fall_flying = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.as_guest(host, conn, |game| {
|
||||
let Some(p) = game.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
p.fall_fly_ticks += 1;
|
||||
let next = p.fall_fly_ticks + 1;
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
if next % 10 != 0 {
|
||||
return;
|
||||
}
|
||||
if (next / 10) % 2 == 0
|
||||
&& !creative
|
||||
&& let Some(elytra) = game.armor[1].as_mut()
|
||||
{
|
||||
elytra.damage += 1;
|
||||
}
|
||||
let ctx = game.player_context(None);
|
||||
game.post_game_event("minecraft:elytra_glide", at, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// `Player.isInWall`: a suffocating block in the 0.48-wide box at the
|
||||
/// eyes. Suffocating is a full collision box that is also a full render
|
||||
/// cube (`BlockBehaviour.isSuffocating`'s default; glass and the like
|
||||
|
||||
@@ -78,6 +78,8 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
| MobEvent::EffectPlayer { .. }
|
||||
| MobEvent::AxolotlSupportPlayer
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,6 +117,25 @@ impl Game<'_> {
|
||||
views
|
||||
}
|
||||
|
||||
/// Runs `f` as each guest in play here, for what strikes every player in
|
||||
/// an area (`Level.getEntities`), not only the window's own.
|
||||
pub(super) fn each_guest_hit(&mut self, mut f: impl FnMut(&mut Self)) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
self.guest_hit_by(&mut host, conn, &mut f);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
|
||||
/// Mob events aimed at a guest, run as that guest (`guest_hit_by`).
|
||||
pub(super) fn lan_guest_mob_events(&mut self, events: Vec<(u32, MobEvent)>) {
|
||||
if events.is_empty() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`).
|
||||
use super::Game;
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_protocol::{
|
||||
entity_data::{Particle, ParticleData},
|
||||
packets::play::{self, Clientbound as C},
|
||||
@@ -154,7 +154,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleExplosion`: the blast's particles (and its
|
||||
/// sound, when the packet carries it).
|
||||
/// sound, when the packet carries it), then the player's push.
|
||||
pub(super) fn remote_explode(&mut self, p: &play::Explode) {
|
||||
if p.play_sound {
|
||||
let pitch = (1.0
|
||||
@@ -168,37 +168,88 @@ impl Game<'_> {
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
let (world, facts) = (&self.world, self.res.facts());
|
||||
self.particles
|
||||
.explosion(p.center, p.radius, p.block_count.max(0) as usize, &|b| {
|
||||
facts.is_air(world.block(b[0], b[1], b[2]))
|
||||
});
|
||||
if let Some(options) = self.received_particle(&p.particle, p.center) {
|
||||
self.particles.add(&options, p.center, [1.0, 0.0, 0.0]);
|
||||
}
|
||||
// A wind charge's burst throws none.
|
||||
if !p.block_particles.is_empty() {
|
||||
let (world, facts) = (&self.world, self.res.facts());
|
||||
self.particles.explosion_blocks(
|
||||
p.center,
|
||||
p.radius,
|
||||
p.block_count.max(0) as usize,
|
||||
&|b| facts.is_air(world.block(b[0], b[1], b[2])),
|
||||
);
|
||||
}
|
||||
// Entity.pushFromExplosion.
|
||||
if let (Some(push), Some(player)) = (p.knockback, self.player.as_mut()) {
|
||||
player.velocity = Vec3::new(
|
||||
player.velocity.x + push[0],
|
||||
player.velocity.y + push[1],
|
||||
player.velocity.z + push[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
|
||||
/// sound and the push reach them on their own (the level's sounds, the
|
||||
/// players' motion), so the packet carries neither.
|
||||
/// motion a hurt player is sent), so the packet carries neither.
|
||||
pub(super) fn lan_explode(&self, center: [f64; 3], radius: f32, block_count: usize) {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let particle = |name: &str| {
|
||||
Some(Particle {
|
||||
id: host.hub.registry_id("minecraft:particle_type", name)?,
|
||||
data: None,
|
||||
})
|
||||
};
|
||||
let emitter = if radius < 2.0 {
|
||||
"minecraft:explosion"
|
||||
} else {
|
||||
"minecraft:explosion_emitter"
|
||||
};
|
||||
let (Some(at_center), Some(poof), Some(smoke)) = (
|
||||
particle(emitter),
|
||||
particle("minecraft:poof"),
|
||||
particle("minecraft:smoke"),
|
||||
) else {
|
||||
return;
|
||||
let sound = "minecraft:entity.generic.explode";
|
||||
if let Some(packet) =
|
||||
self.explosion_packet(center, radius, block_count, emitter, sound, true)
|
||||
{
|
||||
self.send_explosion(packet, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
|
||||
/// no block particles, and for each player it pushed the push
|
||||
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
|
||||
/// nothing hurt them.
|
||||
pub(super) fn lan_wind_burst(
|
||||
&self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
sound: &str,
|
||||
block_count: usize,
|
||||
pushed: &[(u32, [f64; 3])],
|
||||
) {
|
||||
let emitter = if radius < 2.0 {
|
||||
"minecraft:gust_emitter_small"
|
||||
} else {
|
||||
"minecraft:gust_emitter_large"
|
||||
};
|
||||
if let Some(packet) =
|
||||
self.explosion_packet(center, radius, block_count, emitter, sound, false)
|
||||
{
|
||||
self.send_explosion(packet, pushed);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientboundExplodePacket` without a push, or None while not serving.
|
||||
/// The sound is the level's, which the players hear already, so it isn't
|
||||
/// played from the packet.
|
||||
fn explosion_packet(
|
||||
&self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
block_count: usize,
|
||||
emitter: &str,
|
||||
sound: &str,
|
||||
block_particles: bool,
|
||||
) -> Option<play::Explode> {
|
||||
let hub = &self.lan.as_ref()?.hub;
|
||||
let particle = |name: &str| {
|
||||
Some(Particle {
|
||||
id: hub.registry_id("minecraft:particle_type", name)?,
|
||||
data: None,
|
||||
})
|
||||
};
|
||||
// Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES.
|
||||
let info = |particle, scaling| play::ExplosionParticle {
|
||||
@@ -207,22 +258,49 @@ impl Game<'_> {
|
||||
speed: 1.0,
|
||||
weight: 1,
|
||||
};
|
||||
let packet = C::Explode(play::Explode {
|
||||
let block_particles = if block_particles {
|
||||
vec![
|
||||
info(particle("minecraft:poof")?, 0.5),
|
||||
info(particle("minecraft:smoke")?, 1.0),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Some(play::Explode {
|
||||
center,
|
||||
radius,
|
||||
block_count: block_count as i32,
|
||||
knockback: None,
|
||||
particle: at_center,
|
||||
sound: match host
|
||||
.hub
|
||||
.registry_id("minecraft:sound_event", "minecraft:entity.generic.explode")
|
||||
{
|
||||
particle: particle(emitter)?,
|
||||
sound: match hub.registry_id("minecraft:sound_event", sound) {
|
||||
Some(id) => (id, None),
|
||||
None => (-1, Some(("minecraft:entity.generic.explode".into(), None))),
|
||||
None => (-1, Some((sound.into(), None))),
|
||||
},
|
||||
block_particles: vec![info(poof, 0.5), info(smoke, 1.0)],
|
||||
block_particles,
|
||||
play_sound: false,
|
||||
});
|
||||
host.hub.broadcast_near(center, 64.0, &packet, None);
|
||||
})
|
||||
}
|
||||
|
||||
/// `packet` to each player within 64 blocks of its centre, with the push
|
||||
/// `pushed` holds for them.
|
||||
fn send_explosion(&self, mut packet: play::Explode, pushed: &[(u32, [f64; 3])]) {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let center = packet.center;
|
||||
let plain = C::Explode(packet.clone()).encode();
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
let d: f64 = (0..3).map(|k| (g.position[k] - center[k]).powi(2)).sum();
|
||||
if d >= 4096.0 {
|
||||
continue;
|
||||
}
|
||||
match pushed.iter().find(|(conn, _)| *conn == g.conn) {
|
||||
Some((_, push)) => {
|
||||
packet.knockback = Some(*push);
|
||||
g.send(&C::Explode(packet.clone()));
|
||||
}
|
||||
None => g.sender.send(plain.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -588,17 +588,7 @@ impl Game<'_> {
|
||||
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);
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
// The block interaction range (4.5, creative 5) and the buffer.
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
let near = (0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(gs.pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach;
|
||||
if !(block && near) {
|
||||
if !(block && within_reach(g, gs.pos)) {
|
||||
let window = gs.window;
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
@@ -670,3 +660,18 @@ pub(super) fn station_inputs(gs: &GuestStation) -> Vec<ItemStack> {
|
||||
.map(|(_, _, inputs)| inputs.filter_map(|s| gs.menu.slot(s).cloned()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `Player.isWithinBlockInteractionRange(pos, 4)`: the guest's eyes within
|
||||
/// its block interaction range (4.5, creative 5) and a 4-block buffer of the
|
||||
/// block's box.
|
||||
pub(super) fn within_reach(g: &horizon_client::lan::guest::Guest, pos: [i32; 3]) -> bool {
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
(0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach
|
||||
}
|
||||
@@ -280,11 +280,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// `thunderHit` for entities within 3 blocks (up to 9 above).
|
||||
fn thunder_hit(&mut self, b: &mut Bolt) {
|
||||
let inside = |p: [f64; 3]| {
|
||||
(p[0] - b.at[0]).abs() <= 3.0
|
||||
&& (p[2] - b.at[2]).abs() <= 3.0
|
||||
&& p[1] >= b.at[1] - 3.0
|
||||
&& p[1] <= b.at[1] + 9.0
|
||||
let at = b.at;
|
||||
let inside = move |p: [f64; 3]| {
|
||||
(p[0] - at[0]).abs() <= 3.0
|
||||
&& (p[2] - at[2]).abs() <= 3.0
|
||||
&& p[1] >= at[1] - 3.0
|
||||
&& p[1] <= at[1] + 9.0
|
||||
};
|
||||
let damage = Damage {
|
||||
kind: DamageKind::Generic,
|
||||
@@ -349,19 +350,24 @@ impl Game<'_> {
|
||||
if !events.is_empty() {
|
||||
let _ = self.handle_mob_events(events);
|
||||
}
|
||||
if let Some(p) = self.player.as_ref().map(|p| p.position)
|
||||
&& inside([p.x, p.y, p.z])
|
||||
{
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.fire_ticks += 1;
|
||||
if p.fire_ticks == 0
|
||||
&& let Err(error) = p.ignite_for_ticks(160)
|
||||
{
|
||||
self.error = Some(error);
|
||||
// Every player in the box, the server's players too.
|
||||
let strike = move |g: &mut Self| {
|
||||
if let Some(p) = g.player.as_ref().map(|p| p.position)
|
||||
&& inside([p.x, p.y, p.z])
|
||||
{
|
||||
if let Some(p) = g.player.as_mut() {
|
||||
p.fire_ticks += 1;
|
||||
if p.fire_ticks == 0
|
||||
&& let Err(error) = p.ignite_for_ticks(160)
|
||||
{
|
||||
g.error = Some(error);
|
||||
}
|
||||
}
|
||||
g.hurt_player_by("minecraft:lightning_bolt", 5.0);
|
||||
}
|
||||
self.hurt_player_by("minecraft:lightning_bolt", 5.0);
|
||||
}
|
||||
};
|
||||
strike(self);
|
||||
self.each_guest_hit(strike);
|
||||
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
|
||||
self.dropped.retain(|e| {
|
||||
!inside([e.position.x, e.position.y, e.position.z])
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! shading, the dithered edge), the player marker, `data/minecraft/maps/<id>.dat` and
|
||||
//! `maps/last_id.dat`, the 128x128 texture in an atlas slot, the two-handed
|
||||
//! first-person view and maps in item frames.
|
||||
use super::Game;
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
blocks::{Fluid, MAP_COLORS},
|
||||
hud::{MAP_SLOTS, Rect},
|
||||
@@ -14,13 +14,48 @@ use horizon_client::{
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A decoration: sprite, map position (-128..127, half pixels) and rotation (0..15).
|
||||
/// A decoration: sprite, map position (-128..127, half pixels) and rotation
|
||||
/// (0..15), and the player it marks (an item's own decorations mark none).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Decoration {
|
||||
pub sprite: &'static str,
|
||||
pub x: i8,
|
||||
pub y: i8,
|
||||
pub rot: u8,
|
||||
pub holder: Option<u32>,
|
||||
}
|
||||
/// The host among a map's holders (the guests are their connections).
|
||||
const HOST: u32 = u32::MAX;
|
||||
/// A player carrying a map, or seeing it framed
|
||||
/// (`MapItemSavedData.HoldingPlayer`): the column sweep's step, and what
|
||||
/// changed since the last `ClientboundMapItemDataPacket` it was sent.
|
||||
struct Holder {
|
||||
who: u32,
|
||||
/// The last tick it carried the map, and saw it framed.
|
||||
carried: i64,
|
||||
framed: i64,
|
||||
step: i32,
|
||||
/// The changed pixels' box (min x, min y, max x, max y).
|
||||
dirty: Option<[i32; 4]>,
|
||||
decorations: bool,
|
||||
tick: u32,
|
||||
}
|
||||
impl Holder {
|
||||
fn new(who: u32) -> Self {
|
||||
Self {
|
||||
who,
|
||||
carried: 0,
|
||||
framed: 0,
|
||||
step: 0,
|
||||
dirty: Some([0, 0, 127, 127]),
|
||||
decorations: true,
|
||||
tick: 0,
|
||||
}
|
||||
}
|
||||
/// Still carrying the map, or seeing it framed (frames tick every 10).
|
||||
fn holds(&self, now: i64) -> bool {
|
||||
self.carried + 1 >= now || self.framed + 10 >= now
|
||||
}
|
||||
}
|
||||
pub(super) struct MapData {
|
||||
pub center: [i32; 2],
|
||||
@@ -31,8 +66,7 @@ pub(super) struct MapData {
|
||||
pub unlimited: bool,
|
||||
pub colors: Vec<u8>,
|
||||
pub decorations: Vec<Decoration>,
|
||||
/// HoldingPlayer.step for the column sweep.
|
||||
step: i32,
|
||||
holders: Vec<Holder>,
|
||||
/// Changed since the last save / texture upload.
|
||||
dirty: bool,
|
||||
texture_dirty: bool,
|
||||
@@ -51,7 +85,7 @@ impl MapData {
|
||||
unlimited: false,
|
||||
colors: vec![0; 128 * 128],
|
||||
decorations: Vec::new(),
|
||||
step: 0,
|
||||
holders: Vec::new(),
|
||||
dirty: true,
|
||||
texture_dirty: true,
|
||||
}
|
||||
@@ -64,8 +98,128 @@ impl MapData {
|
||||
self.colors[i] = color;
|
||||
self.dirty = true;
|
||||
self.texture_dirty = true;
|
||||
// setColorsDirty: every holder's next packet carries it.
|
||||
for h in &mut self.holders {
|
||||
h.dirty = Some(match h.dirty {
|
||||
Some([x0, y0, x1, y1]) => [x0.min(x), y0.min(y), x1.max(x), y1.max(y)],
|
||||
None => [x, y, x, y],
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `tickCarriedBy`'s holder for `who`, added when new.
|
||||
fn holder(&mut self, who: u32) -> &mut Holder {
|
||||
let i = match self.holders.iter().position(|h| h.who == who) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.holders.push(Holder::new(who));
|
||||
self.holders.len() - 1
|
||||
}
|
||||
};
|
||||
&mut self.holders[i]
|
||||
}
|
||||
/// The holders that no longer carry the map nor see it framed go, and
|
||||
/// their markers with them (`tickCarriedBy`'s removeDecoration).
|
||||
fn drop_holders(&mut self, now: i64) {
|
||||
self.holders.retain(|h| h.holds(now));
|
||||
let holders = &self.holders;
|
||||
let before = self.decorations.len();
|
||||
self.decorations
|
||||
.retain(|d| d.holder.is_none_or(|w| holders.iter().any(|h| h.who == w)));
|
||||
if self.decorations.len() != before {
|
||||
self.decorations_changed();
|
||||
}
|
||||
}
|
||||
/// setDecorationsDirty.
|
||||
fn decorations_changed(&mut self) {
|
||||
for h in &mut self.holders {
|
||||
h.decorations = true;
|
||||
}
|
||||
}
|
||||
/// `addDecoration` / `removeDecoration` for `holder`'s marker: in place,
|
||||
/// so that the order (the drawing order) stays.
|
||||
fn set_marker(&mut self, holder: u32, marker: Option<Decoration>) {
|
||||
let i = self
|
||||
.decorations
|
||||
.iter()
|
||||
.position(|d| d.holder == Some(holder));
|
||||
let changed = match (i, marker) {
|
||||
(Some(i), Some(d)) if self.decorations[i] == d => false,
|
||||
(Some(i), Some(d)) => {
|
||||
self.decorations[i] = d;
|
||||
true
|
||||
}
|
||||
(Some(i), None) => {
|
||||
self.decorations.remove(i);
|
||||
true
|
||||
}
|
||||
(None, Some(d)) => {
|
||||
self.decorations.push(d);
|
||||
true
|
||||
}
|
||||
(None, None) => false,
|
||||
};
|
||||
if changed {
|
||||
self.decorations_changed();
|
||||
}
|
||||
}
|
||||
/// `HoldingPlayer.nextUpdatePacket` for `who`: the changed pixels, and
|
||||
/// the decorations (at most every 5th call while they changed).
|
||||
fn update_packet(
|
||||
&mut self,
|
||||
id: i32,
|
||||
who: u32,
|
||||
kind: impl Fn(&str) -> Option<i32>,
|
||||
) -> Option<horizon_protocol::packets::play::MapItemData> {
|
||||
use horizon_protocol::packets::play::{MapDecoration, MapItemData, MapPatch};
|
||||
let h = self.holders.iter_mut().find(|h| h.who == who)?;
|
||||
let patch = h.dirty.take().map(|[x0, y0, x1, y1]| {
|
||||
let (width, height) = (x1 + 1 - x0, y1 + 1 - y0);
|
||||
let mut colors = Vec::with_capacity((width * height) as usize);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
colors.push(self.colors[(x0 + x + (y0 + y) * 128) as usize]);
|
||||
}
|
||||
}
|
||||
MapPatch {
|
||||
start_x: x0,
|
||||
start_y: y0,
|
||||
width,
|
||||
height,
|
||||
colors,
|
||||
}
|
||||
});
|
||||
let decorations = if h.decorations && {
|
||||
let due = h.tick % 5 == 0;
|
||||
h.tick += 1;
|
||||
due
|
||||
} {
|
||||
h.decorations = false;
|
||||
Some(
|
||||
self.decorations
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
Some(MapDecoration {
|
||||
kind: kind(decoration_type(d.sprite)?)?,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
rot: d.rot as i8,
|
||||
name: None,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(patch.is_some() || decorations.is_some()).then_some(MapItemData {
|
||||
map_id: id,
|
||||
scale: self.scale as i8,
|
||||
locked: self.locked,
|
||||
decorations,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
fn to_nbt(&self) -> Tag {
|
||||
let flag = |b: bool| Tag::Byte(i8::from(b));
|
||||
Tag::compound([
|
||||
@@ -112,7 +266,7 @@ impl MapData {
|
||||
unlimited: int("unlimitedTracking").unwrap_or(0) != 0,
|
||||
colors,
|
||||
decorations: Vec::new(),
|
||||
step: 0,
|
||||
holders: Vec::new(),
|
||||
dirty: false,
|
||||
texture_dirty: true,
|
||||
})
|
||||
@@ -143,36 +297,48 @@ fn packed_rgba(packed: u8) -> [u8; 4] {
|
||||
pub(super) fn map_id(stack: &ItemStack) -> Option<i32> {
|
||||
stack.data().and_then(|d| d.map_id)
|
||||
}
|
||||
/// `MapDecorationTypes`: the sprite of a decoration type that a
|
||||
/// `minecraft:map_decorations` entry can name.
|
||||
/// `MapDecorationTypes` Horizon draws: (type, sprite).
|
||||
const DECORATION_TYPES: [(&str, &str); 24] = [
|
||||
("player", "map/decorations/player"),
|
||||
("frame", "map/decorations/frame"),
|
||||
("red_marker", "map/decorations/red_marker"),
|
||||
("blue_marker", "map/decorations/blue_marker"),
|
||||
("target_x", "map/decorations/target_x"),
|
||||
("target_point", "map/decorations/target_point"),
|
||||
("player_off_map", "map/decorations/player_off_map"),
|
||||
("player_off_limits", "map/decorations/player_off_limits"),
|
||||
("mansion", "map/decorations/woodland_mansion"),
|
||||
("monument", "map/decorations/ocean_monument"),
|
||||
("red_x", "map/decorations/red_x"),
|
||||
("village_desert", "map/decorations/desert_village"),
|
||||
("village_plains", "map/decorations/plains_village"),
|
||||
("village_savanna", "map/decorations/savanna_village"),
|
||||
("village_snowy", "map/decorations/snowy_village"),
|
||||
("village_taiga", "map/decorations/taiga_village"),
|
||||
("jungle_temple", "map/decorations/jungle_temple"),
|
||||
("swamp_hut", "map/decorations/swamp_hut"),
|
||||
("trial_chambers", "map/decorations/trial_chambers"),
|
||||
("abandoned_camp", "map/decorations/abandoned_camp"),
|
||||
("ancient_city", "map/decorations/ancient_city"),
|
||||
("desert_pyramid", "map/decorations/desert_pyramid"),
|
||||
("mineshaft", "map/decorations/mineshaft"),
|
||||
("ocean_ruin_warm", "map/decorations/warm_ocean_ruins"),
|
||||
];
|
||||
/// The sprite of a decoration type that a `minecraft:map_decorations`
|
||||
/// entry or a server's packet names.
|
||||
fn decoration_sprite(id: &str) -> Option<&'static str> {
|
||||
Some(match id.strip_prefix("minecraft:").unwrap_or(id) {
|
||||
"player" => "map/decorations/player",
|
||||
"frame" => "map/decorations/frame",
|
||||
"red_marker" => "map/decorations/red_marker",
|
||||
"blue_marker" => "map/decorations/blue_marker",
|
||||
"target_x" => "map/decorations/target_x",
|
||||
"target_point" => "map/decorations/target_point",
|
||||
"player_off_map" => "map/decorations/player_off_map",
|
||||
"player_off_limits" => "map/decorations/player_off_limits",
|
||||
"mansion" => "map/decorations/woodland_mansion",
|
||||
"monument" => "map/decorations/ocean_monument",
|
||||
"red_x" => "map/decorations/red_x",
|
||||
"village_desert" => "map/decorations/desert_village",
|
||||
"village_plains" => "map/decorations/plains_village",
|
||||
"village_savanna" => "map/decorations/savanna_village",
|
||||
"village_snowy" => "map/decorations/snowy_village",
|
||||
"village_taiga" => "map/decorations/taiga_village",
|
||||
"jungle_temple" => "map/decorations/jungle_temple",
|
||||
"swamp_hut" => "map/decorations/swamp_hut",
|
||||
"trial_chambers" => "map/decorations/trial_chambers",
|
||||
"abandoned_camp" => "map/decorations/abandoned_camp",
|
||||
"ancient_city" => "map/decorations/ancient_city",
|
||||
"desert_pyramid" => "map/decorations/desert_pyramid",
|
||||
"mineshaft" => "map/decorations/mineshaft",
|
||||
"ocean_ruin_warm" => "map/decorations/warm_ocean_ruins",
|
||||
_ => return None,
|
||||
})
|
||||
let name = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
DECORATION_TYPES
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.map(|(_, sprite)| *sprite)
|
||||
}
|
||||
/// The type a decoration's sprite stands for, as its registry names it.
|
||||
fn decoration_type(sprite: &str) -> Option<&'static str> {
|
||||
DECORATION_TYPES
|
||||
.iter()
|
||||
.find(|(_, s)| *s == sprite)
|
||||
.map(|(name, _)| *name)
|
||||
}
|
||||
/// `minecraft:map_decorations` of a map item: (sprite, x, z, rotation), in
|
||||
/// the component's order.
|
||||
@@ -275,12 +441,29 @@ impl Game<'_> {
|
||||
}
|
||||
self.maps.data.get_mut(&id)
|
||||
}
|
||||
/// `ClientboundMapItemDataPacket.handle`: the server's colours for a map
|
||||
/// this client does not own (lobby map art, a framed map on a wall).
|
||||
/// `ClientboundMapItemDataPacket.handle`: the server's colours and
|
||||
/// decorations for a map (one carried or framed on a server).
|
||||
pub(super) fn remote_map_item_data(
|
||||
&mut self,
|
||||
p: &horizon_protocol::packets::play::MapItemData,
|
||||
) {
|
||||
let decorations = p.decorations.as_ref().map(|list| {
|
||||
let types = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.builtin.get("minecraft:map_decoration_type"));
|
||||
list.iter()
|
||||
.filter_map(|d| {
|
||||
Some(Decoration {
|
||||
sprite: decoration_sprite(types?.name(d.kind)?)?,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
rot: (d.rot & 15) as u8,
|
||||
holder: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let dimension = self.dimension_id();
|
||||
let map = self
|
||||
.maps
|
||||
@@ -288,6 +471,9 @@ impl Game<'_> {
|
||||
.entry(p.map_id)
|
||||
.or_insert_with(|| MapData::fresh(0.0, 0.0, p.scale.max(0) as u8, dimension));
|
||||
map.locked = p.locked;
|
||||
if let Some(decorations) = decorations {
|
||||
map.decorations = decorations;
|
||||
}
|
||||
let Some(patch) = &p.patch else {
|
||||
map.dirty = false;
|
||||
return;
|
||||
@@ -566,10 +752,14 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let (yaw, dim) = (self.yaw, self.dimension_id());
|
||||
let (nether, game_time) = (self.dim == super::Dim::Nether, self.world.game_time);
|
||||
// The guest whose turn it is, else the host.
|
||||
let me = self.lan_acting.unwrap_or(HOST);
|
||||
for (id, targets) in carried {
|
||||
let Some(m) = self.map(id) else { continue };
|
||||
m.holder(me).carried = game_time;
|
||||
m.drop_holders(game_time);
|
||||
// tickCarriedBy: the player marker while tracking in this dimension.
|
||||
m.decorations.clear();
|
||||
let mut marker = None;
|
||||
if m.tracking && m.dimension == dim {
|
||||
let s = f64::from(1 << m.scale);
|
||||
let dx = ((p.x - f64::from(m.center[0])) / s) as f32;
|
||||
@@ -596,18 +786,19 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(sprite) = sprite {
|
||||
m.decorations.push(Decoration {
|
||||
sprite,
|
||||
x: clamp(dx),
|
||||
y: clamp(dz),
|
||||
rot,
|
||||
});
|
||||
}
|
||||
marker = sprite.map(|sprite| Decoration {
|
||||
sprite,
|
||||
x: clamp(dx),
|
||||
y: clamp(dz),
|
||||
rot,
|
||||
holder: Some(me),
|
||||
});
|
||||
}
|
||||
m.set_marker(me, marker);
|
||||
// The item's MAP_DECORATIONS (an explorer map's target), added
|
||||
// after the players as addDecoration would.
|
||||
let s = (1 << m.scale) as f32;
|
||||
let mut items: Vec<Decoration> = Vec::new();
|
||||
for (sprite, x, z, rotation) in targets {
|
||||
let dx = (x - f64::from(m.center[0])) as f32 / s;
|
||||
let dz = (z - f64::from(m.center[1])) as f32 / s;
|
||||
@@ -620,16 +811,95 @@ impl Game<'_> {
|
||||
x: clamp_map_coordinate(dx),
|
||||
y: clamp_map_coordinate(dz),
|
||||
rot: decoration_rotation(nether, game_time, f64::from(rotation)),
|
||||
holder: None,
|
||||
};
|
||||
if !m.decorations.contains(&d) {
|
||||
m.decorations.push(d);
|
||||
if !items.contains(&d) {
|
||||
items.push(d);
|
||||
}
|
||||
}
|
||||
if !m
|
||||
.decorations
|
||||
.iter()
|
||||
.filter(|d| d.holder.is_none())
|
||||
.eq(items.iter())
|
||||
{
|
||||
m.decorations.retain(|d| d.holder.is_some());
|
||||
m.decorations.extend(items);
|
||||
m.decorations_changed();
|
||||
}
|
||||
}
|
||||
for id in held {
|
||||
self.update_map(id);
|
||||
}
|
||||
}
|
||||
/// The maps the player carries, for `status`: how many of their pixels
|
||||
/// are drawn, and their decorations.
|
||||
pub(super) fn maps_status(&self) -> Vec<serde_json::Value> {
|
||||
self.inventory
|
||||
.iter()
|
||||
.chain(std::iter::once(&self.offhand))
|
||||
.flatten()
|
||||
.filter_map(map_id)
|
||||
.filter_map(|id| {
|
||||
let m = self.maps.data.get(&id)?;
|
||||
Some(serde_json::json!({
|
||||
"id": id,
|
||||
"pixels": m.colors.iter().filter(|&&c| c != 0).count(),
|
||||
"decorations": m.decorations.iter().map(|d| d.sprite).collect::<Vec<_>>(),
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `ServerPlayer.synchronizeSpecialItemUpdates` for each map the guest
|
||||
/// carries, and every 10 ticks (`ServerEntity.sendChanges`) for each map
|
||||
/// framed in the level: a `ClientboundMapItemDataPacket` when something
|
||||
/// changed since the guest's last one.
|
||||
pub(super) fn lan_map_updates(&mut self, host: &LanHost, conn: u32) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let mut ids: Vec<i32> = g
|
||||
.inventory
|
||||
.iter()
|
||||
.chain(std::iter::once(&g.offhand))
|
||||
.flatten()
|
||||
.filter_map(map_id)
|
||||
.collect();
|
||||
let now = self.world.game_time;
|
||||
if now % 10 == 0 && host.hub.here(g) {
|
||||
let framed: Vec<i32> = self
|
||||
.item_frames
|
||||
.iter()
|
||||
.filter_map(|f| f.item.as_ref().and_then(map_id))
|
||||
.collect();
|
||||
for id in framed {
|
||||
if let Some(m) = self.map(id) {
|
||||
m.holder(conn).framed = now;
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let kind = |name: &str| {
|
||||
host.hub.registry_id(
|
||||
"minecraft:map_decoration_type",
|
||||
&format!("minecraft:{name}"),
|
||||
)
|
||||
};
|
||||
for id in ids {
|
||||
if let Some(p) = self
|
||||
.maps
|
||||
.data
|
||||
.get_mut(&id)
|
||||
.and_then(|m| m.update_packet(id, conn, kind))
|
||||
{
|
||||
g.send(&horizon_protocol::packets::play::Clientbound::MapItemData(
|
||||
p,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `MapItem.update` for the player holding map `id`.
|
||||
fn update_map(&mut self, id: i32) {
|
||||
let dim = self.dimension_id();
|
||||
@@ -651,8 +921,9 @@ impl Game<'_> {
|
||||
if nether {
|
||||
radius /= 2;
|
||||
}
|
||||
m.step += 1;
|
||||
let step = m.step;
|
||||
let h = m.holder(self.lan_acting.unwrap_or(HOST));
|
||||
h.step += 1;
|
||||
let step = h.step;
|
||||
let mut changes: Vec<(i32, i32, u8)> = Vec::new();
|
||||
let mut consecutive = false;
|
||||
for img_x in px - radius + 1..px + radius {
|
||||
|
||||
@@ -641,6 +641,22 @@ impl Game<'_> {
|
||||
("mob_factor", Tag::Float(v[5].to_bits())),
|
||||
]),
|
||||
Component::Profile(p) => profile_tag(p),
|
||||
// ItemStackTemplate.CODEC: id, count and components.
|
||||
Component::Stacks(stacks) => Tag::List(
|
||||
stacks
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut entries = vec![
|
||||
("id", Tag::String(self.item_name(t.item).as_ref().into())),
|
||||
("count", Tag::Int(t.count)),
|
||||
];
|
||||
if let Some(c) = self.protocol_components(t) {
|
||||
entries.push(("components", c));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Component::CustomModelData {
|
||||
floats,
|
||||
flags,
|
||||
@@ -2557,6 +2573,8 @@ impl Game<'_> {
|
||||
self.drop_remote_chunks_out_of_range();
|
||||
}
|
||||
C::BlockEntityData(p) => self.remote_block_entity_data(p.pos, p.kind, &p.tag),
|
||||
C::OpenSignEditor(p) => self.remote_open_sign_editor(p),
|
||||
C::OpenBook(p) => self.remote_open_book(p.hand),
|
||||
// handleSetSpawn: ClientLevel.setRespawnData, which spawn compasses point at.
|
||||
C::SetDefaultSpawnPosition(p) => {
|
||||
let [x, y, z] = p.pos.pos;
|
||||
@@ -3121,14 +3139,11 @@ impl Game<'_> {
|
||||
[player.position.x, player.position.y, player.position.z],
|
||||
);
|
||||
let was_flying = player.flying;
|
||||
player.yaw = self.yaw;
|
||||
// As the window's own tick has them: the helmet's Respiration
|
||||
// (decreaseAirSupply) and the dimension's FAST_LAVA, the Nether's
|
||||
// stronger lava current.
|
||||
player.respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
player.fluids.fast_lava = self.dim == Dim::Nether;
|
||||
self.sync_player_body();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let was_gliding = player.fall_flying;
|
||||
let dead = self.screen == Screen::Dead;
|
||||
// LivingEntity.isImmobile: a sleeper lies still in the bed.
|
||||
if let Some(head) = self.sleeping {
|
||||
@@ -3153,6 +3168,9 @@ impl Game<'_> {
|
||||
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
|
||||
// jump, a spectator, landing) is sent to the server.
|
||||
let flying = player.flying;
|
||||
// LocalPlayer.aiStep: a glide this client started is sent to the
|
||||
// server (ServerboundPlayerCommandPacket START_FALL_FLYING).
|
||||
let started_gliding = player.fall_flying && !was_gliding;
|
||||
let events = std::mem::take(&mut player.events);
|
||||
self.previous_eye = self.eye;
|
||||
self.eye += (player.dimensions.eye_height - self.eye) * 0.5;
|
||||
@@ -3192,6 +3210,14 @@ impl Game<'_> {
|
||||
flags: if flying { 2 } else { 0 },
|
||||
}));
|
||||
}
|
||||
if started_gliding {
|
||||
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
|
||||
self.send(S::PlayerCommand(play::PlayerCommand {
|
||||
id,
|
||||
action: 6,
|
||||
data: 0,
|
||||
}));
|
||||
}
|
||||
self.remote_send_state(input);
|
||||
Ok(())
|
||||
}
|
||||
@@ -4010,21 +4036,60 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
// MultiPlayerGameMode.useItem runs the use here too: food starts
|
||||
// being eaten (Consumable.startConsuming).
|
||||
// being eaten (Consumable.startConsuming), a book and quill opens.
|
||||
self.with_hand(hand, |g| g.start_eating(&info));
|
||||
// BowItem.use: startUsingItem here too (with an arrow, or in creative), so that letting
|
||||
// go sends RELEASE_USE_ITEM (Minecraft.handleKeybinds) and the server shoots.
|
||||
if &*held.item == "minecraft:bow" {
|
||||
self.with_hand(hand, |g| {
|
||||
if g.using.is_none() && (g.creative || g.projectile_slot("minecraft:bow").is_some())
|
||||
{
|
||||
g.using = Some((g.held_slot(), 0));
|
||||
}
|
||||
});
|
||||
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
|
||||
return true;
|
||||
}
|
||||
self.with_hand(hand, |g| g.remote_start_held_use(&held));
|
||||
item_has_use(&info)
|
||||
}
|
||||
|
||||
/// `Item.use` runs on the client too (MultiPlayerGameMode.useItem): an
|
||||
/// item held in use until the button lets go starts being used here, so
|
||||
/// that letting go sends RELEASE_USE_ITEM (Minecraft.handleKeybinds) and
|
||||
/// the server ends the use: a bow or a trident is let go, a crossbow's
|
||||
/// charge ends, a shield is lowered, a spyglass stops scoping. Their
|
||||
/// sounds, loaded arrows and thrown tridents come from the server.
|
||||
fn remote_start_held_use(&mut self, held: &ItemStack) {
|
||||
if self.using.is_some() {
|
||||
return;
|
||||
}
|
||||
let start = match &*held.item {
|
||||
// BowItem.use: with an arrow, or in creative.
|
||||
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
|
||||
// CrossbowItem.use: charging when not charged and with ammunition;
|
||||
// a charged one shoots on the server.
|
||||
"minecraft:crossbow" => {
|
||||
held.data().is_none_or(|d| d.charged.is_empty())
|
||||
&& (self.creative || self.projectile_slot("minecraft:crossbow").is_some())
|
||||
}
|
||||
// TridentItem.use: not about to break, Riptide only in water or rain.
|
||||
"minecraft:trident" => {
|
||||
self.use_trident(held);
|
||||
return;
|
||||
}
|
||||
// SpyglassItem.use (its sound is the server's).
|
||||
"minecraft:spyglass" => true,
|
||||
// ShieldItem.use: anything that blocks attacks, off cooldown.
|
||||
_ => {
|
||||
self.raise_shield(self.held_slot());
|
||||
return;
|
||||
}
|
||||
};
|
||||
if start {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
}
|
||||
/// Whether `stack` stays in use until the button lets go (bows,
|
||||
/// crossbows, tridents, spyglasses, shields): its use only counts ticks here.
|
||||
fn held_until_released(&self, stack: &ItemStack) -> bool {
|
||||
matches!(
|
||||
&*stack.item,
|
||||
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" | "minecraft:spyglass"
|
||||
) || self.blocks_attacks(stack).is_some()
|
||||
}
|
||||
|
||||
/// The rest of Minecraft.startUseItem once the main hand's target passed:
|
||||
/// the main hand's item, then the off hand's turn (its block, its item).
|
||||
/// The server does each packet's part with that packet's hand only.
|
||||
@@ -4043,7 +4108,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.remote_use_item(Hand::Off);
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: a drawn bow, and a
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
|
||||
/// use (`held_until_released`), and a
|
||||
/// consumable's eating animation and its crumbs. The server eats it and
|
||||
/// sends the stack, the hunger and the sounds.
|
||||
fn remote_tick_using(&mut self) {
|
||||
@@ -4051,8 +4117,9 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let stack = self.stack_in(slot).cloned();
|
||||
// A drawn bow: held until the button lets go (or another slot is picked).
|
||||
if stack.as_ref().is_some_and(|s| &*s.item == "minecraft:bow") {
|
||||
// A drawn bow, a charging crossbow, a raised shield...: held until
|
||||
// the button lets go (or another slot is picked).
|
||||
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
|
||||
self.using = (slot == self.selected || slot == super::shield::OFFHAND_SLOT)
|
||||
.then_some((slot, ticks + 1));
|
||||
return;
|
||||
|
||||
@@ -88,6 +88,8 @@ impl Game<'_> {
|
||||
self.end
|
||||
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
|
||||
}
|
||||
// FishingHook.recreateFromPacket: the owner's id is the data.
|
||||
"minecraft:fishing_bobber" => self.spawn_remote_hook(id, at, data),
|
||||
"minecraft:end_crystal" => self.crystals.push(super::dragon::Crystal {
|
||||
id,
|
||||
position: at,
|
||||
@@ -114,6 +116,7 @@ impl Game<'_> {
|
||||
/// `handleSetEntityData` for these types: what changes how they look.
|
||||
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let id = id as u32;
|
||||
self.remote_hook_data(id, values);
|
||||
if self.potions.has_thrown(id) {
|
||||
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
|
||||
for (index, _, value) in values {
|
||||
@@ -205,6 +208,7 @@ impl Game<'_> {
|
||||
c.position = step(c.id, c.position);
|
||||
}
|
||||
self.potions.step_remote_thrown(&mut step);
|
||||
self.step_remote_hooks(&mut step);
|
||||
self.end.step_remote_eyes(|id, at| {
|
||||
let at = step(id, Vec3::new(at[0], at[1], at[2]));
|
||||
[at.x, at.y, at.z]
|
||||
@@ -220,6 +224,7 @@ impl Game<'_> {
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.potions.remove_remote_thrown(id);
|
||||
self.end.remove_remote_eye(id);
|
||||
self.remove_remote_hook(id);
|
||||
}
|
||||
|
||||
/// Every one a server sent (a new connection, level or dimension).
|
||||
@@ -232,6 +237,7 @@ impl Game<'_> {
|
||||
self.crystals.clear();
|
||||
self.potions.clear_thrown();
|
||||
self.end.clear_remote_eyes();
|
||||
self.clear_remote_hooks();
|
||||
}
|
||||
|
||||
/// Their kind and place, for `status`.
|
||||
@@ -260,6 +266,7 @@ impl Game<'_> {
|
||||
for (id, p) in self.end.eye_positions() {
|
||||
out.push(json!({"id": id, "kind": "minecraft:eye_of_ender", "position": p}));
|
||||
}
|
||||
out.extend(self.hooks_status());
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
if let Some(r) = self.renderer.as_mut()
|
||||
&& let Err(e) = r.set_shader_pack(pack.as_ref())
|
||||
&& let Err(e) = r.set_shader_pack(pack.as_ref(), &self.pack.render.sprite_layers)
|
||||
{
|
||||
return self.shader_pack_error(&id, &e.to_string());
|
||||
}
|
||||
@@ -79,7 +79,10 @@ impl Game<'_> {
|
||||
/// Give the loaded pack to the renderer (also after it is rebuilt).
|
||||
pub(super) fn apply_shader_pack(&mut self) -> horizon_client::Result<()> {
|
||||
match self.renderer.as_mut() {
|
||||
Some(r) => r.set_shader_pack(self.shader_packs.pack.as_ref()),
|
||||
Some(r) => r.set_shader_pack(
|
||||
self.shader_packs.pack.as_ref(),
|
||||
&self.pack.render.sprite_layers,
|
||||
),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//! The sign editing screen (`SignEditScreen` / `HangingSignEditScreen`):
|
||||
//! opened when a sign is placed, four lines limited to the sign's line
|
||||
//! width, Up/Down/Enter move between lines, Escape or Done closes.
|
||||
use super::Game;
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
signs::wood_type,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
@@ -33,14 +34,95 @@ impl Game<'_> {
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
}
|
||||
/// `AbstractSignEditScreen.removed`: a server's client sends what it
|
||||
/// wrote (ServerboundSignUpdatePacket); the window's own world keeps it
|
||||
/// and sends it to its guests (`sendBlockUpdated`).
|
||||
pub(super) fn close_sign_editor(&mut self) {
|
||||
if let Some(edit) = self.sign_edit.take() {
|
||||
self.world
|
||||
.modified
|
||||
.insert((edit.pos.0 >> 4, edit.pos.2 >> 4));
|
||||
if self.remote.is_some() {
|
||||
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) {
|
||||
let side = if edit.front { &sign.front } else { &sign.back };
|
||||
self.send(S::SignUpdate(play::SignUpdate {
|
||||
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
|
||||
lines: side.lines.clone(),
|
||||
front: edit.front,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
self.mark_block_entity_updated(edit.pos);
|
||||
}
|
||||
}
|
||||
self.capture(true);
|
||||
}
|
||||
/// `ServerPlayer.openTextEdit` for the guest whose action opened a sign:
|
||||
/// the block, then the editor, on its client, and the guest may write on it.
|
||||
pub(super) fn lan_open_sign_editor(&mut self, host: &mut LanHost, conn: u32, edit: SignEditor) {
|
||||
let pos = [edit.pos.0, edit.pos.1, edit.pos.2];
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0 as i32;
|
||||
host.hub
|
||||
.send_to(conn, &C::BlockUpdate(play::BlockUpdate { pos, state }));
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::OpenSignEditor(play::OpenSignEditor {
|
||||
pos,
|
||||
front: edit.front,
|
||||
}),
|
||||
);
|
||||
host.play.entry(conn).or_default().sign_editing = Some(pos);
|
||||
}
|
||||
/// `ServerGamePacketListenerImpl.handleSignUpdate`: the lines, formatting
|
||||
/// codes stripped, go on the sign the guest may write on unless it is
|
||||
/// waxed (`SignBlockEntity.updateSignText`); a guest gone out of reach
|
||||
/// may no longer write (`playerIsTooFarAwayToEdit`).
|
||||
pub(super) fn lan_sign_update(&mut self, host: &mut LanHost, conn: u32, p: play::SignUpdate) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let near = super::lan_stations::within_reach(g, p.pos);
|
||||
let Some(play) = host.play.get_mut(&conn) else {
|
||||
return;
|
||||
};
|
||||
if !near {
|
||||
play.sign_editing = None;
|
||||
}
|
||||
if play.sign_editing != Some(p.pos) {
|
||||
log::warn!("{} just tried to change non-editable sign", g.name);
|
||||
return;
|
||||
}
|
||||
let key = (p.pos[0], p.pos[1], p.pos[2]);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
|
||||
return;
|
||||
};
|
||||
if sign.waxed {
|
||||
return;
|
||||
}
|
||||
let side = if p.front {
|
||||
&mut sign.front
|
||||
} else {
|
||||
&mut sign.back
|
||||
};
|
||||
for (line, new) in side.lines.iter_mut().zip(&p.lines) {
|
||||
*line = horizon_client::clipboard::strip_formatting(new);
|
||||
}
|
||||
play.sign_editing = None;
|
||||
self.mark_block_entity_updated(key);
|
||||
}
|
||||
/// `ClientPacketListener.handleOpenSignEditor`: the editor on a sign this
|
||||
/// client has.
|
||||
pub(super) fn remote_open_sign_editor(&mut self, p: play::OpenSignEditor) {
|
||||
let key = (p.pos[0], p.pos[1], p.pos[2]);
|
||||
if matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Sign(_))
|
||||
) {
|
||||
self.open_sign_editor(key, p.front);
|
||||
} else {
|
||||
log::warn!(
|
||||
"ignoring openTextEdit on a block that is no sign at {:?}",
|
||||
p.pos
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) fn sign_edit_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
|
||||
@@ -10,7 +10,7 @@ pub(super) const HELP: &[&str] = &[
|
||||
"use X Y Z open the block's menu as if right-clicked",
|
||||
"click-slot ID [right] [shift] click a menu slot (0-35 inventory, 300+ container, 600+ station, 650 result)",
|
||||
"click-gui X Y [right] [shift] click at GUI pixel X Y relative to the menu's top-left corner",
|
||||
"book [next|back|take|done] the open book or lectern: its page, or press one of its buttons",
|
||||
"book [next|back|take|sign|done] the open book or lectern: its page, or press one of its buttons",
|
||||
"type TEXT | type-back type into the anvil name box / delete one character",
|
||||
"enchant ID LEVEL enchant the held item (books store the enchantment)",
|
||||
"potion ITEM POTION give a potion item (potion, splash_potion, lingering_potion, tipped_arrow)",
|
||||
|
||||
@@ -22,6 +22,14 @@ const PLAYER_KNOCKBACK: f32 = 1.22;
|
||||
/// `BreezeWindCharge.RADIUS`.
|
||||
const BREEZE_RADIUS: f32 = 3.0;
|
||||
|
||||
/// A burst `wind_burst` puts off until a guest's turn is over.
|
||||
pub(super) struct Burst {
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
multiplier: f32,
|
||||
breeze: bool,
|
||||
wind_charge: bool,
|
||||
}
|
||||
/// `Projectile.deflect` with `ProjectileDeflection.REVERSE`.
|
||||
pub(super) fn reverse(p: &mut Projectile, jitter: f32) {
|
||||
p.velocity = Vec3::new(
|
||||
@@ -196,7 +204,9 @@ impl Game<'_> {
|
||||
set.into_iter().collect()
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` for a calculator that damages nothing:
|
||||
/// knockback scaled by distance, exposure and the multiplier.
|
||||
/// knockback scaled by distance, exposure and the multiplier. Returns the
|
||||
/// guests it pushed and by how much (`hitPlayers`), which their clients
|
||||
/// apply from the burst's packet.
|
||||
fn wind_knockback(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
@@ -204,7 +214,7 @@ impl Game<'_> {
|
||||
multiplier: f32,
|
||||
wind_charge: bool,
|
||||
blocklike: bool,
|
||||
) {
|
||||
) -> Vec<(u32, [f64; 3])> {
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
let distance = |p: [f64; 3]| {
|
||||
((p[0] - center[0]).powi(2) + (p[1] - center[1]).powi(2) + (p[2] - center[2]).powi(2))
|
||||
@@ -223,42 +233,30 @@ impl Game<'_> {
|
||||
[d[0] / l, d[1] / l, d[2] / l]
|
||||
}
|
||||
};
|
||||
let multiplier = f64::from(multiplier);
|
||||
// The player (not a spectator; a flying creative player takes none).
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& !self.spectator()
|
||||
{
|
||||
let feet = [p.position.x, p.position.y, p.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
let flying = self.creative && p.flying;
|
||||
if dist <= 1.0 && !flying {
|
||||
let w = f64::from(p.dimensions.width) / 2.0;
|
||||
let h = f64::from(p.dimensions.height);
|
||||
let eye = [
|
||||
feet[0],
|
||||
feet[1] + f64::from(p.dimensions.eye_height),
|
||||
feet[2],
|
||||
];
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet[0] - w, feet[1], feet[2] - w],
|
||||
[feet[0] + w, feet[1] + h, feet[2] + w],
|
||||
);
|
||||
let power = (1.0 - dist) * f64::from(exposure) * multiplier;
|
||||
let dir = direction(eye);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = Vec3::new(
|
||||
p.velocity.x + dir[0] * power,
|
||||
p.velocity.y + dir[1] * power,
|
||||
p.velocity.z + dir[2] * power,
|
||||
);
|
||||
// ServerPlayer.onExplosionHit: a wind charge's burst cancels the
|
||||
// fall; any other burst ends the grace of the last impulse.
|
||||
let at = p.position;
|
||||
p.impulse.set_ignore_fall_damage(wind_charge, Some(at));
|
||||
// The player, then every other player in the level (LAN guests),
|
||||
// each swapped in as the player. In a guest's turn (a mace's wind
|
||||
// burst) the player is that guest.
|
||||
let own = self.wind_push_player(center, radius, multiplier, wind_charge);
|
||||
let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect();
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
let guests: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in guests {
|
||||
let push = self.as_guest(&mut host, conn, |g| {
|
||||
g.wind_push_player(center, radius, multiplier, wind_charge)
|
||||
});
|
||||
if let Some(Some(push)) = push {
|
||||
pushed.push((conn, push));
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
let multiplier = f64::from(multiplier);
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
for m in mobs.iter_mut().filter(|m| m.alive()) {
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
@@ -351,6 +349,67 @@ impl Game<'_> {
|
||||
push(t.position, &mut t.velocity, self);
|
||||
}
|
||||
self.tnts = tnts;
|
||||
pushed
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` for the player (not a spectator, nor a
|
||||
/// creative player flying): the push, less with explosion knockback
|
||||
/// resistance, and `ServerPlayer.onExplosionHit`. Returns the push.
|
||||
fn wind_push_player(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
multiplier: f32,
|
||||
wind_charge: bool,
|
||||
) -> Option<[f64; 3]> {
|
||||
if self.spectator() {
|
||||
return None;
|
||||
}
|
||||
let resistance = self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:explosion_knockback_resistance").ok())
|
||||
.unwrap_or(0.0);
|
||||
let creative = self.creative;
|
||||
let p = self.player.as_ref()?;
|
||||
let feet = [p.position.x, p.position.y, p.position.z];
|
||||
let dist = ((feet[0] - center[0]).powi(2)
|
||||
+ (feet[1] - center[1]).powi(2)
|
||||
+ (feet[2] - center[2]).powi(2))
|
||||
.sqrt()
|
||||
/ f64::from(radius * 2.0);
|
||||
if dist > 1.0 || creative && p.flying {
|
||||
return None;
|
||||
}
|
||||
let w = f64::from(p.dimensions.width) / 2.0;
|
||||
let h = f64::from(p.dimensions.height);
|
||||
let eye = [
|
||||
feet[0] - center[0],
|
||||
feet[1] + f64::from(p.dimensions.eye_height) - center[1],
|
||||
feet[2] - center[2],
|
||||
];
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet[0] - w, feet[1], feet[2] - w],
|
||||
[feet[0] + w, feet[1] + h, feet[2] + w],
|
||||
);
|
||||
let power = (1.0 - dist) * f64::from(exposure) * f64::from(multiplier) * (1.0 - resistance);
|
||||
let l = (eye[0] * eye[0] + eye[1] * eye[1] + eye[2] * eye[2]).sqrt();
|
||||
let push = if l < 1.0e-5 {
|
||||
[0.0; 3]
|
||||
} else {
|
||||
eye.map(|d| d / l * power)
|
||||
};
|
||||
let p = self.player.as_mut()?;
|
||||
p.velocity = Vec3::new(
|
||||
p.velocity.x + push[0],
|
||||
p.velocity.y + push[1],
|
||||
p.velocity.z + push[2],
|
||||
);
|
||||
// ServerPlayer.onExplosionHit: a wind charge's burst cancels the
|
||||
// fall; any other burst ends the grace of the last impulse.
|
||||
let at = p.position;
|
||||
p.impulse.set_ignore_fall_damage(wind_charge, Some(at));
|
||||
Some(push)
|
||||
}
|
||||
/// The burst: knockback, the blocks that react, the gust and the sound.
|
||||
pub(super) fn wind_burst(
|
||||
@@ -361,13 +420,27 @@ impl Game<'_> {
|
||||
breeze: bool,
|
||||
wind_charge: bool,
|
||||
) -> Result<()> {
|
||||
// In a guest's turn (their charge landing, their mace's burst) the
|
||||
// host holds the other players (`as_guest`): the burst waits for the
|
||||
// turn to end so that it reaches every player, the guest too.
|
||||
if self.lan_acting.is_some() && self.lan.is_none() {
|
||||
self.creatures.wind_bursts.push(Burst {
|
||||
center,
|
||||
radius,
|
||||
multiplier,
|
||||
breeze,
|
||||
wind_charge,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
// ServerExplosion.explode: the blast is heard first.
|
||||
let ctx = horizon_client::sculk::Context::default();
|
||||
self.post_game_event("minecraft:explode", center, ctx);
|
||||
let mut targets = self.wind_positions(center, radius);
|
||||
// ServerExplosion.shouldAffectBlocklikeEntities
|
||||
let blocklike = !breeze && !wind_charge && self.ui.rules.bool("mob_griefing");
|
||||
self.wind_knockback(center, radius, multiplier, wind_charge, blocklike);
|
||||
let pushed = self.wind_knockback(center, radius, multiplier, wind_charge, blocklike);
|
||||
let block_count = targets.len();
|
||||
// Explosion.canTriggerBlocks: a breeze's charge needs mob griefing.
|
||||
if !breeze || self.ui.rules.bool("mob_griefing") {
|
||||
// Util.shuffle(list, level.random)
|
||||
@@ -396,6 +469,14 @@ impl Game<'_> {
|
||||
};
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ty), center, [1.0, 0.0, 0.0]);
|
||||
self.lan_wind_burst(center, radius, sound, block_count, &pushed);
|
||||
Ok(())
|
||||
}
|
||||
/// The bursts guests' turns put off.
|
||||
pub(super) fn flush_wind_bursts(&mut self) -> Result<()> {
|
||||
for b in std::mem::take(&mut self.creatures.wind_bursts) {
|
||||
self.wind_burst(b.center, b.radius, b.multiplier, b.breeze, b.wind_charge)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `BlockBehaviour.onExplosionHit` for `TRIGGER_BLOCK`: doors, gates,
|
||||
|
||||
@@ -35,7 +35,7 @@ fn entity_shader() -> String {
|
||||
mod entity_batch;
|
||||
mod weather;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::WATER_MASK_OVERLAY;
|
||||
pub use entity_batch::{SHADOW_OVERLAY, WATER_MASK_OVERLAY};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
pub fn outline_width(width: u32) -> f32 {
|
||||
@@ -65,7 +65,8 @@ mod arena;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
mod java_stages;
|
||||
pub(crate) mod java_stages;
|
||||
pub(crate) mod java_terrain;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod occlusion;
|
||||
@@ -158,6 +159,18 @@ struct Globals {
|
||||
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
|
||||
}
|
||||
pub type SectionKey = (i32, i32, i32);
|
||||
/// The sections a Java pack's shadow map draws ([`Renderer`]'s `shadow`).
|
||||
#[derive(Default)]
|
||||
struct ShadowList {
|
||||
visible: Vec<resident::Visible>,
|
||||
draws: Vec<[u32; 5]>,
|
||||
pages: Vec<(u32, Option<u32>)>,
|
||||
/// How many of `draws` are solid.
|
||||
solid: u32,
|
||||
indirect: Option<wgpu::Buffer>,
|
||||
capacity: u64,
|
||||
}
|
||||
|
||||
struct SectionGpu {
|
||||
/// Arena page and first vertex in it: solid quads, then translucent ones.
|
||||
page: u32,
|
||||
@@ -375,6 +388,7 @@ pub struct Renderer {
|
||||
entity_pipeline: wgpu::RenderPipeline,
|
||||
weather: weather::Weather,
|
||||
crumbling_pipeline: wgpu::RenderPipeline,
|
||||
shadow_blob_pipeline: wgpu::RenderPipeline,
|
||||
/// `RenderPipelines.WATER_MASK`: depth writes only, no colour.
|
||||
water_mask_pipeline: wgpu::RenderPipeline,
|
||||
cloud_pipeline: wgpu::RenderPipeline,
|
||||
@@ -478,6 +492,10 @@ pub struct Renderer {
|
||||
/// built, and their pages.
|
||||
phase2_draws: Vec<[u32; 5]>,
|
||||
phase2_pages: Vec<(u32, Option<u32>)>,
|
||||
/// A Java pack's shadow map (`gfx/java_terrain.rs`): the sections in
|
||||
/// the light's view, their draws (solid then translucent, instances
|
||||
/// after the camera's), and the indirect buffer they're uploaded to.
|
||||
shadow: ShadowList,
|
||||
/// Each section's solid draws, to group them by arena page.
|
||||
solid_runs: page_runs::PageRuns,
|
||||
phase2_runs: page_runs::PageRuns,
|
||||
@@ -491,6 +509,15 @@ pub struct Renderer {
|
||||
world_post: world_post::Post,
|
||||
/// The selected shader pack's stages (Horizon setting; none by default).
|
||||
pack: Option<Pack>,
|
||||
/// A Java pack's terrain programs (`gfx/java_terrain.rs`): their page
|
||||
/// groups' layout, the glue's table, each arena page's group.
|
||||
java_page_layout: wgpu::BindGroupLayout,
|
||||
/// The device renders to and filters 16-bit normalized formats.
|
||||
java_norm16: bool,
|
||||
java_table: Option<wgpu::Buffer>,
|
||||
java_pages: Vec<Option<(wgpu::Buffer, wgpu::BindGroup)>>,
|
||||
/// The sprite layers and mip levels `animate` wrote this frame.
|
||||
animated: Vec<(u32, u32)>,
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
@@ -839,7 +866,10 @@ async fn open_device(
|
||||
| cull::Cull::features(&adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS));
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
|
||||
// Java shader packs' 16-bit colour targets.
|
||||
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES));
|
||||
let driver = format!("{} {}", info.driver, info.driver_info);
|
||||
startup_step(format_args!(
|
||||
"requesting the device (driver {}) with features [{features}] and the adapter's \
|
||||
@@ -1369,8 +1399,10 @@ impl Renderer {
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
// COPY_SRC: animated sprites into a Java pack's atlas.
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::STORAGE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
@@ -1815,6 +1847,26 @@ impl Renderer {
|
||||
CRUMBLING_BLEND,
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.ENTITY_SHADOW: translucent, depth test without
|
||||
// writes. The quads lie on the block tops like vanilla's, but the
|
||||
// terrain under them snaps to the subpixel grid at other vertices, so
|
||||
// at a slant their depths differ by up to 1/256 of a pixel's depth
|
||||
// slope and whole shadows failed the test. The bias covers that and
|
||||
// the rounding, and stays below the 0.001 a model's feet sit higher
|
||||
// (1/27 of a pixel's slope at the 16-block shadow range in 720p).
|
||||
let shadow_blob_pipeline = entity_pipeline(
|
||||
"Entity shadows",
|
||||
None,
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
wgpu::DepthBiasState {
|
||||
constant: 16,
|
||||
slope_scale: 0.015,
|
||||
clamp: 0.0,
|
||||
},
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let cloud_pipeline = entity_pipeline(
|
||||
"Clouds",
|
||||
Some(wgpu::Face::Back),
|
||||
@@ -2214,6 +2266,8 @@ impl Renderer {
|
||||
std::mem::size_of::<TerrainVertex>() as u64,
|
||||
1 << 20,
|
||||
);
|
||||
let java_page_layout = java_terrain::page_layout(&device);
|
||||
let java_norm16 = java_terrain::norm16_targets(&adapter, &device);
|
||||
// Sections as one index list per frame where multi-draw is emulated.
|
||||
let flat = flat::wanted(&adapter, &device).then(|| {
|
||||
let buffers = [Some(wgpu::VertexBufferLayout {
|
||||
@@ -2332,6 +2386,7 @@ impl Renderer {
|
||||
entity_pipeline,
|
||||
weather,
|
||||
crumbling_pipeline,
|
||||
shadow_blob_pipeline,
|
||||
water_mask_pipeline,
|
||||
cloud_pipeline,
|
||||
cloud_mesh: Default::default(),
|
||||
@@ -2407,6 +2462,7 @@ impl Renderer {
|
||||
draw_pages: Vec::new(),
|
||||
phase2_draws: Vec::new(),
|
||||
phase2_pages: Vec::new(),
|
||||
shadow: ShadowList::default(),
|
||||
solid_runs: page_runs::PageRuns::default(),
|
||||
phase2_runs: page_runs::PageRuns::default(),
|
||||
block_entities: HashMap::new(),
|
||||
@@ -2414,6 +2470,11 @@ impl Renderer {
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
world_post,
|
||||
pack: None,
|
||||
java_page_layout,
|
||||
java_norm16,
|
||||
java_table: None,
|
||||
java_pages: Vec::new(),
|
||||
animated: Vec::new(),
|
||||
hud,
|
||||
menu_blur: None,
|
||||
info,
|
||||
@@ -2722,28 +2783,177 @@ impl Renderer {
|
||||
base: u32,
|
||||
first: u32,
|
||||
count: u32,
|
||||
) {
|
||||
self.draw_sections_with(pass, indirect, base, first, count, |_, _| {});
|
||||
}
|
||||
/// [`Self::draw_sections`]; `page` binds what else a page's draws need.
|
||||
fn draw_sections_with(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
indirect: &wgpu::Buffer,
|
||||
base: u32,
|
||||
first: u32,
|
||||
count: u32,
|
||||
page_group: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
) {
|
||||
let list = (&self.draws[..], &self.draw_pages[..]);
|
||||
self.draw_list(pass, indirect, list, base, first, count, page_group);
|
||||
}
|
||||
/// [`Self::draw_sections_with`] over another list of draws and pages.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_list(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
indirect: &wgpu::Buffer,
|
||||
(draws, pages): (&[[u32; 5]], &[(u32, Option<u32>)]),
|
||||
base: u32,
|
||||
first: u32,
|
||||
count: u32,
|
||||
page_group: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
) {
|
||||
let (mut i, end) = (first as usize, (first + count) as usize);
|
||||
while i < end {
|
||||
let (page, index_page) = self.draw_pages[i];
|
||||
let run = self.draw_pages[i..end]
|
||||
let (page, index_page) = pages[i];
|
||||
let run = pages[i..end]
|
||||
.iter()
|
||||
.take_while(|&&p| p == (page, index_page))
|
||||
.count();
|
||||
pass.set_vertex_buffer(0, self.arena.buffer(page).slice(..));
|
||||
page_group(pass, page);
|
||||
let indices = index_page.map_or(&self.indices, |p| self.sorted_indices.buffer(p));
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if self.multi_draw {
|
||||
let offset = (i - base as usize) as u64 * 20;
|
||||
pass.multi_draw_indexed_indirect(indirect, offset, run as u32);
|
||||
} else {
|
||||
for d in &self.draws[i..i + run] {
|
||||
for d in &draws[i..i + run] {
|
||||
pass.draw_indexed(d[2]..d[2] + d[0], d[3] as i32, d[4]..d[4] + 1);
|
||||
}
|
||||
}
|
||||
i += run;
|
||||
}
|
||||
}
|
||||
/// One `layer` of Horizon's terrain with a Java pack's geometry
|
||||
/// pipelines (`java_terrain::DrawTerrain`): the frame's flat draws or its
|
||||
/// section draws, each page with its group 3. `draws` are the frame's
|
||||
/// phase 1 solid draws, all its solid draws and its translucent ones;
|
||||
/// `culling` tells whether phase 2's come from occlusion culling.
|
||||
fn draw_java_terrain(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
layer: java_terrain::Layer,
|
||||
pipelines: &java_terrain::TerrainPipelines,
|
||||
flat_frame: bool,
|
||||
draws: [u32; 3],
|
||||
culling: bool,
|
||||
) {
|
||||
use java_terrain::Layer;
|
||||
let page = |pass: &mut wgpu::RenderPass<'_>, page: u32| {
|
||||
if let Some(Some((_, group))) = self.java_pages.get(page as usize) {
|
||||
pass.set_bind_group(3, group, &[]);
|
||||
}
|
||||
};
|
||||
if matches!(layer, Layer::ShadowSolid | Layer::ShadowTranslucent) {
|
||||
let shadow = &self.shadow;
|
||||
let all = shadow.draws.len() as u32;
|
||||
let (first, count) = if layer == Layer::ShadowSolid {
|
||||
(0, shadow.solid)
|
||||
} else {
|
||||
(shadow.solid, all - shadow.solid)
|
||||
};
|
||||
// Without multi-draw the draws are read from the list.
|
||||
let indirect = shadow.indirect.as_ref().unwrap_or(&self.indirect);
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
pass.set_bind_group(2, pipelines.group, &[]);
|
||||
pass.set_pipeline(pipelines.instanced);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
let list = (&shadow.draws[..], &shadow.pages[..]);
|
||||
self.draw_list(pass, indirect, list, 0, first, count, page);
|
||||
return;
|
||||
}
|
||||
pass.set_bind_group(2, pipelines.group, &[]);
|
||||
match (self.flat.as_ref().filter(|_| flat_frame), pipelines.flat) {
|
||||
// Without occlusion culling, phase 1 is every solid call.
|
||||
(Some(flat), Some(pipeline)) => {
|
||||
let vertices = |p| self.arena.buffer(p);
|
||||
pass.set_pipeline(pipeline);
|
||||
match layer {
|
||||
Layer::Solid => {
|
||||
flat.draw(pass, &flat.calls[..flat.phase1_calls], vertices, page)
|
||||
}
|
||||
Layer::Revealed if culling => flat.draw_phase2(pass, vertices, page),
|
||||
Layer::Revealed => {}
|
||||
Layer::Translucent => {
|
||||
flat.draw(pass, &flat.calls[flat.solid_calls..], vertices, page)
|
||||
}
|
||||
// Drawn from the shadow list, above.
|
||||
Layer::ShadowSolid | Layer::ShadowTranslucent => {}
|
||||
}
|
||||
}
|
||||
(Some(_), None) => {}
|
||||
(None, _) => {
|
||||
let [phase1, solid, translucent_draws] = draws;
|
||||
let cull = self.cull.as_ref().filter(|_| culling);
|
||||
let (indirect, base, first, count) = match (layer, cull) {
|
||||
(Layer::Solid, Some(_)) => (&self.indirect, 0, 0, phase1),
|
||||
(Layer::Solid, None) => (&self.indirect, 0, 0, solid),
|
||||
(Layer::Revealed, Some(c)) => (&c.phase2, phase1, phase1, solid - phase1),
|
||||
(Layer::Revealed, None) => return,
|
||||
(Layer::Translucent, _) => (&self.indirect, 0, solid, translucent_draws),
|
||||
(Layer::ShadowSolid | Layer::ShadowTranslucent, _) => return,
|
||||
};
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(pipelines.instanced);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
self.draw_sections_with(pass, indirect, base, first, count, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Each arena page's group for a Java pack's terrain programs: its
|
||||
/// vertices and the glue's table.
|
||||
fn prepare_java_pages(&mut self) {
|
||||
let Some(table) = &self.java_table else {
|
||||
return;
|
||||
};
|
||||
let count = self.arena.page_count() as usize;
|
||||
self.java_pages.resize_with(count, || None);
|
||||
let limit = self.device.limits().max_storage_buffer_binding_size;
|
||||
for page in 0..count {
|
||||
if !self.arena.has_page(page as u32) {
|
||||
self.java_pages[page] = None;
|
||||
continue;
|
||||
}
|
||||
let buffer = self.arena.buffer(page as u32);
|
||||
if matches!(&self.java_pages[page], Some((b, _)) if b == buffer) {
|
||||
continue;
|
||||
}
|
||||
let size = buffer.size().min(limit / 4 * 4);
|
||||
let group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Java shader pack terrain page"),
|
||||
layout: &self.java_page_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer,
|
||||
offset: 0,
|
||||
size: std::num::NonZeroU64::new(size),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: table.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.java_pages[page] = Some((buffer.clone(), group));
|
||||
}
|
||||
}
|
||||
fn release_section(&mut self, key: SectionKey) {
|
||||
if let Some(s) = self.sections.remove(key) {
|
||||
self.release_mesh(s);
|
||||
@@ -2827,9 +3037,29 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
/// Run `pack`'s stages over the world from now on (`None`: no pack, the
|
||||
/// vanilla frame). On error the previous pack stays.
|
||||
pub fn set_shader_pack(&mut self, pack: Option<&crate::shader_pack::ShaderPack>) -> Result<()> {
|
||||
/// vanilla frame). On error the previous pack stays. `sprite_layers`
|
||||
/// names the block sprites, for a Java pack's `block.properties`.
|
||||
pub fn set_shader_pack(
|
||||
&mut self,
|
||||
pack: Option<&crate::shader_pack::ShaderPack>,
|
||||
sprite_layers: &std::collections::BTreeMap<String, u32>,
|
||||
) -> Result<()> {
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
let layouts = java_stages::GeometryLayouts {
|
||||
flat: self.flat.as_ref().map(|f| f.draw_layout()),
|
||||
page: &self.java_page_layout,
|
||||
};
|
||||
let table = pack.and_then(|p| p.java.as_ref()).map(|java| {
|
||||
let table = java_terrain::table(sprite_layers, &java.block_ids);
|
||||
wgpu::util::DeviceExt::create_buffer_init(
|
||||
&self.device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Java shader pack terrain table"),
|
||||
contents: bytemuck::cast_slice(&table),
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
},
|
||||
)
|
||||
});
|
||||
self.pack = match pack {
|
||||
Some(pack) => Some(match &pack.java {
|
||||
Some(java) => Pack::Java(java_stages::JavaStages::new(
|
||||
@@ -2838,6 +3068,8 @@ impl Renderer {
|
||||
format,
|
||||
&pack.id,
|
||||
java,
|
||||
&layouts,
|
||||
self.java_norm16,
|
||||
)?),
|
||||
None => Pack::Horizon(pack_stages::PackStages::new(
|
||||
&self.device,
|
||||
@@ -2848,6 +3080,8 @@ impl Renderer {
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
self.java_table = table;
|
||||
self.java_pages.clear();
|
||||
Ok(())
|
||||
}
|
||||
/// The folder name of the pack `set_shader_pack` gave.
|
||||
@@ -2988,6 +3222,15 @@ impl Renderer {
|
||||
}
|
||||
fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64) {
|
||||
self.animations.animate(encoder, &mut self.uploads, tick);
|
||||
// A Java pack's atlas copies what changed, every level.
|
||||
let levels = self.mip_levels;
|
||||
self.animated.clear();
|
||||
self.animated.extend(
|
||||
self.animations
|
||||
.changed
|
||||
.iter()
|
||||
.flat_map(|&layer| (0..levels).map(move |level| (layer, level))),
|
||||
);
|
||||
}
|
||||
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
|
||||
let rendered = self.draw_frame(frame);
|
||||
@@ -3238,8 +3481,59 @@ impl Renderer {
|
||||
visible_lods.sort_unstable_by_key(|v| (v.distance, v.key));
|
||||
}
|
||||
self.drawn_sections = visible.len() + visible_lods.len();
|
||||
if self.drawn_sections as u64 > self.instance_capacity {
|
||||
self.instance_capacity = (self.drawn_sections as u64).next_power_of_two();
|
||||
// A Java pack's shadow map: the sections in the light's orthographic
|
||||
// box, whatever the camera sees (Iris turns cave culling off too).
|
||||
let mut shadow_visible = std::mem::take(&mut self.shadow.visible);
|
||||
shadow_visible.clear();
|
||||
let shadow_view = match &self.pack {
|
||||
Some(Pack::Java(j)) if !frame.title && self.java_table.is_some() => j.shadow_view(
|
||||
frame.shader.dimension,
|
||||
&java_stages::JavaFrame {
|
||||
eye_light: frame.shader.eye_light,
|
||||
sun_angle: frame.sky.sun_angle,
|
||||
moon_angle: frame.sky.moon_angle,
|
||||
},
|
||||
camera.as_vec3(),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((light_view, [half, near, far])) = shadow_view {
|
||||
// Inside when `plane · (p, 1) >= 0`: |x|, |y| <= half and the
|
||||
// eye depth between the near and far planes (GL looks down -z).
|
||||
let (x, y, z) = (light_view.row(0), light_view.row(1), light_view.row(2));
|
||||
let planes = [
|
||||
Vec4::W * half - x,
|
||||
Vec4::W * half + x,
|
||||
Vec4::W * half - y,
|
||||
Vec4::W * half + y,
|
||||
-z - Vec4::W * near,
|
||||
z + Vec4::W * far,
|
||||
];
|
||||
// Toward the light: the light's view looks down its -z.
|
||||
let light = z.truncate().normalize_or_zero();
|
||||
let casts = |k: SectionKey| {
|
||||
let min = DVec3::new(
|
||||
f64::from(k.0 * 16),
|
||||
f64::from(crate::chunk::MIN_Y + k.1 * 16),
|
||||
f64::from(k.2 * 16),
|
||||
) - camera;
|
||||
let min = min.as_vec3();
|
||||
view.casts_into(min, min + Vec3::splat(16.0), light)
|
||||
};
|
||||
crate::spans::time("shadow visibility", || {
|
||||
self.sections.visible(
|
||||
camera,
|
||||
(frame.detail_distance / 16.0) as i32,
|
||||
&Frustum { planes },
|
||||
casts,
|
||||
|_, _| false,
|
||||
&mut shadow_visible,
|
||||
)
|
||||
});
|
||||
}
|
||||
let instances = self.drawn_sections + shadow_visible.len();
|
||||
if instances as u64 > self.instance_capacity {
|
||||
self.instance_capacity = (instances as u64).next_power_of_two();
|
||||
self.instances = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Section origins"),
|
||||
size: self.instance_capacity * 16,
|
||||
@@ -3254,8 +3548,63 @@ impl Renderer {
|
||||
visible
|
||||
.iter()
|
||||
.map(|v| section_instance(v.origin))
|
||||
.chain(visible_lods.iter().map(|v| lod::instance(v.origin))),
|
||||
.chain(visible_lods.iter().map(|v| lod::instance(v.origin)))
|
||||
.chain(shadow_visible.iter().map(|v| section_instance(v.origin))),
|
||||
);
|
||||
// The shadow map's draws: every solid quad (no face is culled), then
|
||||
// the translucent ones in the order the camera's sort left them.
|
||||
let shadow = &mut self.shadow;
|
||||
shadow.draws.clear();
|
||||
shadow.pages.clear();
|
||||
for pass in [false, true] {
|
||||
for (j, v) in shadow_visible.iter().enumerate() {
|
||||
let s = self.sections.get(v.slot);
|
||||
let instance = (self.drawn_sections + j) as u32;
|
||||
match (pass, s.sorted_indices) {
|
||||
(false, _) if s.solid > 0 => {
|
||||
shadow
|
||||
.draws
|
||||
.push([s.solid * 6, 1, 0, s.start as u32, instance]);
|
||||
shadow.pages.push((s.page, None));
|
||||
}
|
||||
(true, Some((index_page, index_start))) if s.translucent > 0 => {
|
||||
let first = s.start + u64::from(s.solid) * 4;
|
||||
shadow.draws.push([
|
||||
s.translucent * 6,
|
||||
1,
|
||||
index_start as u32,
|
||||
first as u32,
|
||||
instance,
|
||||
]);
|
||||
shadow.pages.push((s.page, Some(index_page)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !pass {
|
||||
shadow.solid = shadow.draws.len() as u32;
|
||||
}
|
||||
}
|
||||
if self.multi_draw && !shadow.draws.is_empty() {
|
||||
if shadow.draws.len() as u64 > shadow.capacity || shadow.indirect.is_none() {
|
||||
shadow.capacity = (shadow.draws.len() as u64).next_power_of_two();
|
||||
shadow.indirect = Some(self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Java shader pack shadow draws"),
|
||||
size: shadow.capacity * 20,
|
||||
usage: wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}));
|
||||
}
|
||||
if let Some(indirect) = &shadow.indirect {
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
indirect,
|
||||
0,
|
||||
bytemuck::cast_slice(&shadow.draws),
|
||||
);
|
||||
}
|
||||
}
|
||||
shadow.visible = shadow_visible;
|
||||
if !self.origins.is_empty() {
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
@@ -3272,6 +3621,12 @@ impl Renderer {
|
||||
let eye = (frame.bob * crate::camera::view_matrix(frame.yaw, frame.pitch))
|
||||
.inverse()
|
||||
.transform_point3(Vec3::ZERO);
|
||||
// A Java pack that draws the terrain (`gfx/java_terrain.rs`): the
|
||||
// world pass leaves it out, and occlusion culling tests against the
|
||||
// pack's depth (`Cull::test_gl`).
|
||||
let java_terrain = !frame.title
|
||||
&& self.java_table.is_some()
|
||||
&& matches!(&self.pack, Some(Pack::Java(j)) if j.draws_terrain(frame.shader.dimension));
|
||||
// Occlusion culling leaves the sections its last result did not see
|
||||
// to phase 2, after the others, with instance counts of 0. Whether
|
||||
// the frame has draws enough goes by the last one's.
|
||||
@@ -3510,6 +3865,7 @@ impl Renderer {
|
||||
crumbling_quads,
|
||||
translucent_crumbling_quads,
|
||||
water_mask_quads,
|
||||
shadow_quads,
|
||||
] = self.entity_batch.update(
|
||||
frame.entities,
|
||||
frame.additive,
|
||||
@@ -3517,8 +3873,11 @@ impl Renderer {
|
||||
frame.see_through,
|
||||
);
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads =
|
||||
entity_quads + crumbling_quads + translucent_crumbling_quads + water_mask_quads;
|
||||
let world_quads = entity_quads
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads
|
||||
+ shadow_quads;
|
||||
let cloud_quads = if let Some(input) = &frame.clouds {
|
||||
let (rebuilt, uniform) =
|
||||
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
|
||||
@@ -3729,6 +4088,14 @@ impl Renderer {
|
||||
bytemuck::cast_slice(&self.draws),
|
||||
);
|
||||
}
|
||||
if java_terrain {
|
||||
self.prepare_java_pages();
|
||||
if let Some(table) = &self.java_table {
|
||||
let nether = i32::from(frame.shader.dimension == -1);
|
||||
self.queue
|
||||
.write_buffer(table, 4, bytemuck::bytes_of(&nether));
|
||||
}
|
||||
}
|
||||
let view_target = target_texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(self.config.format.remove_srgb_suffix()),
|
||||
..Default::default()
|
||||
@@ -3890,6 +4257,7 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
match self.flat.as_ref().filter(|_| flat_frame) {
|
||||
_ if java_terrain => {}
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(&flat.solid);
|
||||
let vertices = |p| self.arena.buffer(p);
|
||||
@@ -3897,7 +4265,12 @@ impl Renderer {
|
||||
// As below: phase 1, the test, then phase 2's draws
|
||||
// that the test saw, copied after it.
|
||||
Some(cull) => {
|
||||
flat.draw(&mut pass, &flat.calls[..flat.phase1_calls], vertices);
|
||||
flat.draw(
|
||||
&mut pass,
|
||||
&flat.calls[..flat.phase1_calls],
|
||||
vertices,
|
||||
|_, _| {},
|
||||
);
|
||||
cull.test(&mut pass);
|
||||
drop(pass);
|
||||
cull.test_pass(&mut encoder);
|
||||
@@ -3910,9 +4283,14 @@ impl Renderer {
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_pipeline(&flat.solid);
|
||||
flat.draw_phase2(&mut pass, vertices);
|
||||
flat.draw_phase2(&mut pass, vertices, |_, _| {});
|
||||
}
|
||||
None => flat.draw(&mut pass, &flat.calls[..flat.solid_calls], vertices),
|
||||
None => flat.draw(
|
||||
&mut pass,
|
||||
&flat.calls[..flat.solid_calls],
|
||||
vertices,
|
||||
|_, _| {},
|
||||
),
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -3972,6 +4350,17 @@ impl Renderer {
|
||||
pass.draw_indexed(0..crumbling_quads * 6, (entity_quads * 4) as i32, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if shadow_quads > 0 {
|
||||
pass.set_pipeline(&self.shadow_blob_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..shadow_quads * 6,
|
||||
((world_quads - shadow_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// BoatRenderer's water patch (RenderTypes.waterMask): depth only,
|
||||
// before translucent terrain, so no water shows inside the boat.
|
||||
if water_mask_quads > 0 {
|
||||
@@ -4041,11 +4430,15 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
match self.flat.as_ref().filter(|_| flat_frame) {
|
||||
_ if java_terrain => {}
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(&flat.translucent);
|
||||
flat.draw(&mut pass, &flat.calls[flat.solid_calls..], |p| {
|
||||
self.arena.buffer(p)
|
||||
});
|
||||
flat.draw(
|
||||
&mut pass,
|
||||
&flat.calls[flat.solid_calls..],
|
||||
|p| self.arena.buffer(p),
|
||||
|_, _| {},
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
@@ -4120,10 +4513,39 @@ impl Renderer {
|
||||
frame.shader.rain,
|
||||
);
|
||||
}
|
||||
if let (true, Some((color, depth)), Some(size), Some(stages)) =
|
||||
(pack, &scaled, self.world_post.size(), self.pack.as_mut())
|
||||
if pack
|
||||
&& let (Some((color, depth)), Some(size)) = (&scaled, self.world_post.size())
|
||||
&& let Some(mut stages) = self.pack.take()
|
||||
{
|
||||
let output = match stages {
|
||||
// The pack's terrain programs draw with the frame's draws.
|
||||
let draws = [phase1_draws, solid_draws, translucent_draws];
|
||||
let draw = |render: &mut wgpu::RenderPass<'_>,
|
||||
layer: java_terrain::Layer,
|
||||
pipelines: &java_terrain::TerrainPipelines| {
|
||||
self.draw_java_terrain(render, layer, pipelines, flat_frame, draws, culling);
|
||||
};
|
||||
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
|
||||
let occlusion = |encoder: &mut wgpu::CommandEncoder, depth: &wgpu::TextureView| {
|
||||
if let Some(cull) = cull {
|
||||
cull.test_gl(&self.device, encoder, depth);
|
||||
// The flat path's phase 2: the calls the test saw.
|
||||
if let Some(flat) = self.flat.as_ref().filter(|_| flat_frame) {
|
||||
flat.flatten_phase2(encoder);
|
||||
}
|
||||
}
|
||||
};
|
||||
let world = java_terrain::JavaWorld {
|
||||
sprites: &self.sprite_textures,
|
||||
split: sprite_split(),
|
||||
levels: self.mipmaps,
|
||||
animated: &self.animated,
|
||||
lightmap: &self.lightmap_view,
|
||||
draw: &draw,
|
||||
occlusion: cull
|
||||
.is_some()
|
||||
.then_some(&occlusion as &java_terrain::OcclusionTest),
|
||||
};
|
||||
let output = match &mut stages {
|
||||
Pack::Horizon(stages) => stages.run(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
@@ -4149,10 +4571,12 @@ impl Renderer {
|
||||
sun_angle: frame.sky.sun_angle,
|
||||
moon_angle: frame.sky.moon_angle,
|
||||
},
|
||||
java_terrain.then_some(&world),
|
||||
),
|
||||
};
|
||||
self.world_post
|
||||
.finish_with(&mut encoder, &view_target, output);
|
||||
self.pack = Some(stages);
|
||||
} else if scaled.is_some() {
|
||||
self.world_post.finish(&mut encoder, &view_target);
|
||||
}
|
||||
@@ -4463,6 +4887,22 @@ impl Frustum {
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
/// Whether the camera-relative box `min..max`, lit from the direction
|
||||
/// `light` (toward the light), can cast a shadow inside: its shadow is
|
||||
/// the box swept away from the light, which stays outside a plane the
|
||||
/// box is outside of unless the sweep crosses it (Iris's advanced
|
||||
/// shadow culling, conservatively).
|
||||
pub fn casts_into(&self, min: Vec3, max: Vec3, light: Vec3) -> bool {
|
||||
!self.planes.iter().any(|p| {
|
||||
let v = Vec3::new(
|
||||
if p.x >= 0.0 { max.x } else { min.x },
|
||||
if p.y >= 0.0 { max.y } else { min.y },
|
||||
if p.z >= 0.0 { max.z } else { min.z },
|
||||
);
|
||||
p.truncate().dot(v) + p.w < 0.0 && p.truncate().dot(light) >= 0.0
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the camera-relative box `min..max` is entirely inside. Then
|
||||
/// `intersects` accepts every box within it: each plane's distance only
|
||||
/// grows from this box's nearest corner to the inner box's farthest, and
|
||||
@@ -4569,6 +5009,31 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn shadow_casters_are_the_boxes_whose_shadow_can_reach_the_view() {
|
||||
use glam::Vec3;
|
||||
// Looking north (-z) from the origin, level.
|
||||
let m = super::world_projection(70.0, 16.0 / 9.0, 256.0)
|
||||
* crate::camera::view_matrix(180.0, 0.0);
|
||||
let view = super::Frustum {
|
||||
planes: super::frustum(m),
|
||||
};
|
||||
let cube =
|
||||
|x: f32, y: f32, z: f32| (Vec3::new(x, y, z), Vec3::new(x + 16.0, y + 16.0, z + 16.0));
|
||||
let noon = Vec3::Y;
|
||||
// In view: casts.
|
||||
let (min, max) = cube(-8.0, -8.0, -64.0);
|
||||
assert!(view.intersects(min, max) && view.casts_into(min, max, noon));
|
||||
// Far above the view, the sun overhead: its shadow falls into it.
|
||||
let (min, max) = cube(-8.0, 200.0, -64.0);
|
||||
assert!(!view.intersects(min, max) && view.casts_into(min, max, noon));
|
||||
// Behind the camera: its shadow falls straight down, behind it too.
|
||||
let (min, max) = cube(-8.0, -8.0, 32.0);
|
||||
assert!(!view.casts_into(min, max, noon));
|
||||
// The same box with a low sun behind the camera (south): it does.
|
||||
assert!(view.casts_into(min, max, Vec3::new(0.0, 0.2, 1.0).normalize()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_is_8_bit_and_not_srgb() {
|
||||
use super::surface_format;
|
||||
@@ -4625,14 +5090,14 @@ mod tests {
|
||||
normal: [0; 4],
|
||||
}; 4]
|
||||
};
|
||||
let entities: Vec<_> = [(0, 0), (4, 1), (11, 2), (7, 3), (4, 4), (12, 7)]
|
||||
let entities: Vec<_> = [(0, 0), (14, 9), (4, 1), (11, 2), (7, 3), (4, 4), (12, 7)]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, id)| quad(layer, id))
|
||||
.collect();
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 0]);
|
||||
assert_eq!(counts, [4, 1, 1, 0, 1]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -4640,7 +5105,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|q| q[0].light)
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1, 3, 4, 2, 7, 5, 6, 8]
|
||||
[0, 1, 3, 4, 2, 7, 9, 5, 6, 8]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -51,6 +51,9 @@ pub(super) struct SpriteAnimator {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
/// Edge of the arrays' layers.
|
||||
size: u32,
|
||||
/// The sprite layers (across the arrays) the last `animate` rewrote, at
|
||||
/// every mip level: a Java pack's atlas copies them.
|
||||
pub changed: Vec<u32>,
|
||||
}
|
||||
|
||||
impl SpriteAnimator {
|
||||
@@ -205,6 +208,7 @@ impl SpriteAnimator {
|
||||
jobs,
|
||||
pipeline,
|
||||
size,
|
||||
changed: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +220,7 @@ impl SpriteAnimator {
|
||||
uploads: &mut Uploads,
|
||||
tick: u64,
|
||||
) {
|
||||
self.changed.clear();
|
||||
if self.collect(tick) == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -251,6 +256,7 @@ impl SpriteAnimator {
|
||||
continue;
|
||||
}
|
||||
sprite.shown = Some(blend);
|
||||
self.changed.push(sprite.animation.layer);
|
||||
let job = [
|
||||
sprite.layer,
|
||||
sprite.frames[blend.frame],
|
||||
|
||||
@@ -65,8 +65,11 @@ pub struct Arena {
|
||||
}
|
||||
|
||||
impl Arena {
|
||||
/// Vertices; also storage, which a Java shader pack's terrain programs
|
||||
/// read a quad's corners from (`gfx/java_terrain.rs`).
|
||||
pub fn new(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
|
||||
Self::with_usage(device, stride, capacity, wgpu::BufferUsages::VERTEX)
|
||||
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE;
|
||||
Self::with_usage(device, stride, capacity, usage)
|
||||
}
|
||||
/// Sorted translucent indices; `storage` when compute passes read them.
|
||||
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
|
||||
@@ -145,6 +148,10 @@ impl Arena {
|
||||
pub fn page_vertices(&self) -> u64 {
|
||||
self.page_limit
|
||||
}
|
||||
/// One past the last page index.
|
||||
pub fn page_count(&self) -> u32 {
|
||||
self.pages.len() as u32
|
||||
}
|
||||
pub fn has_page(&self, page: u32) -> bool {
|
||||
self.pages.get(page as usize).is_some_and(Option::is_some)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,14 @@ fn fs_box_early(in: Box) -> @location(0) vec4<f32> {
|
||||
see(in.test);
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
// fs_box_early against a Java shader pack's depth (`vs_box_gl`), in a pass
|
||||
// without colour targets.
|
||||
@fragment
|
||||
@early_depth_test(force)
|
||||
fn fs_box_early_depth(in: Box) {
|
||||
see(in.test);
|
||||
}
|
||||
";
|
||||
|
||||
/// The test's depth texture binding (`InShader`), for the view it was made with.
|
||||
@@ -118,6 +126,8 @@ struct DepthGroup {
|
||||
|
||||
pub struct Cull {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// [`Self::test_gl`]'s: a depth-only pass in OpenGL's layout.
|
||||
gl_pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
group: Option<Group>,
|
||||
depth_test: DepthTest,
|
||||
@@ -261,53 +271,76 @@ impl Cull {
|
||||
bind_group_layouts: &[Some(&layout), depth_layout.as_ref()],
|
||||
..Default::default()
|
||||
});
|
||||
// In the shader: no colour target, and phase 1's depth attached read
|
||||
// only (a pass needs an attachment), its test left to the shader.
|
||||
let (fragment_entry, targets, depth_compare) = match depth_test {
|
||||
DepthTest::Early => (
|
||||
"fs_box_early",
|
||||
&[Some(wgpu::ColorTargetState {
|
||||
format: color,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})][..],
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
),
|
||||
DepthTest::InShader => ("fs_box", &[][..], wgpu::CompareFunction::Always),
|
||||
// `gl`: a Java shader pack's depth (`test_gl`), in OpenGL's layout
|
||||
// (y flipped, and with it the faces' winding; 0 near), without a
|
||||
// colour target. In the shader (`InShader`): no colour target, and
|
||||
// the depth attached read only (a pass needs an attachment), its
|
||||
// test left to the shader.
|
||||
let pipeline = |label, gl: bool| {
|
||||
let color_target = [Some(wgpu::ColorTargetState {
|
||||
format: color,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})];
|
||||
let (fragment_entry, targets, depth_compare) = match (depth_test, gl) {
|
||||
(DepthTest::Early, false) => (
|
||||
"fs_box_early",
|
||||
&color_target[..],
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
),
|
||||
(DepthTest::Early, true) => (
|
||||
"fs_box_early_depth",
|
||||
&[][..],
|
||||
wgpu::CompareFunction::LessEqual,
|
||||
),
|
||||
(DepthTest::InShader, false) => ("fs_box", &[][..], wgpu::CompareFunction::Always),
|
||||
(DepthTest::InShader, true) => {
|
||||
("fs_box_gl", &[][..], wgpu::CompareFunction::Always)
|
||||
}
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(if gl { "vs_box_gl" } else { "vs_box" }),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
front_face: if gl {
|
||||
wgpu::FrontFace::Cw
|
||||
} else {
|
||||
wgpu::FrontFace::Ccw
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
// The pass's depth test (reversed in the world pass, OpenGL's
|
||||
// in a Java pack's), without writes.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(depth_compare),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(fragment_entry),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Section culling"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_box"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
// The world pass's reversed depth test, without writes.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(depth_compare),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(fragment_entry),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let gl_pipeline = pipeline("Section culling, OpenGL layout", true);
|
||||
let pipeline = pipeline("Section culling", false);
|
||||
Self {
|
||||
pipeline,
|
||||
gl_pipeline,
|
||||
layout,
|
||||
group: None,
|
||||
depth_test,
|
||||
@@ -553,6 +586,51 @@ impl Cull {
|
||||
pass.draw(0..36, 0..self.test_data.len() as u32);
|
||||
}
|
||||
|
||||
/// [`Self::test`] for a Java shader pack that draws the terrain
|
||||
/// (`gfx/java_stages.rs`): in a pass of its own against `depth`, the
|
||||
/// pack's depth after phase 1 in OpenGL's layout (y up, 0 near), attached
|
||||
/// read only, and also read by the shader where the test runs there
|
||||
/// (`InShader`).
|
||||
pub fn test_gl(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::TextureView,
|
||||
) {
|
||||
let Some(Group { group, .. }) = &self.group else {
|
||||
return;
|
||||
};
|
||||
if self.test_data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let depth_group = self.depth_layout.as_ref().map(|layout| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Section culling depth, Java shader pack"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
}],
|
||||
})
|
||||
});
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Section culling, Java shader pack"),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: None,
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.gl_pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
if let Some(depth) = &depth_group {
|
||||
pass.set_bind_group(1, depth, &[]);
|
||||
}
|
||||
pass.draw(0..36, 0..self.test_data.len() as u32);
|
||||
}
|
||||
|
||||
/// After the world passes: copy the results for reading back, when a
|
||||
/// readback buffer is free.
|
||||
pub fn end(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder) {
|
||||
|
||||
@@ -52,6 +52,10 @@ fn corner(origin: vec3<f32>, c: u32) -> vec3<f32> {
|
||||
// section fail the depth test against the final depth, which is only nearer.
|
||||
@vertex
|
||||
fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box {
|
||||
return box_vertex(v, t);
|
||||
}
|
||||
|
||||
fn box_vertex(v: u32, t: u32) -> Box {
|
||||
let origin = origins[tests[t].x].xyz;
|
||||
var out: Box;
|
||||
out.test = t;
|
||||
@@ -86,6 +90,15 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
|
||||
return out;
|
||||
}
|
||||
|
||||
// The same box in OpenGL's layout, for a Java shader pack's terrain depth
|
||||
// (gfx/java_stages.rs): y flipped, depth from 0 near to 1 far.
|
||||
@vertex
|
||||
fn vs_box_gl(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box {
|
||||
var out = box_vertex(v, t);
|
||||
out.position = vec4(out.position.x, -out.position.y, out.position.w - out.position.z, out.position.w);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The box is seen at this fragment: store the stamp, and give its phase 2
|
||||
// draws their instance.
|
||||
fn see(t: u32) {
|
||||
@@ -111,3 +124,12 @@ fn fs_box(in: Box) {
|
||||
see(in.test);
|
||||
}
|
||||
}
|
||||
|
||||
// fs_box against a Java shader pack's depth in OpenGL's layout (`vs_box_gl`):
|
||||
// less or equal passing.
|
||||
@fragment
|
||||
fn fs_box_gl(in: Box) {
|
||||
if in.position.z <= textureLoad(depth, vec2<u32>(in.position.xy), 0) {
|
||||
see(in.test);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@
|
||||
use super::Vertex;
|
||||
|
||||
/// Entity materials by a quad's overlay (`Vertex::layer`), in draw order:
|
||||
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12)
|
||||
/// and the boat water mask (13, `RenderPipelines.WATER_MASK`).
|
||||
const MATERIALS: usize = 4;
|
||||
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
|
||||
/// the boat water mask (13, `RenderPipelines.WATER_MASK`) and entity shadows
|
||||
/// (14, `RenderPipelines.ENTITY_SHADOW`).
|
||||
const MATERIALS: usize = 5;
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
pub const WATER_MASK_OVERLAY: u32 = 13;
|
||||
/// The overlay of entity shadow quads (`RenderTypes.entityShadow`).
|
||||
pub const SHADOW_OVERLAY: u32 = 14;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EntityBatch {
|
||||
@@ -37,6 +40,7 @@ impl EntityBatch {
|
||||
11 => 1,
|
||||
12 => 2,
|
||||
WATER_MASK_OVERLAY => 3,
|
||||
SHADOW_OVERLAY => 4,
|
||||
_ => 0,
|
||||
};
|
||||
self.buckets[material][counts[material]] = index;
|
||||
@@ -88,12 +92,13 @@ mod tests {
|
||||
) -> (Vec<Vertex>, [u32; MATERIALS]) {
|
||||
let mut combined = Vec::with_capacity(entities.len() + additive.len() + hand.len());
|
||||
let mut counts = [0; MATERIALS];
|
||||
for (i, layer) in [0, 11, 12, 13].into_iter().enumerate() {
|
||||
for (i, layer) in [0, 11, 12, 13, 14].into_iter().enumerate() {
|
||||
for quad in entities.as_chunks::<4>().0 {
|
||||
let material = match quad[0].layer {
|
||||
11 => 11,
|
||||
12 => 12,
|
||||
13 => 13,
|
||||
14 => 14,
|
||||
_ => 0,
|
||||
};
|
||||
if material == layer {
|
||||
@@ -113,7 +118,7 @@ mod tests {
|
||||
pos: [index as f32, 2.0, 3.0],
|
||||
uv: [index as f32, 5.0],
|
||||
layer: if mixed {
|
||||
[7, 11, 12, 4, 13, 0][index / 4 % 6]
|
||||
[7, 11, 14, 12, 4, 13, 0][index / 4 % 7]
|
||||
} else {
|
||||
0
|
||||
},
|
||||
|
||||
@@ -640,25 +640,36 @@ impl Flat {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
/// The bind group layout of the draws' group 1 (block slots, slot
|
||||
/// origins).
|
||||
pub fn draw_layout(&self) -> &wgpu::BindGroupLayout {
|
||||
&self.draw_layout
|
||||
}
|
||||
|
||||
/// Draws `calls` with a flat pipeline set and `vertices` giving each
|
||||
/// page's vertex buffer.
|
||||
/// page's vertex buffer; `page` binds what else a page's draws need (a
|
||||
/// Java pack's terrain reads the page's vertices).
|
||||
pub fn draw<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for call in calls {
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
page(pass, call.page);
|
||||
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
/// Draws phase 2's calls, as `flatten_phase2` left them.
|
||||
/// Draws phase 2's calls, as `flatten_phase2` left them; `page` as in
|
||||
/// [`Self::draw`].
|
||||
pub fn draw_phase2<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
|
||||
@@ -666,6 +677,7 @@ impl Flat {
|
||||
.enumerate()
|
||||
{
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
page(pass, call.page);
|
||||
pass.draw_indexed_indirect(&self.phase2_calls, k as u64 * 20);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,667 @@
|
||||
//! Horizon's terrain drawn with a Java pack's `gbuffers_terrain` and
|
||||
//! `gbuffers_water` (docs/shader-packs.md). The pack's vertex program runs
|
||||
//! after a glue that rebuilds what Iris would feed it from Horizon's
|
||||
//! `TerrainVertex`: the camera-relative position, the quad's normal and
|
||||
//! tangent (from its four corners, read from the arena page as storage),
|
||||
//! the colour without vanilla's directional shade, the lightmap
|
||||
//! coordinates, the sprite's place in a block atlas laid out from Horizon's
|
||||
//! sprite arrays, and `mc_Entity` from the pack's `block.properties`, by
|
||||
//! sprite name.
|
||||
use horizon_shaderpack::translate::Geometry;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// `TerrainVertex` in 32-bit words.
|
||||
const VERTEX_WORDS: u32 = 6;
|
||||
|
||||
/// The glue's declarations and `hz_vertex`; `HZ_ORIGIN_DECL` and
|
||||
/// `HZ_ORIGIN` are the variant's (instanced or flat draws).
|
||||
const GLUE: &str = r#"
|
||||
layout(location = 0) in vec3 hz_position;
|
||||
layout(location = 1) in uvec2 hz_uv;
|
||||
layout(location = 2) in uint hz_layer;
|
||||
layout(location = 3) in vec4 hz_color;
|
||||
layout(location = 4) in uvec2 hz_light;
|
||||
HZ_ORIGIN_DECL
|
||||
layout(std430, set = 3, binding = 0) readonly buffer HzVertices { uint hz_vertices[]; };
|
||||
// [0]: atlas columns, [1]: 1 in the Nether, then each sprite layer's block id.
|
||||
layout(std430, set = 3, binding = 1) readonly buffer HzTable { int hz_table[]; };
|
||||
vec4 hz_pos;
|
||||
vec3 hz_normal;
|
||||
vec4 hz_tangent;
|
||||
vec4 hz_tint;
|
||||
vec2 hz_atlas;
|
||||
vec2 hz_mid;
|
||||
vec2 hz_lightmap;
|
||||
float hz_block;
|
||||
vec3 hz_mid_block;
|
||||
vec3 hz_corner(uint v) {
|
||||
uint b = v * HZ_WORDSu;
|
||||
return vec3(uintBitsToFloat(hz_vertices[b]), uintBitsToFloat(hz_vertices[b + 1u]),
|
||||
uintBitsToFloat(hz_vertices[b + 2u]));
|
||||
}
|
||||
vec2 hz_corner_uv(uint v) {
|
||||
uint w = hz_vertices[v * HZ_WORDSu + 3u];
|
||||
return vec2(float(w & 65535u), float(w >> 16u));
|
||||
}
|
||||
void hz_vertex() {
|
||||
vec4 origin = HZ_ORIGIN;
|
||||
hz_pos = vec4(hz_position + origin.xyz, 1.0);
|
||||
// The quad's corners: quads are four vertices from a multiple of four.
|
||||
uint q = uint(gl_VertexIndex) & 4294967292u;
|
||||
vec3 p0 = hz_corner(q);
|
||||
vec3 e1 = hz_corner(q + 1u) - p0;
|
||||
vec3 e2 = hz_corner(q + 2u) - p0;
|
||||
vec3 n = cross(e1, e2);
|
||||
if (dot(n, n) < 1e-12) {
|
||||
n = cross(e2, hz_corner(q + 3u) - p0);
|
||||
}
|
||||
hz_normal = dot(n, n) > 0.0 ? normalize(n) : vec3(0.0, 1.0, 0.0);
|
||||
vec2 t0 = hz_corner_uv(q);
|
||||
vec2 d1 = hz_corner_uv(q + 1u) - t0;
|
||||
vec2 d2 = hz_corner_uv(q + 2u) - t0;
|
||||
float s = d1.x * d2.y - d2.x * d1.y < 0.0 ? -1.0 : 1.0;
|
||||
vec3 t = (e1 * d2.y - e2 * d1.y) * s;
|
||||
vec3 b = (e2 * d1.x - e1 * d2.x) * s;
|
||||
t = t - hz_normal * dot(hz_normal, t);
|
||||
t = dot(t, t) > 0.0 ? normalize(t) : vec3(1.0, 0.0, 0.0);
|
||||
hz_tangent = vec4(t, dot(cross(hz_normal, t), b) < 0.0 ? -1.0 : 1.0);
|
||||
// Iris turns vanilla's directional shade off: undo it on axis faces.
|
||||
vec3 a = abs(hz_normal);
|
||||
bool nether = hz_table[1] != 0;
|
||||
float shade = 1.0;
|
||||
if (a.y > 0.999) {
|
||||
shade = nether ? 0.9 : (hz_normal.y > 0.0 ? 1.0 : 0.5);
|
||||
} else if (a.z > 0.999) {
|
||||
shade = 0.8;
|
||||
} else if (a.x > 0.999) {
|
||||
shade = 0.6;
|
||||
}
|
||||
hz_tint = vec4(min(hz_color.rgb / shade, vec3(1.0)), hz_color.a);
|
||||
// The sprite's cell in the atlas, columns of square cells.
|
||||
int layer = int(hz_layer & 16383u);
|
||||
int cols = max(hz_table[0], 1);
|
||||
vec2 cell = vec2(float(layer % cols), float(layer / cols));
|
||||
vec2 uv = clamp(vec2(hz_uv) * origin.w, 0.0, 1.0);
|
||||
hz_atlas = (cell + uv) / float(cols);
|
||||
hz_mid = (cell + 0.5) / float(cols);
|
||||
// Vanilla's UV2: block light, then sky light, 0..240.
|
||||
hz_lightmap = vec2(float(hz_light.y), float(hz_light.x));
|
||||
hz_block = float(hz_table[2 + layer]);
|
||||
hz_mid_block = (floor(hz_position - hz_normal * 0.01) + 0.5 - hz_position) * 64.0;
|
||||
}
|
||||
"#;
|
||||
|
||||
/// The glue of one draw path: instanced section draws (the origin is an
|
||||
/// instance attribute) or flat ones (`gfx/flat.rs`: from the block's slot).
|
||||
pub fn glue(flat: bool, alpha_test: Option<f32>) -> Geometry {
|
||||
let (decl, origin) = if flat {
|
||||
(
|
||||
"layout(std430, set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
layout(std430, set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };",
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]",
|
||||
)
|
||||
} else {
|
||||
("layout(location = 5) in vec4 hz_origin;", "hz_origin")
|
||||
};
|
||||
let vertex_glue = GLUE
|
||||
.replace("HZ_ORIGIN_DECL", decl)
|
||||
.replace("HZ_ORIGIN", origin)
|
||||
.replace("HZ_WORDS", &VERTEX_WORDS.to_string());
|
||||
let lightmap = "vec4(hz_lightmap, 0.0, 1.0)";
|
||||
let attributes: BTreeMap<String, String> = [
|
||||
("iris_Vertex", "hz_pos"),
|
||||
("vaPosition", "hz_pos"),
|
||||
("iris_Normal", "vec4(hz_normal, 0.0)"),
|
||||
("vaNormal", "vec4(hz_normal, 0.0)"),
|
||||
("iris_Color", "hz_tint"),
|
||||
("vaColor", "hz_tint"),
|
||||
("iris_MultiTexCoord0", "vec4(hz_atlas, 0.0, 1.0)"),
|
||||
("vaUV0", "vec4(hz_atlas, 0.0, 1.0)"),
|
||||
("iris_MultiTexCoord1", lightmap),
|
||||
("iris_MultiTexCoord2", lightmap),
|
||||
("vaUV2", lightmap),
|
||||
// OverlayTexture.NO_OVERLAY.
|
||||
("vaUV1", "vec4(0.0, 10.0, 0.0, 0.0)"),
|
||||
("mc_Entity", "vec4(hz_block, -1.0, 0.0, 0.0)"),
|
||||
("mc_midTexCoord", "vec4(hz_mid, 0.0, 1.0)"),
|
||||
("at_tangent", "hz_tangent"),
|
||||
("at_midBlock", "vec4(hz_mid_block, 0.0)"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect();
|
||||
Geometry {
|
||||
vertex_glue,
|
||||
attributes,
|
||||
alpha_test,
|
||||
}
|
||||
}
|
||||
|
||||
/// The glue's table: atlas columns, the Nether flag, then each sprite
|
||||
/// layer's block id from `block.properties` (-1 for blocks it doesn't
|
||||
/// list, as Iris).
|
||||
pub fn table(sprite_layers: &BTreeMap<String, u32>, block_ids: &BTreeMap<String, i32>) -> Vec<i32> {
|
||||
let layers = sprite_layers.values().max().map_or(0, |&m| m as usize + 1);
|
||||
let mut out = vec![-1; layers + 2];
|
||||
out[0] = atlas_columns(layers as u32) as i32;
|
||||
out[1] = 0;
|
||||
for (name, &layer) in sprite_layers {
|
||||
if let Some(id) = horizon_shaderpack::properties::sprite_block_id(name, block_ids) {
|
||||
out[layer as usize + 2] = id;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The atlas's columns (and rows) of sprite cells for `layers` sprites.
|
||||
pub fn atlas_columns(layers: u32) -> u32 {
|
||||
(f64::from(layers.max(1)).sqrt().ceil() as u32).max(1)
|
||||
}
|
||||
|
||||
/// Whether `device` renders to and filters the 16-bit normalized formats
|
||||
/// a Java pack's `RGBA16` targets become (`java_stages::target_format`).
|
||||
pub fn norm16_targets(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
use wgpu::TextureFormat as F;
|
||||
let wanted = wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES;
|
||||
device.features().contains(wanted)
|
||||
&& [
|
||||
F::R16Unorm,
|
||||
F::Rg16Unorm,
|
||||
F::Rgba16Unorm,
|
||||
F::R16Snorm,
|
||||
F::Rg16Snorm,
|
||||
F::Rgba16Snorm,
|
||||
]
|
||||
.into_iter()
|
||||
.all(|f| {
|
||||
let features = adapter.get_texture_format_features(f);
|
||||
features
|
||||
.allowed_usages
|
||||
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
|
||||
&& features
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::FILTERABLE)
|
||||
})
|
||||
}
|
||||
|
||||
/// The bind group layout of a page group: the page's vertices and the glue's
|
||||
/// table, read by the vertex stage.
|
||||
pub fn page_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
||||
let storage = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Java shader pack terrain page"),
|
||||
entries: &[storage(0), storage(1)],
|
||||
})
|
||||
}
|
||||
|
||||
/// Which of Horizon's terrain draws a pack's program draws.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Layer {
|
||||
/// The solid sections: all of them, or with occlusion culling
|
||||
/// ([`JavaWorld::occlusion`]) the ones the last test saw (phase 1).
|
||||
Solid,
|
||||
/// With occlusion culling, the solid sections the test after phase 1
|
||||
/// found in sight (phase 2).
|
||||
Revealed,
|
||||
Translucent,
|
||||
/// The shadow map's sections ([`super::Renderer`]'s shadow list), solid
|
||||
/// then translucent.
|
||||
ShadowSolid,
|
||||
ShadowTranslucent,
|
||||
}
|
||||
|
||||
/// Draws Horizon's terrain with a pack's pipelines. Set by the renderer,
|
||||
/// which owns the sections.
|
||||
pub type DrawTerrain<'a> = dyn Fn(&mut wgpu::RenderPass<'_>, Layer, &TerrainPipelines<'_>) + 'a;
|
||||
|
||||
/// Occlusion culling's test between [`Layer::Solid`] and
|
||||
/// [`Layer::Revealed`], against the pack's depth (OpenGL's layout).
|
||||
pub type OcclusionTest<'a> = dyn Fn(&mut wgpu::CommandEncoder, &wgpu::TextureView) + 'a;
|
||||
|
||||
/// What the renderer binds to draw with a pack's geometry program: the
|
||||
/// pipeline of its draw path and the pack's group (group 2). Group 1 is the
|
||||
/// flat path's, group 3 the page's ([`page_layout`]). `flat` is `None` when
|
||||
/// the renderer has no flat draws.
|
||||
pub struct TerrainPipelines<'a> {
|
||||
pub instanced: &'a wgpu::RenderPipeline,
|
||||
pub flat: Option<&'a wgpu::RenderPipeline>,
|
||||
pub group: &'a wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// What a Java pack's geometry reads from the renderer each frame.
|
||||
pub struct JavaWorld<'a> {
|
||||
/// The block sprite arrays, [`super::SPRITE_SPLIT`]-layer each.
|
||||
pub sprites: &'a [wgpu::Texture],
|
||||
pub split: u32,
|
||||
/// Mipmap levels the terrain samples.
|
||||
pub levels: u32,
|
||||
/// The sprite layers and mip levels animation rewrote this frame.
|
||||
pub animated: &'a [(u32, u32)],
|
||||
pub lightmap: &'a wgpu::TextureView,
|
||||
pub draw: &'a DrawTerrain<'a>,
|
||||
/// Set when the frame culls solid sections by occlusion.
|
||||
pub occlusion: Option<&'a OcclusionTest<'a>>,
|
||||
}
|
||||
|
||||
const ATLAS: &str = r#"
|
||||
struct Atlas { cols: u32, layers: u32, split: u32, pad: u32 }
|
||||
@group(0) @binding(0) var<uniform> atlas: Atlas;
|
||||
@group(0) @binding(1) var s0: texture_2d_array<f32>;
|
||||
@group(0) @binding(2) var s1: texture_2d_array<f32>;
|
||||
@group(0) @binding(3) var s2: texture_2d_array<f32>;
|
||||
@group(0) @binding(4) var s3: texture_2d_array<f32>;
|
||||
@group(0) @binding(5) var s4: texture_2d_array<f32>;
|
||||
@group(0) @binding(6) var s5: texture_2d_array<f32>;
|
||||
@group(0) @binding(7) var s6: texture_2d_array<f32>;
|
||||
@group(0) @binding(8) var s7: texture_2d_array<f32>;
|
||||
|
||||
struct Out {
|
||||
@builtin(position) p: vec4f,
|
||||
@location(0) @interpolate(flat) mip: u32,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) i: u32, @builtin(instance_index) mip: u32) -> Out {
|
||||
let uv = vec2f(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return Out(vec4f(uv * 2.0 - 1.0, 0.0, 1.0), mip);
|
||||
}
|
||||
|
||||
// Each sprite layer into its cell, at mip level `mip`.
|
||||
@fragment
|
||||
fn fs(in: Out) -> @location(0) vec4f {
|
||||
let cell = max(textureDimensions(s0, in.mip).x, 1u);
|
||||
let px = vec2u(in.p.xy);
|
||||
let c = px / cell;
|
||||
let layer = c.y * atlas.cols + c.x;
|
||||
if c.x >= atlas.cols || layer >= atlas.layers {
|
||||
return vec4f(0.0);
|
||||
}
|
||||
let at = px % cell;
|
||||
let l = i32(layer % atlas.split);
|
||||
let m = i32(in.mip);
|
||||
switch layer / atlas.split {
|
||||
case 0u: { return textureLoad(s0, at, l, m); }
|
||||
case 1u: { return textureLoad(s1, at, l, m); }
|
||||
case 2u: { return textureLoad(s2, at, l, m); }
|
||||
case 3u: { return textureLoad(s3, at, l, m); }
|
||||
case 4u: { return textureLoad(s4, at, l, m); }
|
||||
case 5u: { return textureLoad(s5, at, l, m); }
|
||||
case 6u: { return textureLoad(s6, at, l, m); }
|
||||
default: { return textureLoad(s7, at, l, m); }
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Horizon's block sprites as one 2D atlas, as Iris binds `gtexture`:
|
||||
/// square cells, [`atlas_columns`] to a row. Drawn from the sprite arrays
|
||||
/// once, then animated sprites are copied in as they change.
|
||||
pub struct Atlas {
|
||||
texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
mips: Vec<wgpu::TextureView>,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
uniform: wgpu::Buffer,
|
||||
/// Layers, sprite size, mip levels it was made for.
|
||||
key: (u32, u32, u32),
|
||||
/// The first sprite array it was drawn from (resource reloads make
|
||||
/// new ones).
|
||||
source: wgpu::Texture,
|
||||
drawn: bool,
|
||||
}
|
||||
|
||||
impl Atlas {
|
||||
/// The atlas for `world`'s sprites; `None` without any.
|
||||
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, world: &JavaWorld) -> Option<Self> {
|
||||
let key = Self::key(world)?;
|
||||
let (layers, size, levels) = key;
|
||||
let cols = atlas_columns(layers);
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
width: cols * size,
|
||||
height: cols * size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mips = (0..levels)
|
||||
.map(|level| {
|
||||
texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
base_mip_level: level,
|
||||
mip_level_count: Some(1),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut entries = vec![wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}];
|
||||
entries.extend((1..=8).map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
entries: &entries,
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
source: wgpu::ShaderSource::Wgsl(ATLAS.into()),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(
|
||||
&uniform,
|
||||
0,
|
||||
bytemuck::cast_slice(&[cols, layers, world.split.max(1), 0]),
|
||||
);
|
||||
Some(Self {
|
||||
view: texture.create_view(&Default::default()),
|
||||
texture,
|
||||
mips,
|
||||
pipeline,
|
||||
layout,
|
||||
uniform,
|
||||
key,
|
||||
source: world.sprites[0].clone(),
|
||||
drawn: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn key(world: &JavaWorld) -> Option<(u32, u32, u32)> {
|
||||
let first = world.sprites.first()?;
|
||||
let layers = world
|
||||
.sprites
|
||||
.iter()
|
||||
.map(|t| t.depth_or_array_layers())
|
||||
.sum();
|
||||
let levels = (world.levels + 1).min(first.mip_level_count()).max(1);
|
||||
Some((layers, first.width(), levels))
|
||||
}
|
||||
|
||||
/// Whether it was made for `world`'s sprites.
|
||||
pub fn fits(&self, world: &JavaWorld) -> bool {
|
||||
Self::key(world) == Some(self.key) && world.sprites.first() == Some(&self.source)
|
||||
}
|
||||
|
||||
/// Bring it up to date: drawn the first time, then the sprites
|
||||
/// animation rewrote copied into their cells.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: &JavaWorld,
|
||||
) {
|
||||
if !self.drawn {
|
||||
self.draw(device, encoder, world);
|
||||
self.drawn = true;
|
||||
return;
|
||||
}
|
||||
let (layers, size, levels) = self.key;
|
||||
let cols = atlas_columns(layers);
|
||||
let split = world.split.max(1);
|
||||
for &(layer, level) in world.animated {
|
||||
let Some(array) = world.sprites.get((layer / split) as usize) else {
|
||||
continue;
|
||||
};
|
||||
if level >= levels || layer >= layers {
|
||||
continue;
|
||||
}
|
||||
let edge = (size >> level).max(1);
|
||||
encoder.copy_texture_to_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: array,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: layer % split,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &self.texture,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: (layer % cols) * edge,
|
||||
y: (layer / cols) * edge,
|
||||
z: 0,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: edge,
|
||||
height: edge,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The atlas's size in pixels (`atlasSize`).
|
||||
pub fn size(&self) -> [u32; 2] {
|
||||
[self.texture.width(), self.texture.height()]
|
||||
}
|
||||
|
||||
/// Draw the sprites into the atlas, every mip level.
|
||||
fn draw(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, world: &JavaWorld) {
|
||||
let views: Vec<wgpu::TextureView> = (0..8)
|
||||
.map(|i| {
|
||||
let t = world.sprites.get(i).unwrap_or(&world.sprites[0]);
|
||||
t.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
mip_level_count: Some(self.key.2),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut entries = vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.uniform.as_entire_binding(),
|
||||
}];
|
||||
entries.extend(views.iter().enumerate().map(|(i, v)| wgpu::BindGroupEntry {
|
||||
binding: i as u32 + 1,
|
||||
resource: wgpu::BindingResource::TextureView(v),
|
||||
}));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
});
|
||||
for (mip, view) in self.mips.iter().enumerate() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, mip as u32..mip as u32 + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `module` validates and translates for Metal, Vulkan and DirectX 12.
|
||||
fn backends(module: &naga::Module, label: &str) {
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(module)
|
||||
.unwrap_or_else(|e| panic!("{label}: {e:?}"));
|
||||
naga::back::msl::write_string(
|
||||
module,
|
||||
&info,
|
||||
// wgpu's Metal backend asks for MSL 2.x.
|
||||
&naga::back::msl::Options {
|
||||
lang_version: (2, 4),
|
||||
..Default::default()
|
||||
},
|
||||
&naga::back::msl::PipelineOptions::default(),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{label} (MSL): {e}"));
|
||||
naga::back::spv::write_vec(module, &info, &Default::default(), None)
|
||||
.unwrap_or_else(|e| panic!("{label} (SPIR-V): {e}"));
|
||||
for entry in &module.entry_points {
|
||||
let mut hlsl = String::new();
|
||||
naga::back::hlsl::Writer::new(
|
||||
&mut hlsl,
|
||||
&naga::back::hlsl::Options::default(),
|
||||
&naga::back::hlsl::PipelineOptions {
|
||||
entry_point: Some((entry.stage, entry.name.clone())),
|
||||
},
|
||||
)
|
||||
.write(module, &info, None)
|
||||
.unwrap_or_else(|e| panic!("{label} {} (HLSL): {e}", entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// A terrain program reading every attribute the glue fills, run after
|
||||
/// each draw path's glue, and the atlas shader, on every backend.
|
||||
#[test]
|
||||
fn the_glue_feeds_a_terrain_program_on_every_backend() {
|
||||
let program = "#version 120
|
||||
attribute vec4 mc_Entity;
|
||||
attribute vec4 mc_midTexCoord;
|
||||
attribute vec4 at_tangent;
|
||||
attribute vec3 at_midBlock;
|
||||
varying vec4 color;
|
||||
varying vec2 uv;
|
||||
varying vec2 lm;
|
||||
varying vec3 normal;
|
||||
varying float id;
|
||||
void main() {
|
||||
vec4 p = gl_ModelViewMatrix * gl_Vertex;
|
||||
p.xyz += at_midBlock / 64.0 * 0.0 + at_tangent.xyz * 0.0;
|
||||
gl_Position = gl_ProjectionMatrix * p;
|
||||
color = gl_Color;
|
||||
uv = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy + mc_midTexCoord.xy * 0.0;
|
||||
lm = (gl_TextureMatrix[1] * gl_MultiTexCoord1).xy;
|
||||
normal = gl_NormalMatrix * gl_Normal;
|
||||
id = mc_Entity.x;
|
||||
}
|
||||
";
|
||||
for flat in [false, true] {
|
||||
let mut link = horizon_shaderpack::translate::Link {
|
||||
set: 2,
|
||||
geometry: Some(glue(flat, Some(0.1))),
|
||||
..Default::default()
|
||||
};
|
||||
let t = horizon_shaderpack::translate::translate(
|
||||
horizon_shaderpack::Stage::Vertex,
|
||||
program,
|
||||
&mut link,
|
||||
&|l| format!("line {l}"),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("flat {flat}: {e}"));
|
||||
for wanted in ["hz_vertex();", "mc_Entity = vec4(vec4(hz_block"] {
|
||||
assert!(t.glsl.contains(wanted), "{wanted}\n{}", t.glsl);
|
||||
}
|
||||
// The glue's inputs are Horizon's vertex layout.
|
||||
let inputs = t.module.entry_points[0].function.arguments.len();
|
||||
assert_eq!(inputs, if flat { 6 } else { 7 }, "{}", t.wgsl);
|
||||
let reparsed = naga::front::wgsl::parse_str(&t.wgsl).unwrap();
|
||||
backends(&reparsed, &format!("terrain glue (flat {flat})"));
|
||||
}
|
||||
backends(&naga::front::wgsl::parse_str(ATLAS).unwrap(), "atlas");
|
||||
backends(
|
||||
&naga::front::wgsl::parse_str(super::super::java_stages::BLIT).unwrap(),
|
||||
"Java shader pack blits",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprites_take_their_blocks_ids() {
|
||||
let layers: BTreeMap<String, u32> = [
|
||||
("minecraft:block/oak_leaves", 0),
|
||||
("minecraft:block/water_still", 1),
|
||||
("minecraft:block/stone", 2),
|
||||
("minecraft:block/oak_log_top", 3),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(n, l)| (n.to_owned(), l))
|
||||
.collect();
|
||||
let ids: BTreeMap<String, i32> = [("oak_leaves", 10003), ("water", 8), ("oak_log", 7)]
|
||||
.into_iter()
|
||||
.map(|(n, i)| (n.to_owned(), i))
|
||||
.collect();
|
||||
assert_eq!(table(&layers, &ids), [2, 0, 10003, 8, -1, 7]);
|
||||
assert_eq!(atlas_columns(5), 3);
|
||||
assert_eq!(atlas_columns(0), 1);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
//! a folder holding `shaders/`, or a `.zip`. `horizon-shaderpack` translates
|
||||
//! their full-screen programs (`begin`, `prepare`, `deferred`, `composite`,
|
||||
//! `final`) to WGSL, and `gfx/java_stages.rs` runs them over Horizon's world
|
||||
//! as Iris runs them over its own. Geometry programs (`gbuffers_*`,
|
||||
//! `shadow`) don't run yet: the world is drawn by Horizon.
|
||||
//! as Iris runs them over its own. Of the geometry programs, the terrain's
|
||||
//! (`gbuffers_terrain`, `gbuffers_water` and their fallbacks) draw Horizon's
|
||||
//! terrain (`gfx/java_terrain.rs`); the rest of the world is Horizon's.
|
||||
use horizon_shaderpack::{
|
||||
JavaPack,
|
||||
pipeline::{Directives, Program, TargetSettings},
|
||||
pipeline::{Directives, GeometryProgram, Pass, Program, TargetSettings},
|
||||
preprocess::Environment,
|
||||
properties::SkyDirectives,
|
||||
properties::{Blend, SkyDirectives},
|
||||
uniforms::CustomUniforms,
|
||||
};
|
||||
use std::{
|
||||
@@ -36,6 +37,27 @@ pub struct JavaFolder {
|
||||
/// `sunPathRotation`, `eyeBrightnessHalflife`, `wetnessHalflife` and
|
||||
/// `drynessHalflife`.
|
||||
pub directives: Directives,
|
||||
/// The programs Horizon's solid and translucent terrain are drawn with,
|
||||
/// each translated for instanced and flat draws (`variants[0]`).
|
||||
pub terrain: Option<GeometryProgram>,
|
||||
pub water: Option<GeometryProgram>,
|
||||
/// How many of `passes` run before translucent terrain (`begin*`,
|
||||
/// `prepare*`, `deferred*`).
|
||||
pub translucent_at: usize,
|
||||
pub flipped_after_translucent: BTreeSet<u32>,
|
||||
/// Each draw buffer's blending in `terrain` and `water`.
|
||||
pub terrain_blends: Vec<Option<wgpu::BlendState>>,
|
||||
pub water_blends: Vec<Option<wgpu::BlendState>>,
|
||||
/// The shadow map's programs over Horizon's solid and translucent
|
||||
/// terrain (instanced draws only), drawn after `begin*`, into
|
||||
/// `shadowcolor` targets set by `shadow_targets`.
|
||||
pub shadow: Option<GeometryProgram>,
|
||||
pub shadow_water: Option<GeometryProgram>,
|
||||
pub shadow_targets: Vec<TargetSettings>,
|
||||
pub shadow_blends: Vec<Option<wgpu::BlendState>>,
|
||||
pub shadow_water_blends: Vec<Option<wgpu::BlendState>>,
|
||||
/// How many of `passes` run before the shadow map (`begin*`).
|
||||
pub shadow_at: usize,
|
||||
}
|
||||
|
||||
/// The key of `texture.noise` (`noisetex`) in [`JavaPasses::custom_textures`].
|
||||
@@ -60,6 +82,8 @@ pub struct JavaPasses {
|
||||
pub uniforms: CustomUniforms,
|
||||
/// What the pack keeps of the vanilla sky (`clouds`, `sun`, `moon`).
|
||||
pub sky: SkyDirectives,
|
||||
/// `block.properties`: block name → `mc_Entity` id.
|
||||
pub block_ids: BTreeMap<String, i32>,
|
||||
/// `texture.<stage>.<sampler>` and `texture.noise` images, by
|
||||
/// `"<stage>.<sampler>"` and [`NOISE_KEY`].
|
||||
pub custom_textures: BTreeMap<String, crate::shader_pack::Rgba>,
|
||||
@@ -144,6 +168,64 @@ pub fn load(
|
||||
if passes.is_empty() && final_pass.is_none() {
|
||||
continue;
|
||||
}
|
||||
// A geometry program that doesn't translate leaves that terrain to
|
||||
// Horizon, with a warning.
|
||||
let geometry_with = |pass: &Option<horizon_shaderpack::pipeline::Pass>,
|
||||
alpha_test,
|
||||
flat: &[bool]| {
|
||||
let pass = pass.as_ref()?;
|
||||
let glues: Vec<_> = flat
|
||||
.iter()
|
||||
.map(|&flat| crate::gfx::java_terrain::glue(flat, alpha_test))
|
||||
.collect();
|
||||
match pack.compile_geometry(pass, &plan.targets, &values, &env, &properties, &glues) {
|
||||
Ok(program) => Some(program),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: shader pack {}: {e}", pass.program);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let geometry = |pass: &Option<horizon_shaderpack::pipeline::Pass>, alpha_test| {
|
||||
geometry_with(pass, alpha_test, &[false, true])
|
||||
};
|
||||
// Iris's alpha test of cutout terrain (`AlphaTests.ONE_TENTH_ALPHA`).
|
||||
let terrain = geometry(&plan.terrain, Some(0.1));
|
||||
let water = terrain.as_ref().and_then(|_| geometry(&plan.water, None));
|
||||
// The shadow map draws only with the pack's terrain, and only from
|
||||
// instanced draws (the flat draws hold what the camera sees).
|
||||
let shadow = terrain
|
||||
.as_ref()
|
||||
.and_then(|_| geometry_with(&plan.shadow, Some(0.1), &[false]));
|
||||
let shadow_water = shadow
|
||||
.as_ref()
|
||||
.and_then(|_| geometry_with(&plan.shadow_water, None, &[false]));
|
||||
let translucent_at = early.clone().count() + plan.deferred.len();
|
||||
let blends = |g: &Option<GeometryProgram>, default| {
|
||||
g.as_ref().map_or_else(Vec::new, |g| {
|
||||
pass_blends(&g.program.pass, &g.program.draw_buffers, default)
|
||||
})
|
||||
};
|
||||
let terrain_blends = blends(&terrain, None);
|
||||
// Iris's shadow programs don't blend (`BlendModeOverride.OFF`).
|
||||
let shadow_blends = blends(&shadow, None);
|
||||
let shadow_water_blends = blends(&shadow_water, None);
|
||||
// Vanilla's translucent blending (`BlendFunction.TRANSLUCENT`).
|
||||
let water_blends = blends(
|
||||
&water,
|
||||
Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
);
|
||||
folders.push(JavaFolder {
|
||||
folder: name.to_owned(),
|
||||
targets: plan.targets,
|
||||
@@ -154,6 +236,18 @@ pub fn load(
|
||||
final_pass,
|
||||
flipped_at_end: plan.flipped_at_end,
|
||||
directives: plan.directives,
|
||||
terrain,
|
||||
water,
|
||||
translucent_at,
|
||||
flipped_after_translucent: plan.flipped_after_translucent,
|
||||
terrain_blends,
|
||||
water_blends,
|
||||
shadow,
|
||||
shadow_water,
|
||||
shadow_targets: plan.shadow_targets,
|
||||
shadow_blends,
|
||||
shadow_water_blends,
|
||||
shadow_at: plan.begin.len(),
|
||||
});
|
||||
}
|
||||
if folders.is_empty() {
|
||||
@@ -184,11 +278,69 @@ pub fn load(
|
||||
for problem in &uniforms.problems {
|
||||
log::warn!("Horizon: shader pack custom uniform {problem}");
|
||||
}
|
||||
let block_ids = pack.block_ids(&values, &env).unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: shader pack {e}");
|
||||
BTreeMap::new()
|
||||
});
|
||||
Ok(JavaPasses {
|
||||
folders,
|
||||
uniforms,
|
||||
sky: properties.sky(),
|
||||
custom_textures,
|
||||
block_ids,
|
||||
})
|
||||
}
|
||||
|
||||
/// Each draw buffer's blending, as Iris's `BlendModeOverride`:
|
||||
/// `blend.<program>.<buffer>`, else `blend.<program>`, else `default`
|
||||
/// (none for full-screen passes, vanilla's for the geometry programs).
|
||||
pub(crate) fn pass_blends(
|
||||
pass: &Pass,
|
||||
draw_buffers: &[u32],
|
||||
default: Option<wgpu::BlendState>,
|
||||
) -> Vec<Option<wgpu::BlendState>> {
|
||||
let all = pass.blend.as_ref().map_or(default, blend_state);
|
||||
draw_buffers
|
||||
.iter()
|
||||
.map(|i| {
|
||||
pass.buffer_blends
|
||||
.iter()
|
||||
.find(|(b, _)| b == i)
|
||||
.map_or(all, |(_, b)| blend_state(b))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A `blend.*` value as wgpu's blending (`off`: none). The factors are the
|
||||
/// GL ones `ShaderProperties` accepts.
|
||||
pub(crate) fn blend_state(blend: &Blend) -> Option<wgpu::BlendState> {
|
||||
let Blend::Factors(factors) = blend else {
|
||||
return None;
|
||||
};
|
||||
let factor = |name: &str| {
|
||||
use wgpu::BlendFactor as F;
|
||||
match name {
|
||||
"ZERO" => F::Zero,
|
||||
"SRC_COLOR" => F::Src,
|
||||
"ONE_MINUS_SRC_COLOR" => F::OneMinusSrc,
|
||||
"DST_COLOR" => F::Dst,
|
||||
"ONE_MINUS_DST_COLOR" => F::OneMinusDst,
|
||||
"SRC_ALPHA" => F::SrcAlpha,
|
||||
"ONE_MINUS_SRC_ALPHA" => F::OneMinusSrcAlpha,
|
||||
"DST_ALPHA" => F::DstAlpha,
|
||||
"ONE_MINUS_DST_ALPHA" => F::OneMinusDstAlpha,
|
||||
"SRC_ALPHA_SATURATE" => F::SrcAlphaSaturated,
|
||||
_ => F::One,
|
||||
}
|
||||
};
|
||||
let component = |src: &str, dst: &str| wgpu::BlendComponent {
|
||||
src_factor: factor(src),
|
||||
dst_factor: factor(dst),
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
Some(wgpu::BlendState {
|
||||
color: component(&factors[0], &factors[1]),
|
||||
alpha: component(&factors[2], &factors[3]),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -242,8 +394,21 @@ mod tests {
|
||||
.folders
|
||||
.iter()
|
||||
.flat_map(|f| f.passes.iter().chain(&f.final_pass));
|
||||
let geometry = pack.folders.iter().flat_map(|f| {
|
||||
f.terrain
|
||||
.iter()
|
||||
.chain(&f.water)
|
||||
.chain(&f.shadow)
|
||||
.chain(&f.shadow_water)
|
||||
});
|
||||
passes
|
||||
.flat_map(|p| [(p, &p.vertex, "vsh"), (p, &p.fragment, "fsh")])
|
||||
.chain(geometry.flat_map(|g| {
|
||||
let p = &g.program;
|
||||
[(p, &p.vertex, "vsh"), (p, &p.fragment, "fsh")]
|
||||
.into_iter()
|
||||
.chain(g.variants.iter().map(move |v| (p, v, "vsh (flat)")))
|
||||
}))
|
||||
.find_map(|(p, t, ext)| {
|
||||
let e = naga::front::wgsl::parse_str(&t.wgsl).err()?;
|
||||
Some(format!(
|
||||
@@ -283,7 +448,15 @@ mod tests {
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let n = f.passes.len() + usize::from(f.final_pass.is_some());
|
||||
format!("{} {n} passes", f.folder)
|
||||
let geometry: Vec<&str> = f
|
||||
.terrain
|
||||
.iter()
|
||||
.chain(&f.water)
|
||||
.chain(&f.shadow)
|
||||
.chain(&f.shadow_water)
|
||||
.map(|g| g.program.pass.program.as_str())
|
||||
.collect();
|
||||
format!("{} {n} passes, terrain {geometry:?}", f.folder)
|
||||
})
|
||||
.collect();
|
||||
println!("{name}: loads ({})", folders.join(", "));
|
||||
|
||||
@@ -432,6 +432,13 @@ pub fn stack_to_network(
|
||||
item_id: impl Fn(&str) -> i32,
|
||||
enchantment_id: impl Fn(&str) -> Option<i32>,
|
||||
s: &Option<ItemStack>,
|
||||
) -> Option<Stack> {
|
||||
network_stack(&item_id, &enchantment_id, s)
|
||||
}
|
||||
fn network_stack(
|
||||
item_id: &dyn Fn(&str) -> i32,
|
||||
enchantment_id: &dyn Fn(&str) -> Option<i32>,
|
||||
s: &Option<ItemStack>,
|
||||
) -> Option<Stack> {
|
||||
let s = s.as_ref()?;
|
||||
let mut stack = Stack::new(item_id(&s.item), s.count as i32);
|
||||
@@ -488,6 +495,18 @@ pub fn stack_to_network(
|
||||
horizon_protocol::item::Component::Int(i32::from(mode)),
|
||||
));
|
||||
}
|
||||
// A loaded crossbow's ammunition.
|
||||
if !d.charged.is_empty() {
|
||||
let charged = d
|
||||
.charged
|
||||
.iter()
|
||||
.filter_map(|c| network_stack(item_id, enchantment_id, &Some(c.clone())))
|
||||
.collect();
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::CHARGED_PROJECTILES,
|
||||
horizon_protocol::item::Component::Stacks(charged),
|
||||
));
|
||||
}
|
||||
// The books a lectern shows and a guest reads.
|
||||
if !d.writable_pages.is_empty() {
|
||||
stack.added.push((
|
||||
|
||||
@@ -1981,14 +1981,15 @@ impl Hub {
|
||||
self.guests[i].sneaking = p.flags & 32 != 0;
|
||||
self.guests[i].sprinting = p.flags & 64 != 0 && p.flags & 1 != 0;
|
||||
}
|
||||
// OPEN_INVENTORY is the game's: the ridden mount's inventory.
|
||||
S::PlayerCommand(p) if p.action == 5 => {
|
||||
// OPEN_INVENTORY (the ridden mount's inventory) and
|
||||
// START_FALL_FLYING (checked against the guest's chest slot
|
||||
// and effects) are the game's.
|
||||
S::PlayerCommand(p) if matches!(p.action, 5 | 6) => {
|
||||
events.push(HostEvent::Packet(conn, Box::new(S::PlayerCommand(p))));
|
||||
}
|
||||
S::PlayerCommand(p) => match p.action {
|
||||
1 => self.guests[i].sprinting = true,
|
||||
2 => self.guests[i].sprinting = false,
|
||||
6 => self.guests[i].fall_flying = true,
|
||||
// STOP_SLEEPING.
|
||||
0 => self.wake(i),
|
||||
_ => {}
|
||||
|
||||
@@ -2277,7 +2277,7 @@ mod sensor_scan_tests {
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::json;
|
||||
|
||||
fn kind() -> Arc<MobType> {
|
||||
pub(super) fn kind() -> Arc<MobType> {
|
||||
Arc::new(MobType {
|
||||
id: "minecraft:cow".into(),
|
||||
species: Species::Cow,
|
||||
@@ -2481,56 +2481,9 @@ mod sensor_scan_tests {
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod push_tests {
|
||||
use super::sensor_scan_tests::kind;
|
||||
use super::*;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::json;
|
||||
|
||||
fn kind() -> Arc<MobType> {
|
||||
Arc::new(MobType {
|
||||
id: "minecraft:cow".into(),
|
||||
species: Species::Cow,
|
||||
width: 0.9,
|
||||
height: 1.4,
|
||||
eye_height: 1.3,
|
||||
category: "creature".into(),
|
||||
max_health: 10.0,
|
||||
speed: 0.2,
|
||||
flying_speed: 0.4,
|
||||
attack_damage: 0.0,
|
||||
attack_knockback: 0.0,
|
||||
follow_range: 16.0,
|
||||
step_height: 0.6,
|
||||
armor: 0.0,
|
||||
armor_toughness: 0.0,
|
||||
knockback_resistance: 0.0,
|
||||
jump_strength: 0.42,
|
||||
attributes: Arc::default(),
|
||||
gravity: 0.08,
|
||||
safe_fall_distance: 3.0,
|
||||
fall_damage_multiplier: 1.0,
|
||||
water_efficiency: 0.0,
|
||||
water_slowdown: 0.8,
|
||||
air_speed: 0.02,
|
||||
baby_scale: 0.5,
|
||||
head_rot_speed: 10.0,
|
||||
max_head_x_rot: 40.0,
|
||||
max_head_y_rot: 75.0,
|
||||
ambient_interval: 120,
|
||||
flags: Flags {
|
||||
animal: true,
|
||||
..Default::default()
|
||||
},
|
||||
model: Arc::new(Part::parse("root", &json!({}))),
|
||||
layers: HashMap::new(),
|
||||
texture: String::new(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Rabbit,
|
||||
sounds: [""; 3],
|
||||
step_sound: None,
|
||||
food: Arc::default(),
|
||||
tempt: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The pending pushes as one list, scanned for each mob, as before.
|
||||
fn take_old(events: &mut Vec<MobEvent>, from: usize, pending: &mut Vec<(u32, Vec3)>) {
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
//! own random drives positions, sounds and jumps. `RunOne` shuffles with a
|
||||
//! random of its own, seeded with the brain's `shuffle_seed` when it is
|
||||
//! built.
|
||||
use super::{Mob, MobEvent, MobWorld, Species, Who, path::Path, sniffer};
|
||||
use super::{
|
||||
Mob, MobEvent, MobWorld, Species, Who,
|
||||
brain::{Timed, living_near},
|
||||
path::Path,
|
||||
sniffer,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
@@ -603,22 +608,7 @@ fn tick_sensors(m: &mut Mob, w: &MobWorld) {
|
||||
/// `NearestLivingEntitySensor`: everything alive within the follow range,
|
||||
/// nearest first.
|
||||
fn sense_living(m: &mut Mob, w: &MobWorld) {
|
||||
let r = m.attrs.follow_range;
|
||||
let area = m.bounds().inflate(Vec3::new(r, r, r));
|
||||
let mut found: Vec<(Who, f64)> = Vec::new();
|
||||
for o in w.others_near(area) {
|
||||
if o.id != m.id && o.alive && o.bounds.intersects(area) {
|
||||
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
|
||||
}
|
||||
}
|
||||
if let Some(p) = &w.player
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(area)
|
||||
{
|
||||
found.push((Who::Player, m.distance_sqr(p.position)));
|
||||
}
|
||||
found.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let list: Vec<Who> = found.into_iter().map(|(w, _)| w).collect();
|
||||
let list = living_near(m, w);
|
||||
let mm = mem(m);
|
||||
mm.mobs.set_list(list.clone());
|
||||
mm.visible_mobs.set(Visible {
|
||||
@@ -794,37 +784,6 @@ fn followable_friendly(s: Species) -> bool {
|
||||
|
||||
// ---- Behaviours ------------------------------------------------------------
|
||||
|
||||
/// `Behavior`'s running state and end time.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Timed {
|
||||
running: bool,
|
||||
end: i64,
|
||||
min: i32,
|
||||
max: i32,
|
||||
}
|
||||
impl Timed {
|
||||
fn new(min: i32, max: i32) -> Self {
|
||||
Self {
|
||||
running: false,
|
||||
end: 0,
|
||||
min,
|
||||
max,
|
||||
}
|
||||
}
|
||||
/// `Behavior.tryStart`'s duration draw.
|
||||
fn begin(&mut self, cx: &mut Bx) {
|
||||
self.running = true;
|
||||
let bound = self.max.wrapping_add(1).wrapping_sub(self.min);
|
||||
let d = self
|
||||
.min
|
||||
.wrapping_add(cx.lr.next_int_bound(bound).unwrap_or(0));
|
||||
self.end = cx.now + i64::from(d);
|
||||
}
|
||||
fn timed_out(&self, now: i64) -> bool {
|
||||
now > self.end
|
||||
}
|
||||
}
|
||||
|
||||
/// `RunOne`: shuffles its children by weight and starts the first that can.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Gate {
|
||||
@@ -1076,7 +1035,7 @@ impl B {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
mem(m).walk_target.clear();
|
||||
mem(m).look_target.clear();
|
||||
m.add_effect("minecraft:regeneration", 200, 0);
|
||||
@@ -1086,7 +1045,7 @@ impl B {
|
||||
if !swim_condition(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::AnimalPanic { t, .. } => {
|
||||
@@ -1094,7 +1053,7 @@ impl B {
|
||||
if !(mm.hurt_by.get() == Some(&true) || mm.is_panicking.present()) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
if brain_mut(m).kind == Kind::Sniffer {
|
||||
reset_sniffing(m, cx);
|
||||
}
|
||||
@@ -1123,7 +1082,7 @@ impl B {
|
||||
let reached = reached(m, cx.w, &walk);
|
||||
if !reached && compute_path(m, cx, &walk, path, speed) {
|
||||
*last_target = walk.target.block(cx.w);
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
mem(m).path.set_opt(path.clone());
|
||||
m.nav_move_to_path(path.clone(), f64::from(*speed));
|
||||
return true;
|
||||
@@ -1139,21 +1098,21 @@ impl B {
|
||||
if !mem(m).temptation_cooldown_ticks.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::CountDownGaze(t) => {
|
||||
if !mem(m).gaze_cooldown_ticks.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::CountDownTransport(t) => {
|
||||
if !mem(m).transport_items_cooldown_ticks.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::Transport(x) => x.try_start(m, cx),
|
||||
@@ -1171,7 +1130,7 @@ impl B {
|
||||
let Some(partner) = breed_partner(m, cx.w) else {
|
||||
return false;
|
||||
};
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
if brain_mut(m).kind == Kind::Sniffer {
|
||||
reset_sniffing(m, cx);
|
||||
}
|
||||
@@ -1200,7 +1159,7 @@ impl B {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
if brain_mut(m).kind == Kind::Sniffer {
|
||||
reset_sniffing(m, cx);
|
||||
}
|
||||
@@ -1210,14 +1169,14 @@ impl B {
|
||||
if !mem(m).look_target.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::FeelingHappy(t) => {
|
||||
if !mem(m).sniffer_happy.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
transition(m, cx, sniffer::FEELING_HAPPY);
|
||||
true
|
||||
}
|
||||
@@ -1233,7 +1192,7 @@ impl B {
|
||||
if !can_sniff(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
transition(m, cx, sniffer::DIGGING);
|
||||
true
|
||||
}
|
||||
@@ -1246,7 +1205,7 @@ impl B {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
transition(m, cx, sniffer::RISING);
|
||||
true
|
||||
}
|
||||
@@ -1261,7 +1220,7 @@ impl B {
|
||||
if !can_sniff(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
transition(m, cx, sniffer::SEARCHING);
|
||||
true
|
||||
}
|
||||
@@ -1278,7 +1237,7 @@ impl B {
|
||||
if is_tempted(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
transition(m, cx, sniffer::SCENTING);
|
||||
true
|
||||
}
|
||||
@@ -1293,12 +1252,12 @@ impl B {
|
||||
if m.is_baby() || !can_sniff(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
transition(m, cx, sniffer::SNIFFING);
|
||||
true
|
||||
}
|
||||
B::DoNothing(t) => {
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::AxolotlAction { .. }
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Pieces of vanilla's `Brain` that species implemented as brains share:
|
||||
//! memories that expire, behaviour durations, walk targets and
|
||||
//! `MoveToTargetSink`.
|
||||
use super::{Mob, goals::Cx};
|
||||
use horizon_core::random::RandomSource;
|
||||
use super::{Mob, MobWorld, Who, goals::Cx};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
|
||||
/// A memory with an expiry (`Brain.setMemoryWithExpiry`): present for the
|
||||
/// tick it was set and `ttl` more ticks. `Ttl::default()` is absent.
|
||||
@@ -25,6 +28,57 @@ impl Ttl {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Behavior`'s bookkeeping in the brains ported behaviour for behaviour
|
||||
/// (villager, animal, monster, warden): running, and until when.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Timed {
|
||||
pub running: bool,
|
||||
pub end: i64,
|
||||
pub min: i32,
|
||||
pub max: i32,
|
||||
}
|
||||
impl Timed {
|
||||
pub fn new(min: i32, max: i32) -> Self {
|
||||
Self {
|
||||
running: false,
|
||||
end: 0,
|
||||
min,
|
||||
max,
|
||||
}
|
||||
}
|
||||
/// `Behavior.tryStart`'s duration draw from the level random, in Java's
|
||||
/// int arithmetic (made even when it cannot vary).
|
||||
pub fn begin(&mut self, lr: &mut LegacyRandom, now: i64) {
|
||||
self.running = true;
|
||||
let bound = self.max.wrapping_add(1).wrapping_sub(self.min);
|
||||
let d = self.min.wrapping_add(lr.next_int_bound(bound).unwrap_or(0));
|
||||
self.end = now + i64::from(d);
|
||||
}
|
||||
/// `Behavior.timedOut`.
|
||||
pub fn timed_out(&self, now: i64) -> bool {
|
||||
now > self.end
|
||||
}
|
||||
}
|
||||
/// `NearestLivingEntitySensor`'s scan: the living entities (the player
|
||||
/// among them) whose boxes meet the follow range around `m`, nearest first.
|
||||
pub fn living_near(m: &Mob, w: &MobWorld) -> Vec<Who> {
|
||||
let r = m.attrs.follow_range;
|
||||
let area = m.bounds().inflate(Vec3::new(r, r, r));
|
||||
let mut found: Vec<(Who, f64)> = Vec::new();
|
||||
for o in w.others_near(area) {
|
||||
if o.id != m.id && o.alive && o.bounds.intersects(area) {
|
||||
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
|
||||
}
|
||||
}
|
||||
if let Some(p) = &w.player
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(area)
|
||||
{
|
||||
found.push((Who::Player, m.distance_sqr(p.position)));
|
||||
}
|
||||
found.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
found.into_iter().map(|(w, _)| w).collect()
|
||||
}
|
||||
/// A random duration in `min..=max` (`Behavior`'s end time).
|
||||
pub fn duration(m: &mut Mob, min: i32, max: i32) -> i32 {
|
||||
if max <= min {
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
//! seeded when the brain is built.
|
||||
use super::{
|
||||
Mob, MobEvent, MobWorld, Species, Who,
|
||||
brain::{Timed, living_near},
|
||||
goals::Cx,
|
||||
path::Path,
|
||||
villager_brain::{NEVER, Slot, Tracker, Visible, WalkTarget},
|
||||
villager_brain::{NEVER, Slot, Tracker, Visible, WalkTarget, compute_path, reached},
|
||||
};
|
||||
use crate::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{
|
||||
@@ -129,6 +130,10 @@ fn brain_mut(m: &mut Mob) -> &mut Brain {
|
||||
fn mem(m: &mut Mob) -> &mut Memories {
|
||||
&mut brain_mut(m).mem
|
||||
}
|
||||
/// `CANT_REACH_WALK_TARGET_SINCE`, for `compute_path`.
|
||||
fn cant_reach(m: &mut Mob) -> &mut Slot<i64> {
|
||||
&mut mem(m).cant_reach_walk_target_since
|
||||
}
|
||||
fn mem_ref(m: &Mob) -> &Memories {
|
||||
&brain(m).expect("brain").mem
|
||||
}
|
||||
@@ -292,22 +297,7 @@ fn tick_sensors(m: &mut Mob, w: &MobWorld) {
|
||||
/// `NearestLivingEntitySensor`: everything alive within the follow range,
|
||||
/// nearest first.
|
||||
fn sense_living(m: &mut Mob, w: &MobWorld) {
|
||||
let r = m.attrs.follow_range;
|
||||
let area = m.bounds().inflate(Vec3::new(r, r, r));
|
||||
let mut found: Vec<(Who, f64)> = Vec::new();
|
||||
for o in w.others_near(area) {
|
||||
if o.id != m.id && o.alive && o.bounds.intersects(area) {
|
||||
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
|
||||
}
|
||||
}
|
||||
if let Some(p) = &w.player
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(area)
|
||||
{
|
||||
found.push((Who::Player, m.distance_sqr(p.position)));
|
||||
}
|
||||
found.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let list: Vec<Who> = found.into_iter().map(|(w, _)| w).collect();
|
||||
let list = living_near(m, w);
|
||||
let mm = mem(m);
|
||||
mm.mobs.set_list(list.clone());
|
||||
mm.visible_mobs.set(Visible {
|
||||
@@ -456,31 +446,6 @@ fn sense_breeze_attack(m: &mut Mob, w: &MobWorld) {
|
||||
|
||||
// ---- Behaviours ---------------------------------------------------------
|
||||
|
||||
/// `Behavior`'s bookkeeping: running, and until when.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Timed {
|
||||
running: bool,
|
||||
end: i64,
|
||||
min: i32,
|
||||
max: i32,
|
||||
}
|
||||
impl Timed {
|
||||
fn new(min: i32, max: i32) -> Self {
|
||||
Self {
|
||||
running: false,
|
||||
end: 0,
|
||||
min,
|
||||
max,
|
||||
}
|
||||
}
|
||||
/// `Behavior.tryStart`'s duration draw from the level random.
|
||||
fn begin(&mut self, cx: &mut Bx) {
|
||||
self.running = true;
|
||||
let d = self.min + cx.lr.next_int_bound(self.max + 1 - self.min).unwrap_or(0);
|
||||
self.end = cx.now + i64::from(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// `StartAttacking`'s target finders.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Finder {
|
||||
@@ -607,14 +572,14 @@ impl B {
|
||||
if *creaking && !super::creaking::can_move(m) || !swim_condition(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::LookAtTargetSink(t) => {
|
||||
if !mem(m).look_target.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::MoveToTargetSink {
|
||||
@@ -636,9 +601,9 @@ impl B {
|
||||
}
|
||||
let walk = mm.walk_target.value().unwrap();
|
||||
let reached = reached(m, cx.w, &walk);
|
||||
if !reached && compute_path(m, cx, &walk, path, speed) {
|
||||
if !reached && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) {
|
||||
*last_target = walk.target.block(cx.w);
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
// start
|
||||
mem(m).path.set_opt(path.clone());
|
||||
m.nav_move_to_path(path.clone(), f64::from(*speed));
|
||||
@@ -719,7 +684,7 @@ impl B {
|
||||
true
|
||||
}
|
||||
B::DoNothing(t) => {
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::RandomStroll(running, speed) => {
|
||||
@@ -848,7 +813,7 @@ impl B {
|
||||
mem(m).breeze_shoot.clear();
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
// start
|
||||
super::breeze::set_pose(m, super::breeze::SHOOTING);
|
||||
mem(m).breeze_shoot_charging.set_ttl((), 15);
|
||||
@@ -867,7 +832,7 @@ impl B {
|
||||
if !long_jump_can_run(m, cx) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
// start
|
||||
if !mem(m).breeze_jump_inhaling.present() {
|
||||
mem(m).breeze_jump_inhaling.set_ttl((), 10);
|
||||
@@ -905,7 +870,7 @@ impl B {
|
||||
if !stuck {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
mem(m).breeze_shoot.set_ttl((), 60);
|
||||
true
|
||||
}
|
||||
@@ -924,7 +889,7 @@ impl B {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
slide_start(m, cx);
|
||||
true
|
||||
}
|
||||
@@ -989,7 +954,7 @@ impl B {
|
||||
&& let Some(now_target) = walk.target.block(cx.w)
|
||||
{
|
||||
let d = manhattan_sq(now_target, last);
|
||||
if d > 4 && compute_path(m, cx, &walk, path, speed) {
|
||||
if d > 4 && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) {
|
||||
*last_target = Some(now_target);
|
||||
mem(m).path.set_opt(path.clone());
|
||||
m.nav_move_to_path(path.clone(), f64::from(*speed));
|
||||
@@ -1122,53 +1087,6 @@ fn tracker_visible(m: &mut Mob, w: &MobWorld, t: Tracker) -> bool {
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
/// `MoveToTargetSink.reachedTarget`.
|
||||
fn reached(m: &Mob, w: &MobWorld, walk: &WalkTarget) -> bool {
|
||||
walk.target.block(w).is_some_and(|p| {
|
||||
let h = block_of(m.position);
|
||||
(p[0] - h[0]).abs() + (p[1] - h[1]).abs() + (p[2] - h[2]).abs() <= walk.close
|
||||
})
|
||||
}
|
||||
/// `MoveToTargetSink.tryComputePath`.
|
||||
fn compute_path(
|
||||
m: &mut Mob,
|
||||
cx: &mut Bx,
|
||||
walk: &WalkTarget,
|
||||
path: &mut Option<Path>,
|
||||
speed: &mut f32,
|
||||
) -> bool {
|
||||
let Some(target) = walk.target.block(cx.w) else {
|
||||
return false;
|
||||
};
|
||||
*path = m.create_path_to(cx.w, target, 0);
|
||||
*speed = walk.speed;
|
||||
if reached(m, cx.w, walk) {
|
||||
mem(m).cant_reach_walk_target_since.clear();
|
||||
} else {
|
||||
let can_reach = path.as_ref().is_some_and(|p| p.reached);
|
||||
let mm = mem(m);
|
||||
if can_reach {
|
||||
mm.cant_reach_walk_target_since.clear();
|
||||
} else if !mm.cant_reach_walk_target_since.present() {
|
||||
mm.cant_reach_walk_target_since.set(cx.now);
|
||||
}
|
||||
if path.is_some() {
|
||||
return true;
|
||||
}
|
||||
let to = [
|
||||
f64::from(target[0]) + 0.5,
|
||||
f64::from(target[1]),
|
||||
f64::from(target[2]) + 0.5,
|
||||
];
|
||||
if let Some(step) =
|
||||
m.default_random_pos_towards(cx.w, 10, 7, to, f64::from(std::f32::consts::FRAC_PI_2))
|
||||
{
|
||||
*path = m.create_path_to(cx.w, block_of(Vec3::new(step[0], step[1], step[2])), 0);
|
||||
return path.is_some();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `BehaviorUtils.isWithinAttackRange(body, target, 1)` for a mob without a
|
||||
/// ranged weapon: `Mob.isWithinMeleeAttackRange`.
|
||||
fn within_attack_range(m: &Mob, w: &MobWorld, target: Who) -> bool {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! `Brain::shuffle_seed` when the brain is built.
|
||||
use super::{
|
||||
Mob, MobEvent, MobWorld, Species, Who,
|
||||
brain::{Timed, living_near},
|
||||
path::{Path, PathNode},
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -341,6 +342,10 @@ fn brain_mut(m: &mut Mob) -> &mut Brain {
|
||||
fn mem(m: &mut Mob) -> &mut Memories {
|
||||
&mut brain_mut(m).mem
|
||||
}
|
||||
/// `CANT_REACH_WALK_TARGET_SINCE`, for `compute_path`.
|
||||
fn cant_reach(m: &mut Mob) -> &mut Slot<i64> {
|
||||
&mut mem(m).cant_reach_walk_target_since
|
||||
}
|
||||
fn mem_ref(m: &Mob) -> Option<&Memories> {
|
||||
brain(m).map(|b| &b.mem)
|
||||
}
|
||||
@@ -565,22 +570,7 @@ const FOLLOW_RANGE: f64 = 16.0;
|
||||
/// `NearestLivingEntitySensor`: everything alive within the follow range,
|
||||
/// nearest first.
|
||||
fn sense_living(m: &mut Mob, w: &MobWorld) {
|
||||
let r = m.attrs.follow_range;
|
||||
let area = m.bounds().inflate(Vec3::new(r, r, r));
|
||||
let mut found: Vec<(Who, f64)> = Vec::new();
|
||||
for o in w.others_near(area) {
|
||||
if o.id != m.id && o.alive && o.bounds.intersects(area) {
|
||||
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
|
||||
}
|
||||
}
|
||||
if let Some(p) = &w.player
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(area)
|
||||
{
|
||||
found.push((Who::Player, m.distance_sqr(p.position)));
|
||||
}
|
||||
found.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let list: Vec<Who> = found.into_iter().map(|(w, _)| w).collect();
|
||||
let list = living_near(m, w);
|
||||
let mm = mem(m);
|
||||
mm.mobs.set_list(list.clone());
|
||||
mm.visible_mobs.set(Visible {
|
||||
@@ -791,34 +781,6 @@ fn sense_golem(m: &mut Mob, w: &MobWorld) {
|
||||
|
||||
// ---- Behaviours ---------------------------------------------------------
|
||||
|
||||
/// `Behavior`'s bookkeeping: running, and until when.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Timed {
|
||||
running: bool,
|
||||
end: i64,
|
||||
min: i32,
|
||||
max: i32,
|
||||
}
|
||||
impl Timed {
|
||||
fn new(min: i32, max: i32) -> Self {
|
||||
Self {
|
||||
running: false,
|
||||
end: 0,
|
||||
min,
|
||||
max,
|
||||
}
|
||||
}
|
||||
/// `Behavior.tryStart`'s duration draw.
|
||||
fn begin(&mut self, cx: &mut Bx) {
|
||||
self.running = true;
|
||||
let bound = self.max.wrapping_add(1).wrapping_sub(self.min);
|
||||
let d = self
|
||||
.min
|
||||
.wrapping_add(cx.lr.next_int_bound(bound).unwrap_or(0));
|
||||
self.end = cx.now + i64::from(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// The POI types villagers use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Poi {
|
||||
@@ -1498,18 +1460,18 @@ impl B {
|
||||
if !swim_condition(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::LookAtTargetSink(t) => {
|
||||
if !mem(m).look_target.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::PanicTrigger(t) => {
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
// VillagerPanicTrigger.start
|
||||
if panic_condition(m) {
|
||||
if !brain_mut(m).is_active(Act::Panic) {
|
||||
@@ -1542,9 +1504,9 @@ impl B {
|
||||
}
|
||||
let walk = mm.walk_target.value().unwrap();
|
||||
let reached = reached(m, cx.w, &walk);
|
||||
if !reached && compute_path(m, cx, &walk, path, speed) {
|
||||
if !reached && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) {
|
||||
*last_target = walk.target.block(cx.w);
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
// start
|
||||
mem(m).path.set_opt(path.clone());
|
||||
m.nav_move_to_path(path.clone(), f64::from(*speed));
|
||||
@@ -1561,7 +1523,7 @@ impl B {
|
||||
if !follow_trading_condition(m, cx.w) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
follow_trading_player(m);
|
||||
true
|
||||
}
|
||||
@@ -1573,7 +1535,7 @@ impl B {
|
||||
if !matches!(a, Act::Idle | Act::Work | Act::Play) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::JumpOnBed {
|
||||
@@ -1590,7 +1552,7 @@ impl B {
|
||||
if mm.walk_target.present() || !m.is_baby() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
*bed = Some(nearest);
|
||||
*time_left = 100;
|
||||
*jumps = 3 + cx.lr.next_int_bound(4).unwrap_or(0);
|
||||
@@ -1599,7 +1561,7 @@ impl B {
|
||||
true
|
||||
}
|
||||
B::DoNothing(t) => {
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::GiveGiftToHero {
|
||||
@@ -1618,7 +1580,7 @@ impl B {
|
||||
*until_next -= 1;
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
*given = false;
|
||||
*started = now;
|
||||
let mm = mem(m);
|
||||
@@ -1630,7 +1592,7 @@ impl B {
|
||||
if !mem(m).interaction_target.present() || !show_trades_condition(m, cx.w) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
// start
|
||||
if let Some(target) = mem(m).interaction_target.value() {
|
||||
mem(m).look_target.set(Tracker::Entity(target, true));
|
||||
@@ -1646,7 +1608,7 @@ impl B {
|
||||
if !villager_target_valid(m, cx.w, Mem::InteractionTarget) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
let target = mem(m).interaction_target.value().unwrap();
|
||||
lock_gaze(m, target, cx);
|
||||
true
|
||||
@@ -1659,7 +1621,7 @@ impl B {
|
||||
if !breeding_possible(m, cx.w) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
let target = mem(m).breed_target.value().unwrap();
|
||||
lock_gaze(m, target, cx);
|
||||
*birth = now + 275 + i64::from(m.random.next_int_bound(50).unwrap_or(0));
|
||||
@@ -1680,7 +1642,7 @@ impl B {
|
||||
if !closer_to_center(site, m.position, 1.73) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
// start
|
||||
let mm = mem(m);
|
||||
mm.last_worked_at_poi.set(now);
|
||||
@@ -1709,7 +1671,7 @@ impl B {
|
||||
*valid = farmland_around(m, cx.w);
|
||||
*target = pick_farmland(valid, cx);
|
||||
let Some(p) = *target else { return false };
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
if now > *next_ok {
|
||||
let mm = mem(m);
|
||||
mm.look_target.set(Tracker::Block(p));
|
||||
@@ -2288,7 +2250,7 @@ impl B {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
if now > *next_ok {
|
||||
if super::villager::start_sleeping(m, home, w) {
|
||||
mem(m).last_slept.set(now);
|
||||
@@ -2609,7 +2571,7 @@ impl B {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::LocateHidingPlace {
|
||||
@@ -2769,7 +2731,7 @@ impl B {
|
||||
&& let Some(now_target) = walk.target.block(cx.w)
|
||||
{
|
||||
let d = manhattan_sq(now_target, last);
|
||||
if d > 4 && compute_path(m, cx, &walk, path, speed) {
|
||||
if d > 4 && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) {
|
||||
*last_target = Some(now_target);
|
||||
mem(m).path.set_opt(path.clone());
|
||||
m.nav_move_to_path(path.clone(), f64::from(*speed));
|
||||
@@ -3141,33 +3103,37 @@ fn tracker_visible(m: &mut Mob, w: &MobWorld, t: Tracker) -> bool {
|
||||
}
|
||||
}
|
||||
/// `MoveToTargetSink.reachedTarget`.
|
||||
fn reached(m: &Mob, w: &MobWorld, walk: &WalkTarget) -> bool {
|
||||
pub(super) fn reached(m: &Mob, w: &MobWorld, walk: &WalkTarget) -> bool {
|
||||
walk.target
|
||||
.block(w)
|
||||
.is_some_and(|p| manhattan(p, block_of(m.position)) <= walk.close)
|
||||
}
|
||||
/// `MoveToTargetSink.tryComputePath`.
|
||||
fn compute_path(
|
||||
/// `MoveToTargetSink.tryComputePath`, for the brains on these memories
|
||||
/// (villager, monster, warden): `cant_reach` is the brain's
|
||||
/// `CANT_REACH_WALK_TARGET_SINCE`.
|
||||
pub(super) fn compute_path(
|
||||
m: &mut Mob,
|
||||
cx: &mut Bx,
|
||||
w: &MobWorld,
|
||||
now: i64,
|
||||
walk: &WalkTarget,
|
||||
path: &mut Option<Path>,
|
||||
speed: &mut f32,
|
||||
cant_reach: fn(&mut Mob) -> &mut Slot<i64>,
|
||||
) -> bool {
|
||||
let Some(target) = walk.target.block(cx.w) else {
|
||||
let Some(target) = walk.target.block(w) else {
|
||||
return false;
|
||||
};
|
||||
*path = m.create_path_to(cx.w, target, 0);
|
||||
*path = m.create_path_to(w, target, 0);
|
||||
*speed = walk.speed;
|
||||
if reached(m, cx.w, walk) {
|
||||
mem(m).cant_reach_walk_target_since.clear();
|
||||
if reached(m, w, walk) {
|
||||
cant_reach(m).clear();
|
||||
} else {
|
||||
let can_reach = path.as_ref().is_some_and(|p| p.reached);
|
||||
let mm = mem(m);
|
||||
let since = cant_reach(m);
|
||||
if can_reach {
|
||||
mm.cant_reach_walk_target_since.clear();
|
||||
} else if !mm.cant_reach_walk_target_since.present() {
|
||||
mm.cant_reach_walk_target_since.set(cx.now);
|
||||
since.clear();
|
||||
} else if !since.present() {
|
||||
since.set(now);
|
||||
}
|
||||
if path.is_some() {
|
||||
return true;
|
||||
@@ -3178,14 +3144,14 @@ fn compute_path(
|
||||
f64::from(target[2]) + 0.5,
|
||||
];
|
||||
if let Some(step) =
|
||||
m.default_random_pos_towards(cx.w, 10, 7, to, f64::from(std::f32::consts::FRAC_PI_2))
|
||||
m.default_random_pos_towards(w, 10, 7, to, f64::from(std::f32::consts::FRAC_PI_2))
|
||||
{
|
||||
let p = [
|
||||
step[0].floor() as i32,
|
||||
step[1].floor() as i32,
|
||||
step[2].floor() as i32,
|
||||
];
|
||||
*path = m.create_path_to(cx.w, p, 0);
|
||||
*path = m.create_path_to(w, p, 0);
|
||||
return path.is_some();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
//! shuffle seed.
|
||||
use super::{
|
||||
Mob, MobEvent, MobWorld, Who,
|
||||
brain::Timed,
|
||||
goals::Cx,
|
||||
path::Path,
|
||||
villager_brain::{Slot, Tracker, Visible, WalkTarget},
|
||||
villager_brain::{Slot, Tracker, Visible, WalkTarget, compute_path, reached},
|
||||
warden::{self, Pose},
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -145,31 +146,6 @@ impl Memories {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Behavior`'s bookkeeping: running, and until when.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Timed {
|
||||
running: bool,
|
||||
end: i64,
|
||||
min: i32,
|
||||
max: i32,
|
||||
}
|
||||
impl Timed {
|
||||
fn new(min: i32, max: i32) -> Self {
|
||||
Self {
|
||||
running: false,
|
||||
end: 0,
|
||||
min,
|
||||
max,
|
||||
}
|
||||
}
|
||||
/// `Behavior.tryStart`'s duration draw (made even when it cannot vary).
|
||||
fn begin(&mut self, cx: &mut Bx) {
|
||||
self.running = true;
|
||||
let d = self.min + cx.lr.next_int_bound(self.max + 1 - self.min).unwrap_or(0);
|
||||
self.end = cx.now + i64::from(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// `RunOne`'s `ShufflingList` over a stroll (weight 2) and `DoNothing` (1).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Gate {
|
||||
@@ -383,6 +359,10 @@ fn brain(m: &Mob) -> Option<&Brain> {
|
||||
pub(super) fn mem(m: &mut Mob) -> &mut Memories {
|
||||
&mut m.extra.warden.as_mut().expect("a warden").brain.mem
|
||||
}
|
||||
/// `CANT_REACH_WALK_TARGET_SINCE`, for `compute_path`.
|
||||
fn cant_reach(m: &mut Mob) -> &mut Slot<i64> {
|
||||
&mut mem(m).cant_reach_walk_target_since
|
||||
}
|
||||
pub(super) fn mem_ref(m: &Mob) -> Option<&Memories> {
|
||||
brain(m).map(|b| &b.mem)
|
||||
}
|
||||
@@ -558,9 +538,6 @@ fn find_closest(m: &mut Mob, w: &MobWorld, filter: impl Fn(&Mob, Who) -> bool) -
|
||||
fn block_of(p: Vec3) -> [i32; 3] {
|
||||
[p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]
|
||||
}
|
||||
fn manhattan(a: [i32; 3], b: [i32; 3]) -> i32 {
|
||||
(a[0] - b[0]).abs() + (a[1] - b[1]).abs() + (a[2] - b[2]).abs()
|
||||
}
|
||||
fn dist_sqr(a: [i32; 3], b: [i32; 3]) -> i64 {
|
||||
(0..3).map(|i| i64::from(a[i] - b[i]).pow(2)).sum()
|
||||
}
|
||||
@@ -607,57 +584,6 @@ fn tracker_visible(m: &mut Mob, w: &MobWorld, t: Tracker) -> bool {
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
/// `MoveToTargetSink.reachedTarget`.
|
||||
fn reached(m: &Mob, w: &MobWorld, walk: &WalkTarget) -> bool {
|
||||
walk.target
|
||||
.block(w)
|
||||
.is_some_and(|p| manhattan(p, block_of(m.position)) <= walk.close)
|
||||
}
|
||||
/// `MoveToTargetSink.tryComputePath`.
|
||||
fn compute_path(
|
||||
m: &mut Mob,
|
||||
cx: &mut Bx,
|
||||
walk: &WalkTarget,
|
||||
path: &mut Option<Path>,
|
||||
speed: &mut f32,
|
||||
) -> bool {
|
||||
let Some(target) = walk.target.block(cx.w) else {
|
||||
return false;
|
||||
};
|
||||
*path = m.create_path_to(cx.w, target, 0);
|
||||
*speed = walk.speed;
|
||||
if reached(m, cx.w, walk) {
|
||||
mem(m).cant_reach_walk_target_since.clear();
|
||||
} else {
|
||||
let can_reach = path.as_ref().is_some_and(|p| p.reached);
|
||||
let mm = mem(m);
|
||||
if can_reach {
|
||||
mm.cant_reach_walk_target_since.clear();
|
||||
} else if !mm.cant_reach_walk_target_since.present() {
|
||||
mm.cant_reach_walk_target_since.set(cx.now);
|
||||
}
|
||||
if path.is_some() {
|
||||
return true;
|
||||
}
|
||||
let to = [
|
||||
f64::from(target[0]) + 0.5,
|
||||
f64::from(target[1]),
|
||||
f64::from(target[2]) + 0.5,
|
||||
];
|
||||
if let Some(step) =
|
||||
m.default_random_pos_towards(cx.w, 10, 7, to, f64::from(std::f32::consts::FRAC_PI_2))
|
||||
{
|
||||
let p = [
|
||||
step[0].floor() as i32,
|
||||
step[1].floor() as i32,
|
||||
step[2].floor() as i32,
|
||||
];
|
||||
*path = m.create_path_to(cx.w, p, 0);
|
||||
return path.is_some();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
fn same_path(a: Option<&Path>, b: Option<&Path>) -> bool {
|
||||
match (a, b) {
|
||||
(None, None) => true,
|
||||
@@ -710,7 +636,7 @@ impl B {
|
||||
if !swim_condition(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::SetWardenLookTarget(r) => one(r, || {
|
||||
@@ -732,7 +658,7 @@ impl B {
|
||||
if !mem(m).look_target.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::MoveToTargetSink {
|
||||
@@ -755,9 +681,9 @@ impl B {
|
||||
return false;
|
||||
};
|
||||
let reached = reached(m, cx.w, &walk);
|
||||
if !reached && compute_path(m, cx, &walk, path, speed) {
|
||||
if !reached && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) {
|
||||
*last_target = walk.target.block(cx.w);
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
// start
|
||||
mem(m).path.set_opt(path.clone());
|
||||
m.nav_move_to_path(path.clone(), f64::from(*speed));
|
||||
@@ -775,7 +701,7 @@ impl B {
|
||||
if !mm.is_emerging.present() || mm.walk_target.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
warden::set_pose(m, Pose::Emerging);
|
||||
sound(m, "entity.warden.emerge", 5.0, 1.0, cx.events);
|
||||
true
|
||||
@@ -791,7 +717,7 @@ impl B {
|
||||
if !(m.on_ground || liquid) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
if m.on_ground {
|
||||
warden::set_pose(m, Pose::Digging);
|
||||
sound(m, "entity.warden.dig", 5.0, 1.0, cx.events);
|
||||
@@ -872,7 +798,7 @@ impl B {
|
||||
true
|
||||
}),
|
||||
B::DoNothing(t) => {
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::GoToDisturbance(r) => one(r, || {
|
||||
@@ -907,7 +833,7 @@ impl B {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
sound(m, "entity.warden.sniff", 5.0, 1.0, cx.events);
|
||||
true
|
||||
}
|
||||
@@ -919,7 +845,7 @@ impl B {
|
||||
let Some(target) = mm.roar_target.value() else {
|
||||
return false;
|
||||
};
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
let mm = mem(m);
|
||||
mm.roar_sound_delay.set_ttl((), ROAR_SOUND_DELAY);
|
||||
mm.walk_target.clear();
|
||||
@@ -1004,7 +930,7 @@ impl B {
|
||||
if !close {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
let mm = mem(m);
|
||||
mm.attack_cooling_down
|
||||
.set_ttl(true, i64::from(SONIC_DURATION));
|
||||
@@ -1092,7 +1018,7 @@ impl B {
|
||||
&& let Some(walk) = mem(m).walk_target.value()
|
||||
&& let Some(now_target) = walk.target.block(cx.w)
|
||||
&& dist_sqr(now_target, last) > 4
|
||||
&& compute_path(m, cx, &walk, path, speed)
|
||||
&& compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach)
|
||||
{
|
||||
*last_target = Some(now_target);
|
||||
mem(m).path.set_opt(path.clone());
|
||||
|
||||
@@ -1336,6 +1336,16 @@ impl ParticleEngine {
|
||||
ParticleType::ExplosionEmitter
|
||||
};
|
||||
self.add(&ParticleOptions::simple(ty), center, [1.0, 0.0, 0.0]);
|
||||
self.explosion_blocks(center, radius, block_count, is_air);
|
||||
}
|
||||
/// `ClientExplosionTracker` with `Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES`.
|
||||
pub fn explosion_blocks(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
block_count: usize,
|
||||
is_air: &dyn Fn([i32; 3]) -> bool,
|
||||
) {
|
||||
for _ in 0..block_count.min(512) {
|
||||
let r = &mut self.random;
|
||||
let d = Vec3::new(
|
||||
|
||||
@@ -103,7 +103,10 @@ pub fn shadow(
|
||||
let v = |wz: f64| (-((wz - pos.z) as f32) / 2.0 / radius + 0.5).clamp(0.0, 1.0);
|
||||
let a = (alpha * 255.0) as u32;
|
||||
let color = a << 24 | 0x00FF_FFFF;
|
||||
let top = f64::from(y) + 0.001;
|
||||
// ShadowFeatureRenderer: relativeY + shapeBelow.minY, the block top
|
||||
// itself. A model's feet sit 0.001 above it (LivingEntityRenderer's
|
||||
// translate(0, -1.501, 0)), so a lifted shadow fights with them.
|
||||
let top = f64::from(y);
|
||||
for (wx, wz) in [(bx0, bz0), (bx0, bz1), (bx1, bz1), (bx1, bz0)] {
|
||||
let p = DVec3::new(wx, top, wz) - camera;
|
||||
out.push(Vertex {
|
||||
@@ -113,7 +116,7 @@ pub fn shadow(
|
||||
sprite.u0 + (sprite.u1 - sprite.u0) * u(wx),
|
||||
sprite.v0 + (sprite.v1 - sprite.v0) * v(wz),
|
||||
],
|
||||
layer: 0,
|
||||
layer: crate::gfx::SHADOW_OVERLAY,
|
||||
color,
|
||||
light: 240 | 240 << 8,
|
||||
});
|
||||
@@ -127,6 +130,52 @@ pub fn shadow(
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn shadows_lie_on_the_block_top_under_model_feet() {
|
||||
use crate::chunk::Chunk;
|
||||
use horizon_world::level::data;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
let root = std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("gameplay-facts.json").exists() {
|
||||
eprintln!("skipping resource-backed shadow check: {}", root.display());
|
||||
return;
|
||||
}
|
||||
let registry = data::load_registry(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, ®istry).unwrap();
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let mut world = World::new(facts.air, 0);
|
||||
world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
world.set(x, 63, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
let (camera, pos) = (DVec3::new(8.5, 66.0, 4.0), DVec3::new(8.5, 64.0, 8.5));
|
||||
let mut out = Vec::new();
|
||||
let sprite = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
shadow(
|
||||
&world, &facts, sprite, camera, pos, 0.3, 1.0, 0.0, 0, &mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 4);
|
||||
let feet = (pos.y + (1.501 - 24.0 / 16.0) - camera.y) as f32;
|
||||
for v in &out {
|
||||
assert_eq!(v.pos[1], (pos.y - camera.y) as f32);
|
||||
assert!(v.pos[1] < feet);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn vanilla_radii() {
|
||||
assert_eq!(shadow_params("minecraft:zombie"), (0.5, 1.0));
|
||||
assert_eq!(shadow_params("minecraft:item"), (0.15, 0.75));
|
||||
|
||||
@@ -17,7 +17,7 @@ pub mod pyjson;
|
||||
mod sounds;
|
||||
mod survival;
|
||||
|
||||
pub use sounds::{fonts, panorama, sounds};
|
||||
pub use sounds::{fonts, packs, panorama, sounds};
|
||||
|
||||
use pyjson::Value;
|
||||
use sha2::Digest;
|
||||
@@ -692,7 +692,7 @@ fn steps(
|
||||
}
|
||||
|
||||
// The JAR holds only gray 1x1 panorama placeholders and an empty Unifont
|
||||
// include. Import the indexed ones afterwards, including on reruns that just
|
||||
// include, and none of the built-in packs. Import the indexed ones afterwards, including on reruns that just
|
||||
// extracted the JAR again. They are optional when preparing from
|
||||
// already-imported JARs.
|
||||
sounds::optional_assets(repo, minecraft, run.log);
|
||||
|
||||
@@ -19,6 +19,7 @@ Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [-
|
||||
horizon-prepare sounds [--minecraft-dir PATH] [--root PATH]
|
||||
horizon-prepare panorama [--minecraft-dir PATH] [--root PATH]
|
||||
horizon-prepare fonts [--minecraft-dir PATH] [--root PATH]
|
||||
horizon-prepare packs [--minecraft-dir PATH] [--root PATH]
|
||||
|
||||
--prepare Import the pinned game files from your own copy first
|
||||
--server-jar PATH The pinned server jar you downloaded yourself
|
||||
@@ -34,7 +35,8 @@ The resources go to <root>/.local/horizon-client/<version>; pass that to horizon
|
||||
|
||||
horizon-prepare panorama repairs the classic menu background in existing resources without Java.
|
||||
horizon-prepare fonts adds the GNU Unifont glyphs (characters the bitmap fonts lack) the same way.
|
||||
Full preparation also imports both after extracting the client JAR's placeholders.
|
||||
horizon-prepare packs adds the built-in Programmer Art and High Contrast resource packs the same way.
|
||||
Full preparation also imports all three after extracting the client JAR's placeholders.
|
||||
|
||||
horizon-prepare sounds copies the pinned version's sounds (about 380 MB) from the official
|
||||
launcher's asset store into the same folder, checking every file. It is optional: without it the
|
||||
@@ -66,7 +68,8 @@ fn main() -> ExitCode {
|
||||
};
|
||||
let mut root = None;
|
||||
let mut args = std::env::args_os().skip(1).peekable();
|
||||
let assets = args.next_if(|a| a == "sounds" || a == "panorama" || a == "fonts");
|
||||
let assets =
|
||||
args.next_if(|a| matches!(a.to_str(), Some("sounds" | "panorama" | "fonts" | "packs")));
|
||||
while let Some(arg) = args.next() {
|
||||
let text = arg.to_string_lossy().into_owned();
|
||||
let (flag, inline) = match text.split_once('=') {
|
||||
@@ -132,6 +135,9 @@ fn main() -> ExitCode {
|
||||
Some("fonts") => {
|
||||
horizon_prepare::fonts(&options.root, options.minecraft_dir.as_deref(), &log)
|
||||
}
|
||||
Some("packs") => {
|
||||
horizon_prepare::packs(&options.root, options.minecraft_dir.as_deref(), &log)
|
||||
}
|
||||
_ => horizon_prepare::prepare(&options, &log),
|
||||
};
|
||||
match result {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The pinned version's sounds and title panorama from the player's own official
|
||||
//! launcher install, each object checked against the asset index's SHA-1, copied into
|
||||
//! The pinned version's sounds, title panorama, Unifont glyphs and built-in resource packs
|
||||
//! from the player's own official launcher install, each object checked against the asset index's SHA-1, copied into
|
||||
//! `.local/horizon-client/<version>/assets` where the game reads them (tools/fetch_sounds.py and
|
||||
//! the tools/lab.py functions it called). Nothing is downloaded.
|
||||
|
||||
@@ -124,6 +124,11 @@ pub(crate) fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
|
||||
"Unifont glyphs not imported: {error}; run horizon-prepare fonts later"
|
||||
));
|
||||
}
|
||||
if let Err(error) = packs(root, minecraft, log) {
|
||||
log(&format!(
|
||||
"Programmer Art and High Contrast not imported: {error}; run horizon-prepare packs later"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const PANORAMA: [&str; 7] = [
|
||||
@@ -145,6 +150,30 @@ const UNIFONT: [&str; 2] = [
|
||||
"minecraft/font/unifont.zip",
|
||||
];
|
||||
|
||||
/// Adds the built-in Programmer Art and High Contrast packs, which only the asset index holds
|
||||
/// (`BuiltInPackSource.populatePackList` lists `minecraft:resourcepacks` of the vanilla pack).
|
||||
/// Packs made to sit on Programmer Art, like Golden Days, show the default textures without it.
|
||||
/// Also usable on prepared resources without Java.
|
||||
pub fn packs(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf, String> {
|
||||
copy_assets(
|
||||
root,
|
||||
minecraft,
|
||||
log,
|
||||
is_built_in_pack,
|
||||
"Built-in pack",
|
||||
&BUILT_IN_PACKS,
|
||||
)
|
||||
}
|
||||
|
||||
const BUILT_IN_PACKS: [&str; 2] = [
|
||||
"minecraft/resourcepacks/programmer_art.zip",
|
||||
"minecraft/resourcepacks/high_contrast.zip",
|
||||
];
|
||||
|
||||
fn is_built_in_pack(name: &str) -> bool {
|
||||
name.starts_with("minecraft/resourcepacks/")
|
||||
}
|
||||
|
||||
/// The fonts only the asset index holds (the JAR has the bitmap fonts).
|
||||
fn is_font(name: &str) -> bool {
|
||||
name.starts_with("minecraft/font/")
|
||||
@@ -228,11 +257,13 @@ mod tests {
|
||||
crate::hex(&sha1::Sha1::digest(data))
|
||||
}
|
||||
|
||||
/// Where the fixture's panorama objects start.
|
||||
const FIRST_FACE: usize = 6;
|
||||
/// Where the fixture's built-in packs and panorama objects start.
|
||||
const FIRST_PACK: usize = 6;
|
||||
const FIRST_FACE: usize = 8;
|
||||
|
||||
/// A Horizon folder and an official launcher directory holding version `9.9` with
|
||||
/// sounds, a language file and the full title panorama in its asset store.
|
||||
/// sounds, a language file, Unifont, the built-in packs and the full title panorama in its
|
||||
/// asset store.
|
||||
struct Fixture {
|
||||
dir: PathBuf,
|
||||
root: PathBuf,
|
||||
@@ -263,6 +294,8 @@ mod tests {
|
||||
b"{\"providers\": [{\"type\": \"unihex\"}]}".to_vec(),
|
||||
),
|
||||
(UNIFONT[1], b"PK unifont".to_vec()),
|
||||
(BUILT_IN_PACKS[0], b"PK programmer art".to_vec()),
|
||||
(BUILT_IN_PACKS[1], b"PK high contrast".to_vec()),
|
||||
];
|
||||
for &name in panorama {
|
||||
// Distinct object bytes let the test catch swapped faces too.
|
||||
@@ -499,15 +532,25 @@ mod tests {
|
||||
lines[0],
|
||||
format!("Title panorama not imported: {error}; run horizon-prepare panorama later")
|
||||
);
|
||||
// The fonts come on their own when only a panorama face is missing.
|
||||
// The fonts and packs come on their own when only a panorama face is missing.
|
||||
match missing {
|
||||
"last-object" => assert_eq!(lines[1], "Font assets ready: 2 files, 0.0 MB"),
|
||||
_ => assert!(
|
||||
lines[1].starts_with("Unifont glyphs not imported: "),
|
||||
"{lines:?}"
|
||||
"last-object" => assert_eq!(
|
||||
lines[1..],
|
||||
[
|
||||
"Font assets ready: 2 files, 0.0 MB",
|
||||
"Built-in pack assets ready: 2 files, 0.0 MB"
|
||||
]
|
||||
),
|
||||
_ => {
|
||||
assert!(lines[1].starts_with("Unifont glyphs not imported: "));
|
||||
assert!(
|
||||
lines[2].starts_with("Programmer Art and High Contrast not imported: ")
|
||||
&& lines[2].ends_with("; run horizon-prepare packs later"),
|
||||
"{lines:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines.len(), 3);
|
||||
for name in PANORAMA {
|
||||
assert_eq!(fs::read(target.join(name)).unwrap(), b"jar placeholder");
|
||||
}
|
||||
@@ -528,7 +571,7 @@ mod tests {
|
||||
lines.borrow_mut().push(l.to_string())
|
||||
});
|
||||
assert_eq!(done.unwrap(), target);
|
||||
for (name, data) in &f.objects[4..FIRST_FACE] {
|
||||
for (name, data) in &f.objects[4..FIRST_PACK] {
|
||||
assert_eq!(&fs::read(target.join("assets").join(name)).unwrap(), data);
|
||||
}
|
||||
assert_eq!(*lines.borrow(), ["Font assets ready: 2 files, 0.0 MB"]);
|
||||
@@ -536,6 +579,31 @@ mod tests {
|
||||
assert!(!target.join("assets/minecraft/textures").exists());
|
||||
}
|
||||
|
||||
/// Golden Days is made to sit on Programmer Art, which the client JAR lacks: the asset
|
||||
/// index has it, where `BuiltInPackSource` lists `minecraft:resourcepacks`.
|
||||
#[test]
|
||||
fn packs_add_programmer_art_and_high_contrast_where_the_pack_list_finds_them() {
|
||||
let f = Fixture::new("packs");
|
||||
let target = f.root.join(".local/horizon-client/9.9");
|
||||
let lines = std::cell::RefCell::new(Vec::new());
|
||||
let done = packs(&f.root, Some(&f.minecraft), &|l| {
|
||||
lines.borrow_mut().push(l.to_string())
|
||||
});
|
||||
assert_eq!(done.unwrap(), target);
|
||||
for (name, data) in &f.objects[FIRST_PACK..FIRST_FACE] {
|
||||
assert_eq!(&fs::read(target.join("assets").join(name)).unwrap(), data);
|
||||
}
|
||||
assert!(target
|
||||
.join("assets/minecraft/resourcepacks/programmer_art.zip")
|
||||
.is_file());
|
||||
assert_eq!(
|
||||
*lines.borrow(),
|
||||
["Built-in pack assets ready: 2 files, 0.0 MB"]
|
||||
);
|
||||
assert!(!target.join("assets/minecraft/sounds.json").exists());
|
||||
assert!(!target.join("assets/minecraft/font").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_index_must_hold_the_sounds() {
|
||||
let f = Fixture::new("index");
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod component {
|
||||
pub const DYED_COLOR: i32 = 47;
|
||||
pub const MAP_ID: i32 = 48;
|
||||
pub const MAP_POST_PROCESSING: i32 = 50;
|
||||
pub const CHARGED_PROJECTILES: i32 = 51;
|
||||
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
|
||||
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
|
||||
}
|
||||
@@ -75,6 +76,8 @@ pub enum Component {
|
||||
/// `WritableBookContent`: the pages (raw text).
|
||||
WritableBook(Vec<String>),
|
||||
WrittenBook(WrittenBook),
|
||||
/// `ChargedProjectiles`: a loaded crossbow's ammunition, as templates.
|
||||
Stacks(Vec<Stack>),
|
||||
}
|
||||
|
||||
/// `Filterable.streamCodec`: the raw value, then an optional filtered one
|
||||
@@ -125,6 +128,10 @@ impl Component {
|
||||
resolved: r.bool()?,
|
||||
})
|
||||
}
|
||||
CHARGED_PROJECTILES => {
|
||||
let n = r.count(1024)?;
|
||||
Self::Stacks((0..n).map(|_| Template::read(r)).collect::<Result<_>>()?)
|
||||
}
|
||||
DYED_COLOR => Self::Int(r.i32()?),
|
||||
UNBREAKABLE | CREATIVE_SLOT_LOCK => Self::Unit,
|
||||
ENCHANTMENT_GLINT_OVERRIDE => Self::Bool(r.bool()?),
|
||||
@@ -283,6 +290,10 @@ impl Component {
|
||||
w.varint(hidden.len() as i32);
|
||||
hidden.iter().for_each(|h| w.varint(*h));
|
||||
}
|
||||
Self::Stacks(stacks) => {
|
||||
w.varint(stacks.len() as i32);
|
||||
stacks.iter().for_each(|s| Template::write(s, w));
|
||||
}
|
||||
Self::WritableBook(pages) => {
|
||||
w.varint(pages.len() as i32);
|
||||
for page in pages {
|
||||
@@ -533,6 +544,21 @@ mod tests {
|
||||
round::<Option<Hashed>, HashedSlot>(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charged_projectiles_are_item_stack_templates() {
|
||||
// A crossbow (id 7) loaded with one arrow (id 9): ChargedProjectiles
|
||||
// is a list of ItemStackTemplate, the item before the count.
|
||||
let bytes = [1, 7, 1, 0, 51, 1, 9, 1, 0, 0];
|
||||
let stack = Slot::read(&mut Reader::new(&bytes)).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
stack.component(component::CHARGED_PROJECTILES),
|
||||
Some(&Component::Stacks(vec![Stack::new(9, 1)]))
|
||||
);
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&Some(stack), &mut w);
|
||||
assert_eq!(w.data, bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_book_pages_are_read_and_dropped() {
|
||||
// A writable book with one page "hi" and its filtered "**".
|
||||
|
||||
@@ -461,6 +461,15 @@ packet!(OpenScreen { container: i32 => VarInt, menu: i32 => VarInt, title: Tag =
|
||||
// ClientboundMountScreenOpenPacket: a horse's or llama's inventory, with its
|
||||
// chest's columns (`getInventoryColumns`).
|
||||
packet!(MountScreenOpen { container: i32 => VarInt, columns: i32 => VarInt, entity: i32 => Int });
|
||||
packet!(
|
||||
/// ClientboundOpenSignEditorPacket: the sign whose side the player edits.
|
||||
OpenSignEditor { pos: [i32; 3] => Pos, front: bool => SignSide }
|
||||
);
|
||||
packet!(
|
||||
/// ClientboundOpenBookPacket: the hand (InteractionHand, 0 main, 1 off)
|
||||
/// whose written book the player reads.
|
||||
OpenBook { hand: i32 => VarInt }
|
||||
);
|
||||
packet!(PlayerAbilities { flags: i8 => Byte, flying_speed: f32 => Float, walking_speed: f32 => Float });
|
||||
/// MessageSignature.Packed: an index into the last-seen cache or a full signature.
|
||||
pub struct PackedSignature;
|
||||
@@ -1617,6 +1626,8 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
MerchantOffers(MerchantOffers) = MERCHANT_OFFERS,
|
||||
MountScreenOpen(MountScreenOpen) = MOUNT_SCREEN_OPEN,
|
||||
OpenScreen(OpenScreen) = OPEN_SCREEN,
|
||||
OpenBook(OpenBook) = OPEN_BOOK,
|
||||
OpenSignEditor(OpenSignEditor) = OPEN_SIGN_EDITOR,
|
||||
Ping(Ping) = PING,
|
||||
PongResponse(crate::packets::status::Ping) = PONG_RESPONSE,
|
||||
PlayerAbilities(PlayerAbilities) = PLAYER_ABILITIES,
|
||||
@@ -1917,6 +1928,61 @@ packet!(
|
||||
/// ServerboundRenameItemPacket: `AnvilMenu.setItemName`.
|
||||
RenameItem { name: String => Str }
|
||||
);
|
||||
packet!(
|
||||
/// ServerboundSignUpdatePacket: the four lines the player wrote on a side.
|
||||
SignUpdate { pos: [i32; 3] => Pos, lines: [String; 4] => SignLines, front: bool => SignSide }
|
||||
);
|
||||
packet!(
|
||||
/// ServerboundEditBookPacket: the book's slot (a hotbar slot, or 40 for
|
||||
/// the off hand), its pages and, to sign it, its title.
|
||||
EditBook {
|
||||
slot: i32 => VarInt,
|
||||
pages: Vec<String> => BookPages,
|
||||
title: Option<String> => Opt<StrN<32>>,
|
||||
}
|
||||
);
|
||||
/// `stringUtf8(1024)` in `list(100)`: a book's pages.
|
||||
pub struct BookPages;
|
||||
impl Codec<Vec<String>> for BookPages {
|
||||
fn read(r: &mut Reader) -> Result<Vec<String>> {
|
||||
let n = r.varint()?;
|
||||
if !(0..=100).contains(&n) {
|
||||
return Err(Error::new(format!("{n} book pages, at most 100")));
|
||||
}
|
||||
(0..n).map(|_| r.string(1024)).collect()
|
||||
}
|
||||
fn write(v: &Vec<String>, w: &mut Writer) {
|
||||
List::<StrN<1024>>::write(v, w);
|
||||
}
|
||||
}
|
||||
/// SignTextSlot.STREAM_CODEC: VarInt 0 for the back, 1 for the front (any
|
||||
/// other id reads as the back, ByIdMap's ZERO strategy).
|
||||
pub struct SignSide;
|
||||
impl Codec<bool> for SignSide {
|
||||
fn read(r: &mut Reader) -> Result<bool> {
|
||||
Ok(r.varint()? == 1)
|
||||
}
|
||||
fn write(v: &bool, w: &mut Writer) {
|
||||
w.varint(i32::from(*v));
|
||||
}
|
||||
}
|
||||
/// `stringUtf8(384)` in `fixedSizeList(4)`: four lines, no count.
|
||||
pub struct SignLines;
|
||||
impl Codec<[String; 4]> for SignLines {
|
||||
fn read(r: &mut Reader) -> Result<[String; 4]> {
|
||||
Ok([
|
||||
r.string(384)?,
|
||||
r.string(384)?,
|
||||
r.string(384)?,
|
||||
r.string(384)?,
|
||||
])
|
||||
}
|
||||
fn write(v: &[String; 4], w: &mut Writer) {
|
||||
for line in v {
|
||||
w.string(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
packet!(
|
||||
/// ServerboundSetBeaconPacket: `BeaconMenu.updateEffects`, each power an
|
||||
/// optional `mob_effect` id.
|
||||
@@ -2000,6 +2066,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
|
||||
ContainerClick(ContainerClick) = CONTAINER_CLICK,
|
||||
ContainerClose(ContainerClose) = CONTAINER_CLOSE,
|
||||
CustomPayload(CustomPayload) = CUSTOM_PAYLOAD,
|
||||
EditBook(EditBook) = EDIT_BOOK,
|
||||
Interact(Interact) = INTERACT,
|
||||
KeepAlive(KeepAlive) = KEEP_ALIVE,
|
||||
MovePlayerPos(MovePlayerPos) = MOVE_PLAYER_POS,
|
||||
@@ -2024,6 +2091,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
|
||||
SetBeacon(SetBeacon) = SET_BEACON,
|
||||
SetCarriedItem(SetCarriedItem) = SET_CARRIED_ITEM,
|
||||
SetCreativeModeSlot(SetCreativeModeSlot) = SET_CREATIVE_MODE_SLOT,
|
||||
SignUpdate(SignUpdate) = SIGN_UPDATE,
|
||||
UseItem(UseItem) = USE_ITEM,
|
||||
UseItemOn(UseItemOn) = USE_ITEM_ON,
|
||||
});
|
||||
@@ -2748,4 +2816,48 @@ mod tests {
|
||||
});
|
||||
round_trip(p, Clientbound::encode, Clientbound::decode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_packets_match_vanilla_stream_codecs() {
|
||||
// ClientboundOpenSignEditorPacket: BlockPos, then SignTextSlot as a VarInt (FRONT is 1).
|
||||
let open = Clientbound::OpenSignEditor(OpenSignEditor {
|
||||
pos: [1, 2, 3],
|
||||
front: true,
|
||||
});
|
||||
let body = open.encode();
|
||||
assert_eq!(
|
||||
body[1..9],
|
||||
crate::buf::pack_block_pos([1, 2, 3]).to_be_bytes()
|
||||
);
|
||||
assert_eq!(body[9..], [1]);
|
||||
round_trip(open, Clientbound::encode, Clientbound::decode);
|
||||
// ServerboundSignUpdatePacket: BlockPos, four strings with no count, then the slot.
|
||||
let update = Serverbound::SignUpdate(SignUpdate {
|
||||
pos: [-4, 70, 9],
|
||||
lines: ["Hi".into(), String::new(), "x".into(), String::new()],
|
||||
front: false,
|
||||
});
|
||||
let body = update.encode();
|
||||
assert_eq!(body[9..], [2, b'H', b'i', 0, 1, b'x', 0, 0]);
|
||||
round_trip(update, Serverbound::encode, Serverbound::decode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn book_packets_match_vanilla_stream_codecs() {
|
||||
// ClientboundOpenBookPacket: InteractionHand as a VarInt.
|
||||
let open = Clientbound::OpenBook(OpenBook { hand: 1 });
|
||||
assert_eq!(open.encode()[1..], [1]);
|
||||
round_trip(open, Clientbound::encode, Clientbound::decode);
|
||||
// ServerboundEditBookPacket: the slot, the pages (a counted list), then an optional title.
|
||||
let sign = Serverbound::EditBook(EditBook {
|
||||
slot: 40,
|
||||
pages: vec!["a".into(), String::new()],
|
||||
title: Some("T".into()),
|
||||
});
|
||||
assert_eq!(sign.encode()[1..], [40, 2, 1, b'a', 0, 1, 1, b'T']);
|
||||
round_trip(sign, Serverbound::encode, Serverbound::decode);
|
||||
let mut w = Writer::new();
|
||||
w.varint(101);
|
||||
assert!(BookPages::read(&mut Reader::new(&w.data)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,22 @@ impl JavaPack {
|
||||
}
|
||||
}
|
||||
|
||||
impl JavaPack {
|
||||
/// The block ids of the pack's `block.properties` (empty without one),
|
||||
/// [`properties::block_ids`].
|
||||
pub fn block_ids(
|
||||
&self,
|
||||
values: &OptionValues,
|
||||
env: &Environment,
|
||||
) -> Result<BTreeMap<String, i32>, String> {
|
||||
match self.files.read("block.properties") {
|
||||
Some(text) => properties::block_ids(&text, env, &self.options, values)
|
||||
.map_err(|e| format!("block.properties: {e}")),
|
||||
None => Ok(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A program's name in `shaders.properties` (`world-1/composite1.fsh` is
|
||||
/// `world-1/composite1`).
|
||||
pub fn program_name(program: &str) -> &str {
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
options::OptionValues,
|
||||
preprocess::Environment,
|
||||
properties::{Blend, LEGACY_TARGETS, ShaderProperties},
|
||||
translate::{Link, Translated},
|
||||
translate::{Geometry, Link, Translated},
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -327,8 +327,101 @@ pub struct Passes {
|
||||
/// next frame starts unflipped.
|
||||
pub flipped_at_end: BTreeSet<u32>,
|
||||
pub directives: Directives,
|
||||
/// The program Horizon's solid terrain is drawn with
|
||||
/// ([`TERRAIN_PROGRAMS`]), before `deferred`.
|
||||
pub terrain: Option<Pass>,
|
||||
/// The program its translucent terrain is drawn with
|
||||
/// ([`WATER_PROGRAMS`]), after `deferred`.
|
||||
pub water: Option<Pass>,
|
||||
/// The programs the shadow map's solid and translucent terrain are
|
||||
/// drawn with ([`SHADOW_PROGRAMS`], [`SHADOW_WATER_PROGRAMS`]), after
|
||||
/// `begin`. Their draw buffers are `shadowcolor` targets.
|
||||
pub shadow: Option<Pass>,
|
||||
pub shadow_water: Option<Pass>,
|
||||
/// `shadowcolor0..7`: white and cleared each frame unless the pack
|
||||
/// says otherwise.
|
||||
pub shadow_targets: Vec<TargetSettings>,
|
||||
}
|
||||
|
||||
/// The programs Iris draws the shadow map's solid terrain with. Horizon's
|
||||
/// solid layer holds cutout blocks too, so `shadow_cutout` (with its alpha
|
||||
/// test) comes first.
|
||||
pub const SHADOW_PROGRAMS: &[&str] = &["shadow_cutout", "shadow", "shadow_solid"];
|
||||
|
||||
/// The programs Iris draws the shadow map's translucent terrain with.
|
||||
pub const SHADOW_WATER_PROGRAMS: &[&str] = &["shadow_water", "shadow"];
|
||||
|
||||
/// How many `shadowcolor` targets Iris has.
|
||||
pub const MAX_SHADOW_COLOR_BUFFERS: u32 = 8;
|
||||
|
||||
/// `shadowcolor<N>Format`, `Clear` and `ClearColor` from `fragments`.
|
||||
pub fn shadow_target_settings<'a>(
|
||||
fragments: impl IntoIterator<Item = &'a str>,
|
||||
) -> Vec<TargetSettings> {
|
||||
let mut targets = vec![
|
||||
TargetSettings {
|
||||
clear_color: Some([1.0; 4]),
|
||||
..TargetSettings::default()
|
||||
};
|
||||
MAX_SHADOW_COLOR_BUFFERS as usize
|
||||
];
|
||||
let key = |key: &str, suffix: &str| {
|
||||
let i: u32 = key
|
||||
.strip_prefix("shadowcolor")?
|
||||
.strip_suffix(suffix)?
|
||||
.parse()
|
||||
.ok()?;
|
||||
(i < MAX_SHADOW_COLOR_BUFFERS).then_some(i as usize)
|
||||
};
|
||||
for fragment in fragments {
|
||||
for c in const_directives(fragment) {
|
||||
match c.ty {
|
||||
"int" => {
|
||||
if let Some(i) = key(&c.key, "Format") {
|
||||
let format = c.value.to_uppercase();
|
||||
if TEXTURE_FORMATS.contains(&format.as_str()) {
|
||||
targets[i].format = format;
|
||||
}
|
||||
}
|
||||
}
|
||||
"bool" => {
|
||||
if let (Some(i), Ok(clear)) = (key(&c.key, "Clear"), c.value.parse()) {
|
||||
targets[i].clear = clear;
|
||||
}
|
||||
}
|
||||
"vec4" => {
|
||||
if let (Some(i), Some(color)) =
|
||||
(key(&c.key, "ClearColor"), parse_vec4(&c.value))
|
||||
{
|
||||
targets[i].clear_color = Some(color);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
/// The programs Iris draws solid terrain with, the first the pack has
|
||||
/// (`ProgramId.Terrain`'s fallbacks).
|
||||
pub const TERRAIN_PROGRAMS: &[&str] = &[
|
||||
"gbuffers_terrain",
|
||||
"gbuffers_textured_lit",
|
||||
"gbuffers_textured",
|
||||
"gbuffers_basic",
|
||||
];
|
||||
|
||||
/// The programs Iris draws translucent terrain with (`ProgramId.Water`'s
|
||||
/// fallbacks).
|
||||
pub const WATER_PROGRAMS: &[&str] = &[
|
||||
"gbuffers_water",
|
||||
"gbuffers_terrain",
|
||||
"gbuffers_textured_lit",
|
||||
"gbuffers_textured",
|
||||
"gbuffers_basic",
|
||||
];
|
||||
|
||||
/// The `const` directives of a program folder that aren't about targets
|
||||
/// (`PackDirectives`), read from the same programs in the same order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -343,6 +436,8 @@ pub struct Directives {
|
||||
pub wetness_half_life: f32,
|
||||
/// `drynessHalflife`, tenths of a second: `wetness` smoothing when dry.
|
||||
pub dryness_half_life: f32,
|
||||
/// The shadow map's directives.
|
||||
pub shadow: ShadowDirectives,
|
||||
}
|
||||
|
||||
impl Default for Directives {
|
||||
@@ -352,6 +447,35 @@ impl Default for Directives {
|
||||
eye_brightness_half_life: 10.0,
|
||||
wetness_half_life: 600.0,
|
||||
dryness_half_life: 200.0,
|
||||
shadow: ShadowDirectives::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shadow map's `const` directives (`PackShadowDirectives`), with
|
||||
/// Iris's defaults.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ShadowDirectives {
|
||||
/// `shadowMapResolution`: the shadow targets' width and height.
|
||||
pub resolution: u32,
|
||||
/// `shadowDistance`: half the side of the orthographic projection.
|
||||
pub distance: f32,
|
||||
/// `shadowNearPlane` and `shadowFarPlane` (`ShadowMatrices.NEAR`/`FAR`).
|
||||
pub near: f32,
|
||||
pub far: f32,
|
||||
/// `shadowIntervalSize`: the light's view snaps to a grid this size as
|
||||
/// the camera moves, so shadows don't shimmer.
|
||||
pub interval: f32,
|
||||
}
|
||||
|
||||
impl Default for ShadowDirectives {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
resolution: 1024,
|
||||
distance: 160.0,
|
||||
near: -100.05,
|
||||
far: 156.0,
|
||||
interval: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,6 +491,13 @@ impl Directives {
|
||||
match (c.ty, c.key.as_str(), value) {
|
||||
("float", "sunPathRotation", Ok(v)) => out.sun_path_rotation = v,
|
||||
("float", "eyeBrightnessHalflife", Ok(v)) => out.eye_brightness_half_life = v,
|
||||
("int", "shadowMapResolution", Ok(v)) if v >= 1.0 => {
|
||||
out.shadow.resolution = v as u32;
|
||||
}
|
||||
("float", "shadowDistance", Ok(v)) => out.shadow.distance = v,
|
||||
("float", "shadowNearPlane", Ok(v)) => out.shadow.near = v,
|
||||
("float", "shadowFarPlane", Ok(v)) => out.shadow.far = v,
|
||||
("float", "shadowIntervalSize", Ok(v)) => out.shadow.interval = v,
|
||||
("float", "wetnessHalflife", Ok(v)) => out.wetness_half_life = v,
|
||||
("float", "drynessHalflife", Ok(v)) => out.dryness_half_life = v,
|
||||
_ => {}
|
||||
@@ -492,6 +623,7 @@ impl JavaPack {
|
||||
}
|
||||
let mut out = Passes {
|
||||
targets: target_settings(sources.iter().map(String::as_str), properties),
|
||||
shadow_targets: shadow_target_settings(sources.iter().map(String::as_str)),
|
||||
directives: Directives::read(sources.iter().map(String::as_str)),
|
||||
..Passes::default()
|
||||
};
|
||||
@@ -534,6 +666,7 @@ impl JavaPack {
|
||||
};
|
||||
let mut flipped = BTreeSet::new();
|
||||
out.begin = run(&groups[0], "begin_pre", &mut flipped)?;
|
||||
let flipped_after_begin = flipped.clone();
|
||||
out.prepare = run(&groups[1], "prepare_pre", &mut flipped)?;
|
||||
out.flipped_after_prepare = flipped.clone();
|
||||
// Iris's fallbacks for terrain (`ProgramFallbackResolver`).
|
||||
@@ -554,6 +687,27 @@ impl JavaPack {
|
||||
out.flipped_after_translucent = flipped.clone();
|
||||
out.composite = run(&groups[3], "composite_pre", &mut flipped)?;
|
||||
out.flipped_at_end = flipped;
|
||||
// Geometry writes the side the next passes read.
|
||||
let geometry = |chain: &[&str], flipped: &BTreeSet<u32>| {
|
||||
let Some(name) = chain.iter().find(|n| exists(n)) else {
|
||||
return Ok::<_, String>(None);
|
||||
};
|
||||
let text = fragment(name)?;
|
||||
let (blend, buffer_blends) = resolve_blends(&path(name));
|
||||
Ok(Some(Pass {
|
||||
program: path(name),
|
||||
draw_buffers: draw_buffers(&text).map_err(|e| format!("{}: {e}", path(name)))?,
|
||||
reads_alt: flipped.clone(),
|
||||
mipmapped: BTreeSet::new(),
|
||||
scale: 1.0,
|
||||
blend,
|
||||
buffer_blends,
|
||||
}))
|
||||
};
|
||||
out.terrain = geometry(TERRAIN_PROGRAMS, &out.flipped_after_prepare)?;
|
||||
out.water = geometry(WATER_PROGRAMS, &out.flipped_after_translucent)?;
|
||||
out.shadow = geometry(SHADOW_PROGRAMS, &flipped_after_begin)?;
|
||||
out.shadow_water = geometry(SHADOW_WATER_PROGRAMS, &flipped_after_begin)?;
|
||||
if exists("final") {
|
||||
let prog = path("final");
|
||||
let (blend, buffer_blends) = resolve_blends(&prog);
|
||||
@@ -614,6 +768,70 @@ impl JavaPack {
|
||||
}
|
||||
}
|
||||
|
||||
/// A geometry program translated: the program (its vertex with the first
|
||||
/// glue), and its vertex with each other glue.
|
||||
pub struct GeometryProgram {
|
||||
pub program: Program,
|
||||
pub variants: Vec<Translated>,
|
||||
}
|
||||
|
||||
impl JavaPack {
|
||||
/// Translate geometry program `pass` (`gbuffers_*`) to run over
|
||||
/// Horizon's geometry: its bindings in group 2, its attributes from
|
||||
/// each of `glues` (the vertex program is translated once per glue).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compile_geometry(
|
||||
&self,
|
||||
pass: &Pass,
|
||||
targets: &[TargetSettings],
|
||||
values: &OptionValues,
|
||||
env: &Environment,
|
||||
properties: &ShaderProperties,
|
||||
glues: &[Geometry],
|
||||
) -> Result<GeometryProgram, String> {
|
||||
let (first, rest) = glues
|
||||
.split_first()
|
||||
.ok_or("a geometry program needs a glue")?;
|
||||
let mut link = Link {
|
||||
image_formats: properties.image_formats(),
|
||||
shared_samplers: shared_samplers(targets),
|
||||
set: 2,
|
||||
geometry: Some(first.clone()),
|
||||
..Link::default()
|
||||
};
|
||||
let vertex = self.translate(&format!("{}.vsh", pass.program), values, env, &mut link)?;
|
||||
let fragment = self.translate(&format!("{}.fsh", pass.program), values, env, &mut link)?;
|
||||
let mut variants = Vec::new();
|
||||
for glue in rest {
|
||||
let mut other = link.clone();
|
||||
other.geometry = Some(glue.clone());
|
||||
variants.push(self.translate(
|
||||
&format!("{}.vsh", pass.program),
|
||||
values,
|
||||
env,
|
||||
&mut other,
|
||||
)?);
|
||||
if other.uniforms != link.uniforms || other.bindings != link.bindings {
|
||||
return Err(format!("{}: its variants bind differently", pass.program));
|
||||
}
|
||||
}
|
||||
let draw_buffers = match &pass.draw_buffers {
|
||||
Some(buffers) => buffers.clone(),
|
||||
None => (0..fragment.interface.outputs.keys().max().map_or(1, |m| m + 1)).collect(),
|
||||
};
|
||||
Ok(GeometryProgram {
|
||||
program: Program {
|
||||
pass: pass.clone(),
|
||||
vertex,
|
||||
fragment,
|
||||
draw_buffers,
|
||||
link,
|
||||
},
|
||||
variants,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris's sampler states for the textures it binds: colour targets
|
||||
/// linear (nearest for integer formats, and for 32-bit floats, which wgpu
|
||||
/// can't filter everywhere), depth nearest, noise repeating. For
|
||||
@@ -794,6 +1012,30 @@ mod tests {
|
||||
assert_eq!(d.dryness_half_life, 200.0, "the default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_directives_and_targets() {
|
||||
let text = "\
|
||||
const int shadowMapResolution = 2048;
|
||||
const float shadowDistance = 128.0;
|
||||
const float shadowIntervalSize = 3.0;
|
||||
const bool shadowcolor0Clear = false;
|
||||
const int shadowcolor1Format = RGBA16;
|
||||
const vec4 shadowcolor1ClearColor = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
";
|
||||
let d = Directives::read([text]);
|
||||
assert_eq!(d.shadow.resolution, 2048);
|
||||
assert_eq!(d.shadow.distance, 128.0);
|
||||
assert_eq!(d.shadow.interval, 3.0);
|
||||
assert_eq!(d.shadow.near, ShadowDirectives::default().near);
|
||||
let targets = shadow_target_settings([text]);
|
||||
assert!(!targets[0].clear);
|
||||
assert_eq!(targets[1].format, "RGBA16");
|
||||
assert_eq!(targets[1].clear_color, Some([0.0; 4]));
|
||||
// Iris clears the others to white.
|
||||
assert_eq!(targets[2].clear_color, Some([1.0; 4]));
|
||||
assert!(targets[2].clear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn const_directives_and_target_settings() {
|
||||
let text = "\
|
||||
|
||||
@@ -639,6 +639,58 @@ impl ShaderProperties {
|
||||
}
|
||||
}
|
||||
|
||||
/// `block.properties` (`block.<id>=<blocks>`), preprocessed as
|
||||
/// `shaders.properties` is: each block's id by its name without namespace
|
||||
/// (`oak_leaves`). A block listed with a state (`grass_block:snowy=true`)
|
||||
/// takes that id only when no entry names it without one; numeric ids
|
||||
/// (before 1.13) are skipped.
|
||||
pub fn block_ids(
|
||||
text: &str,
|
||||
env: &Environment,
|
||||
options: &OptionSet,
|
||||
values: &OptionValues,
|
||||
) -> Result<BTreeMap<String, i32>, String> {
|
||||
let properties = Properties::parse(&preprocess(text, env, options, values)?);
|
||||
let mut plain = BTreeMap::new();
|
||||
let mut with_state = BTreeMap::new();
|
||||
for (key, value) in properties.iter() {
|
||||
let Some(Ok(id)) = key.strip_prefix("block.").map(str::parse::<i32>) else {
|
||||
continue;
|
||||
};
|
||||
for entry in value.split_whitespace() {
|
||||
let entry = entry.strip_prefix("minecraft:").unwrap_or(entry);
|
||||
if entry.contains(':') && entry.split(':').nth(1).is_some_and(|s| s.contains('=')) {
|
||||
let name = entry.split(':').next().unwrap_or(entry);
|
||||
with_state.entry(name.to_owned()).or_insert(id);
|
||||
} else if !entry.chars().all(|c| c.is_ascii_digit()) && !entry.contains('=') {
|
||||
plain.insert(entry.to_owned(), id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (name, id) in with_state {
|
||||
plain.entry(name).or_insert(id);
|
||||
}
|
||||
Ok(plain)
|
||||
}
|
||||
|
||||
/// The block a block sprite (`minecraft:block/oak_log_top`) belongs to in
|
||||
/// `ids` ([`block_ids`]): the block named like it, else the longest one its
|
||||
/// name starts with followed by `_` (`oak_log`, `water` for `water_still`).
|
||||
pub fn sprite_block_id(sprite: &str, ids: &BTreeMap<String, i32>) -> Option<i32> {
|
||||
let name = sprite.rsplit(['/', ':']).next()?;
|
||||
if let Some(&id) = ids.get(name) {
|
||||
return Some(id);
|
||||
}
|
||||
let mut end = name.len();
|
||||
while let Some(cut) = name[..end].rfind('_') {
|
||||
if let Some(&id) = ids.get(&name[..cut]) {
|
||||
return Some(id);
|
||||
}
|
||||
end = cut;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Iris's `BooleanParser`: options joined by `!`, `&&`, `||` and brackets,
|
||||
/// `&&` binding tighter. `true`/`1` and `false`/`0` are literals; a name that
|
||||
/// isn't a boolean option is true; a malformed expression is true.
|
||||
@@ -757,6 +809,42 @@ mod tests {
|
||||
set
|
||||
}
|
||||
|
||||
/// `block.properties` as Chocapic13' writes it, and sprites matched to
|
||||
/// its blocks.
|
||||
#[test]
|
||||
fn block_ids_and_the_sprites_of_their_blocks() {
|
||||
let text = "\
|
||||
#if MC_VERSION >= 11300
|
||||
block.10001= minecraft:wheat minecraft:short_grass
|
||||
block.10003 = minecraft:oak_leaves \\
|
||||
minecraft:vine
|
||||
block.10006 = minecraft:grass_block:snowy=true minecraft:snow
|
||||
block.8 = minecraft:water
|
||||
block.2 = minecraft:grass_block
|
||||
#else
|
||||
block.10001= 6 31
|
||||
#endif
|
||||
layer.translucent=minecraft:glass
|
||||
";
|
||||
let env = Environment {
|
||||
defines: vec![("MC_VERSION".into(), "260300".into())],
|
||||
};
|
||||
let ids = block_ids(text, &env, &OptionSet::default(), &OptionValues::default()).unwrap();
|
||||
assert_eq!(ids.get("vine"), Some(&10003));
|
||||
// The stateless entry wins over the snowy one.
|
||||
assert_eq!(ids.get("grass_block"), Some(&2));
|
||||
assert!(!ids.contains_key("6") && !ids.contains_key("glass"));
|
||||
for (sprite, id) in [
|
||||
("minecraft:block/oak_leaves", Some(10003)),
|
||||
("minecraft:block/water_still", Some(8)),
|
||||
("minecraft:block/grass_block_top", Some(2)),
|
||||
("minecraft:block/wheat_stage7", Some(10001)),
|
||||
("minecraft:block/stone", None),
|
||||
] {
|
||||
assert_eq!(sprite_block_id(sprite, &ids), id, "{sprite}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_properties_syntax() {
|
||||
let p = Properties::parse(
|
||||
|
||||
@@ -45,7 +45,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
/// Locations, bindings and uniforms shared by the programs of one pass: a
|
||||
/// pass's vertex program is translated first, its fragment program second.
|
||||
#[derive(Default, Debug)]
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct Link {
|
||||
/// Varying name → location (split matrices and arrays as `name#i`).
|
||||
pub varyings: BTreeMap<String, u32>,
|
||||
@@ -64,6 +64,27 @@ pub struct Link {
|
||||
/// 16). Once any is named, comparison samplers share `iris_compare` and
|
||||
/// integer ones `iris_nearest`; textures not named keep `{name}_s`.
|
||||
pub shared_samplers: BTreeMap<String, String>,
|
||||
/// The bind group of the pack's bindings (0; 2 for geometry programs,
|
||||
/// whose first groups are Horizon's).
|
||||
pub set: u32,
|
||||
/// Set for `gbuffers_*` programs drawn over Horizon's own geometry.
|
||||
pub geometry: Option<Geometry>,
|
||||
}
|
||||
|
||||
/// How a `gbuffers_*` program runs over Horizon's geometry: its vertex
|
||||
/// attributes aren't inputs but globals the glue fills from Horizon's
|
||||
/// vertices before the program's `main`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Geometry {
|
||||
/// GLSL put before the vertex program: Horizon's vertex inputs and
|
||||
/// buffers, and a `void hz_vertex()` run first in `main`.
|
||||
pub vertex_glue: String,
|
||||
/// Each attribute's value as a `vec4` expression of the glue
|
||||
/// (`iris_Vertex`, `mc_Entity`, `vaUV0`...). Others are zero.
|
||||
pub attributes: BTreeMap<String, String>,
|
||||
/// Iris's alpha test: the fragment is discarded unless output 0's
|
||||
/// alpha is above it.
|
||||
pub alpha_test: Option<f32>,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
@@ -385,6 +406,7 @@ pub fn translate(
|
||||
.parse(&options, &glsl)
|
||||
.map_err(|e| first_lines(&e.emit_to_string(&glsl)))?;
|
||||
unpad_private_arrays(&mut module);
|
||||
end_returning_cases(&mut module);
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
@@ -403,6 +425,49 @@ pub fn translate(
|
||||
})
|
||||
}
|
||||
|
||||
/// A `case` that ends with `return` or `discard` rather than `break`
|
||||
/// (Photon's displacement) is a fall-through one to naga's GLSL front end,
|
||||
/// which its WGSL writer refuses: nothing falls through it.
|
||||
fn end_returning_cases(module: &mut naga::Module) {
|
||||
fn block(b: &mut naga::Block) {
|
||||
for s in b.iter_mut() {
|
||||
match s {
|
||||
naga::Statement::Block(inner) => block(inner),
|
||||
naga::Statement::If { accept, reject, .. } => {
|
||||
block(accept);
|
||||
block(reject);
|
||||
}
|
||||
naga::Statement::Loop {
|
||||
body, continuing, ..
|
||||
} => {
|
||||
block(body);
|
||||
block(continuing);
|
||||
}
|
||||
naga::Statement::Switch { cases, .. } => {
|
||||
for case in cases {
|
||||
block(&mut case.body);
|
||||
if case.fall_through
|
||||
&& matches!(
|
||||
case.body.last(),
|
||||
Some(naga::Statement::Return { .. } | naga::Statement::Kill)
|
||||
)
|
||||
{
|
||||
case.fall_through = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, f) in module.functions.iter_mut() {
|
||||
block(&mut f.body);
|
||||
}
|
||||
for e in &mut module.entry_points {
|
||||
block(&mut e.function.body);
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in functions naga folds in `const` initialisers.
|
||||
const FOLDABLE: &[&str] = &[
|
||||
"radians",
|
||||
@@ -634,6 +699,8 @@ struct Legaliser<'a> {
|
||||
mat2_ctor: bool,
|
||||
/// Constants turned into globals ([`needs_runtime`]).
|
||||
runtime_constants: HashSet<String>,
|
||||
/// Geometry programs: each attribute's type, for the glue.
|
||||
attribute_types: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
fn var(name: &str) -> ast::Expr {
|
||||
@@ -952,6 +1019,7 @@ impl<'a> Legaliser<'a> {
|
||||
gather: false,
|
||||
mat2_ctor: false,
|
||||
runtime_constants: HashSet::new(),
|
||||
attribute_types: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,7 +1111,8 @@ impl<'a> Legaliser<'a> {
|
||||
show_declaration(&mut text, d, &mut FormattingState::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.header.push(format!(
|
||||
"layout({kind}, set = 0, binding = {binding}) {}",
|
||||
"layout({kind}, set = {}, binding = {binding}) {}",
|
||||
self.link.set,
|
||||
text.trim()
|
||||
));
|
||||
Ok(false)
|
||||
@@ -1099,7 +1168,8 @@ impl<'a> Legaliser<'a> {
|
||||
for (name, _) in &names {
|
||||
let t = self.link.binding(&format!("{name}_t"));
|
||||
self.header.push(format!(
|
||||
"layout(set = 0, binding = {t}) uniform {texture} {name}_t;"
|
||||
"layout(set = {}, binding = {t}) uniform {texture} {name}_t;",
|
||||
self.link.set
|
||||
));
|
||||
let shared = &self.link.shared_samplers;
|
||||
let var = if shared.is_empty() {
|
||||
@@ -1117,7 +1187,8 @@ impl<'a> Legaliser<'a> {
|
||||
if self.sampler_vars.insert(var.clone()) {
|
||||
let s = self.link.binding(&var);
|
||||
self.header.push(format!(
|
||||
"layout(set = 0, binding = {s}) uniform {sampler} {var};"
|
||||
"layout(set = {}, binding = {s}) uniform {sampler} {var};",
|
||||
self.link.set
|
||||
));
|
||||
}
|
||||
self.interface
|
||||
@@ -1156,7 +1227,8 @@ impl<'a> Legaliser<'a> {
|
||||
.unwrap_or_else(|| "rgba8".to_owned());
|
||||
let binding = self.link.binding(name);
|
||||
layout.push(format!(
|
||||
"layout({f}, set = 0, binding = {binding}) {memory}uniform {ty} {name};"
|
||||
"layout({f}, set = {}, binding = {binding}) {memory}uniform {ty} {name};",
|
||||
self.link.set
|
||||
));
|
||||
self.interface.images.push((name.clone(), f));
|
||||
}
|
||||
@@ -1199,10 +1271,8 @@ impl<'a> Legaliser<'a> {
|
||||
for (i, (name, array)) in names.iter().enumerate() {
|
||||
let full_array = format!("{ty_array}{array}");
|
||||
if input {
|
||||
let at = Link::location(&mut self.link.attributes, name);
|
||||
self.header.push(format!(
|
||||
"layout(location = {at}) in {ty} {name}{full_array};"
|
||||
));
|
||||
let decl = self.attribute(&ty, name, &full_array);
|
||||
self.header.push(decl);
|
||||
self.interface.attributes.insert(name.clone());
|
||||
} else if !varying {
|
||||
let at = explicit
|
||||
@@ -1670,9 +1740,8 @@ impl<'a> Legaliser<'a> {
|
||||
let ty = LEGACY_ATTRIBUTES.iter().find(|(a, _)| *a == n).unwrap().1;
|
||||
let renamed = n.replacen("gl_", "iris_", 1);
|
||||
if self.interface.attributes.insert(renamed.clone()) {
|
||||
let at = Link::location(&mut self.link.attributes, &renamed);
|
||||
self.header
|
||||
.push(format!("layout(location = {at}) in {ty} {renamed};"));
|
||||
let decl = self.attribute(ty, &renamed, "");
|
||||
self.header.push(decl);
|
||||
}
|
||||
renamed
|
||||
}
|
||||
@@ -1694,6 +1763,21 @@ impl<'a> Legaliser<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A vertex attribute's declaration: an input, or for geometry programs
|
||||
/// a global the glue fills ([`Geometry`]).
|
||||
fn attribute(&mut self, ty: &str, name: &str, array: &str) -> String {
|
||||
match &self.link.geometry {
|
||||
Some(_) => {
|
||||
self.attribute_types.insert(name.to_owned(), ty.to_owned());
|
||||
format!("{ty} {name}{array};")
|
||||
}
|
||||
None => {
|
||||
let at = Link::location(&mut self.link.attributes, name);
|
||||
format!("layout(location = {at}) in {ty} {name}{array};")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn frag_data(&mut self, n: u32) -> ast::Expr {
|
||||
let name = format!("iris_FragData{n}");
|
||||
if self.interface.outputs.insert(n, name.clone()).is_none() {
|
||||
@@ -1959,9 +2043,8 @@ impl<'a> Legaliser<'a> {
|
||||
continue;
|
||||
}
|
||||
if attribute && self.stage == Stage::Vertex {
|
||||
let at = Link::location(&mut self.link.attributes, name);
|
||||
self.header
|
||||
.push(format!("layout(location = {at}) in {ty} {name};"));
|
||||
let decl = self.attribute(ty, name, "");
|
||||
self.header.push(decl);
|
||||
self.interface.attributes.insert(name.to_owned());
|
||||
} else if !attribute {
|
||||
self.link.uniform(name, format!("{ty} {name}"))?;
|
||||
@@ -1971,6 +2054,42 @@ impl<'a> Legaliser<'a> {
|
||||
if !self.errors.is_empty() {
|
||||
return Err(self.errors.join("\n"));
|
||||
}
|
||||
if let Some(geometry) = &self.link.geometry {
|
||||
match self.stage {
|
||||
Stage::Vertex => {
|
||||
// The glue first, then the attributes from it, before
|
||||
// the global initialisers (which may read them).
|
||||
let mut first = vec!["hz_vertex();".to_owned()];
|
||||
for (name, ty) in &self.attribute_types {
|
||||
first.push(match geometry.attributes.get(name) {
|
||||
Some(value) => format!("{name} = {ty}({value});"),
|
||||
None => format!("{name} = {ty}(0);"),
|
||||
});
|
||||
}
|
||||
self.prologue.splice(0..0, first);
|
||||
}
|
||||
Stage::Fragment => {
|
||||
if let (Some(reference), Some(name)) =
|
||||
(geometry.alpha_test, self.interface.outputs.get(&0))
|
||||
{
|
||||
let written = format!("iris_out_{name}");
|
||||
let name = if self.declared.contains(&written)
|
||||
|| self
|
||||
.header
|
||||
.iter()
|
||||
.any(|h| h.ends_with(&format!(" {written};")))
|
||||
{
|
||||
written
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
self.epilogue
|
||||
.push(format!("if (!({name}.a > {reference:?})) discard;"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if self.stage == Stage::Vertex {
|
||||
// GL's clip space to wgpu's: y flipped, so texture row 0 stays
|
||||
// the bottom of the picture as in GL (texture coordinates and
|
||||
@@ -1982,6 +2101,10 @@ impl<'a> Legaliser<'a> {
|
||||
}
|
||||
let mut out = String::from("#version 450 core\n");
|
||||
out.push_str(HELPERS);
|
||||
if let (Some(geometry), Stage::Vertex) = (&self.link.geometry, self.stage) {
|
||||
out.push_str(&geometry.vertex_glue);
|
||||
out.push('\n');
|
||||
}
|
||||
if self.modulo {
|
||||
out.push_str(&modulo_helpers());
|
||||
}
|
||||
@@ -1995,7 +2118,10 @@ impl<'a> Legaliser<'a> {
|
||||
out.push_str(MAT2_HELPERS);
|
||||
}
|
||||
if !self.link.uniforms.is_empty() {
|
||||
out.push_str("layout(std140, set = 0, binding = 0) uniform IrisUniforms {\n");
|
||||
out.push_str(&format!(
|
||||
"layout(std140, set = {}, binding = 0) uniform IrisUniforms {{\n",
|
||||
self.link.set
|
||||
));
|
||||
for (_, decl) in &self.link.uniforms {
|
||||
out.push_str(&format!(" {decl};\n"));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use horizon_shaderpack::{
|
||||
include::Inlined,
|
||||
preprocess::{Environment, preprocess},
|
||||
program_name,
|
||||
translate::{Link, Translated, translate},
|
||||
translate::{Geometry, Link, Translated, translate},
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -440,6 +440,95 @@ void main() {{
|
||||
);
|
||||
}
|
||||
|
||||
/// A `gbuffers_*` program over Horizon's geometry: its attributes are
|
||||
/// globals the glue fills before `main`, its bindings in group 2, and its
|
||||
/// fragment discards below Iris's alpha test. A `case` ending in `return`
|
||||
/// (Photon's displacement) isn't a fall-through for naga.
|
||||
#[test]
|
||||
fn geometry_programs_take_their_attributes_from_the_glue() {
|
||||
let glue = Geometry {
|
||||
vertex_glue: "layout(location = 0) in vec3 hz_position;
|
||||
layout(std430, set = 3, binding = 0) readonly buffer HzTable { int hz_table[]; };
|
||||
vec4 hz_pos;
|
||||
void hz_vertex() { hz_pos = vec4(hz_position, 1.0) + float(hz_table[0]); }"
|
||||
.into(),
|
||||
attributes: [
|
||||
("iris_Vertex".to_owned(), "hz_pos".to_owned()),
|
||||
(
|
||||
"mc_Entity".to_owned(),
|
||||
"vec4(8.0, -1.0, 0.0, 0.0)".to_owned(),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
alpha_test: Some(0.1),
|
||||
};
|
||||
let mut link = Link {
|
||||
set: 2,
|
||||
geometry: Some(glue),
|
||||
..Link::default()
|
||||
};
|
||||
let vs = run(
|
||||
Stage::Vertex,
|
||||
"uniform sampler2D noisetex;
|
||||
attribute vec4 mc_Entity;
|
||||
attribute vec2 mc_midTexCoord;
|
||||
varying vec2 uv;
|
||||
vec3 sway(vec3 p, int id) {
|
||||
switch (id) {
|
||||
case 8:
|
||||
return p + 1.0;
|
||||
case 9:
|
||||
case 10:
|
||||
return p - 1.0;
|
||||
default:
|
||||
return p;
|
||||
}
|
||||
}
|
||||
void main() {
|
||||
uv = gl_MultiTexCoord0.xy + mc_midTexCoord;
|
||||
gl_Position = gl_ModelViewProjectionMatrix * vec4(sway(gl_Vertex.xyz, int(mc_Entity.x)), 1.0);
|
||||
}
|
||||
",
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
for wanted in [
|
||||
"hz_vertex();",
|
||||
"mc_Entity = vec4(vec4(8.0, -1.0, 0.0, 0.0));",
|
||||
"iris_Vertex = vec4(hz_pos);",
|
||||
"mc_midTexCoord = vec2(0);",
|
||||
"set = 2, binding = 0) uniform IrisUniforms",
|
||||
] {
|
||||
assert!(vs.glsl.contains(wanted), "{wanted}\n{}", vs.glsl);
|
||||
}
|
||||
// Horizon's vertex input is the only one.
|
||||
assert_eq!(
|
||||
io(&vs, false).into_keys().collect::<Vec<_>>(),
|
||||
["hz_position"]
|
||||
);
|
||||
reparse(&vs.wgsl).unwrap();
|
||||
let fs = run(
|
||||
Stage::Fragment,
|
||||
"uniform sampler2D gtexture;
|
||||
varying vec2 uv;
|
||||
void main() {
|
||||
/* DRAWBUFFERS:02 */
|
||||
gl_FragData[0] = texture2D(gtexture, uv);
|
||||
gl_FragData[1] = vec4(1.0);
|
||||
}
|
||||
",
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
fs.glsl.contains("if (!(iris_FragData0.a > 0.1)) discard;"),
|
||||
"{}",
|
||||
fs.glsl
|
||||
);
|
||||
assert!(fs.glsl.contains("set = 2, binding = "), "{}", fs.glsl);
|
||||
reparse(&fs.wgsl).unwrap();
|
||||
}
|
||||
|
||||
/// A fragment output narrower than its four-channel target (Steadfast's
|
||||
/// `final` writes `out vec3 color`) is written through a `vec4` with alpha 1:
|
||||
/// GL fills the missing channels, wgpu refuses the pipeline.
|
||||
|
||||
@@ -266,8 +266,8 @@ fn is_virtual(block: usize) -> bool {
|
||||
VIRTUAL.contains(&block)
|
||||
}
|
||||
|
||||
/// `getSideMask`.
|
||||
fn side_mask(west: bool, east: bool, north: bool, south: bool) -> i32 {
|
||||
/// `getSideMask` (LeavesFix has the same copy).
|
||||
pub(super) fn side_mask(west: bool, east: bool, north: bool, south: bool) -> i32 {
|
||||
let mut s = 0;
|
||||
if north {
|
||||
if east {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! emulated here (`IntOpenHashSet` and the key order of an `Int2ObjectOpenHashMap`).
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::chunk_flatten::side_mask;
|
||||
use super::dynamic::{as_int, as_long_stream, as_str, ceillog2, set};
|
||||
use crate::nbt::Tag;
|
||||
|
||||
@@ -355,32 +356,6 @@ fn get_index(x: i32, y: i32, z: i32) -> i32 {
|
||||
y << 8 | z << 4 | x
|
||||
}
|
||||
|
||||
fn side_mask(west: bool, east: bool, north: bool, south: bool) -> i32 {
|
||||
let mut s = 0;
|
||||
if north {
|
||||
if east {
|
||||
s |= 2;
|
||||
} else if west {
|
||||
s |= 128;
|
||||
} else {
|
||||
s |= 1;
|
||||
}
|
||||
} else if south {
|
||||
if west {
|
||||
s |= 32;
|
||||
} else if east {
|
||||
s |= 8;
|
||||
} else {
|
||||
s |= 16;
|
||||
}
|
||||
} else if east {
|
||||
s |= 4;
|
||||
} else if west {
|
||||
s |= 64;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// `LeavesFix` over a chunk (`{Level: ...}`).
|
||||
pub(super) fn leaves_fix(chunk: &mut Tag) {
|
||||
let Some(level) = chunk.get_mut("Level") else {
|
||||
|
||||
@@ -53,7 +53,7 @@ Start the client.
|
||||
- `--world`: Open or create this world (a folder under saves) at once
|
||||
- `--creative`: Creative mode for a new world
|
||||
- `--join`: Join a friend's world with its invite code
|
||||
- `--arg`: An extra argument for the game, passed through as-is (repeatable), for example --server-thread
|
||||
- `--arg`: An extra argument for the game, passed through as-is (repeatable), for example --in-frame
|
||||
|
||||
MCP: `play`, then the `game_*` tools.
|
||||
|
||||
@@ -143,7 +143,7 @@ MCP: `settings`.
|
||||
|
||||
## `horizon-launcher game-args`
|
||||
|
||||
Show or set the extra arguments the game starts with (for example --server-thread); quotes group a word with spaces in it.
|
||||
Show or set the extra arguments the game starts with (for example --in-frame); quotes group a word with spaces in it.
|
||||
|
||||
horizon-launcher game-args [ARGS]
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ screen (the same screen as vanilla's) or in game, and every loader reloads from
|
||||
## How it works
|
||||
|
||||
`packs.rs` finds the packs (`FolderRepositorySource`: folders and zips, plus the built-in Programmer Art and High
|
||||
Contrast), reads `pack.mcmeta` (format range, overlays, filters) and builds one overlay directory that layers the
|
||||
Contrast, which only the asset index holds: preparation copies them, `horizon-prepare packs` repairs older resources),
|
||||
reads `pack.mcmeta` (format range, overlays, filters) and builds one overlay directory that layers the
|
||||
selected packs over the prepared vanilla resources, later packs winning. Files vanilla reads as a resource *stack*
|
||||
(language files, `sounds.json`, font definitions, atlas definitions) are merged layer by layer as vanilla merges them.
|
||||
Every loader then reads that one tree; `game/packs.rs` (`reload_packs`) reloads all of them in game, as
|
||||
|
||||
@@ -26,10 +26,9 @@ the other dimensions' levels as `Parked` values and swaps one into place (`enter
|
||||
The packets of a guest are read in its own level's pass (`Hub::tick_level`), so its actions and
|
||||
commands run with its level in place.
|
||||
|
||||
The window's own game (single player's default, `--no-server-thread`, `HORIZON_SERVER_THREAD=0`, and the
|
||||
command harness) keeps one level, which follows its player (`enter_dimension`). LAN guests of a world run
|
||||
that way stay in the host's dimension. It is the default again only until the server thread reaches parity
|
||||
(`--server-thread` or `HORIZON_SERVER_THREAD=1` opts in), so it gets no multi-level work.
|
||||
The window's own game (`--in-frame`, `--no-server-thread`, `HORIZON_SERVER_THREAD=0`, and the command
|
||||
harness) keeps one level, which follows its player (`enter_dimension`). LAN guests of a world run that way
|
||||
stay in the host's dimension. That mode is the escape hatch, so it gets no multi-level work.
|
||||
|
||||
## One code path for both
|
||||
|
||||
|
||||
+67
-18
@@ -24,7 +24,9 @@ Both are off by default. With no pack selected the renderer draws exactly what i
|
||||
| Java shader packs: full-screen pass plan (`horizon_shaderpack::pipeline`: `DRAWBUFFERS`/`RENDERTARGETS`, `colortexN` formats and clearing, buffer flips, `blend.*`) | done: planned for the three test packs |
|
||||
| Java shader packs: full-screen passes drawn (`java_pack.rs`, `gfx/java_stages.rs`: `begin`, `prepare`, `deferred*`, `composite*`, `final` over Horizon's world) | first slice: BSL and Complementary run (below); `depthtex1`/`depthtex2` before translucent geometry, `blend.*`, PNG `texture.<stage>.*` and `texture.noise` |
|
||||
| Java shader packs: custom uniforms (`horizon_shaderpack::uniforms`), `sunPathRotation`, `clouds`/`sun`/`moon`, eye light, `wetness` | done for the full-screen passes (below) |
|
||||
| Java shader packs: `gbuffers_*`, `shadow`, `.dat` and resource-location textures, images, compute | planned |
|
||||
| Java shader packs: `gbuffers_terrain`, `gbuffers_water` (`gfx/java_terrain.rs`: Horizon's terrain fed to the pack's programs) | first slice: MakeUp Ultra Fast, Chocapic13' and BSL draw their own terrain and water (below) |
|
||||
| Java shader packs: `shadow` programs (`gfx/java_stages.rs`: the shadow map from Horizon's terrain) | first slice: terrain shadows for MakeUp Ultra Fast, Chocapic13' and BSL (below) |
|
||||
| Java shader packs: other `gbuffers_*`, entity shadows, `shadowcomp`, `.dat` and resource-location textures, images, compute | planned |
|
||||
|
||||
## How the renderer draws a frame today
|
||||
|
||||
@@ -396,6 +398,49 @@ Picking one translates every planned pass of each dimension folder (`java_pack.r
|
||||
4. `final` draws into a target that replaces the world's picture, where Horizon packs' `composite` output goes: the
|
||||
hand, menus and HUD are drawn over it.
|
||||
|
||||
When the pack has a `gbuffers_terrain` (or `textured_lit`, `textured`, `basic`, the program Iris falls back to), the
|
||||
pack draws the terrain itself (`gfx/java_terrain.rs`): Horizon's world pass skips its own solid and translucent
|
||||
terrain, then
|
||||
|
||||
- at step 2, after Horizon's sky, entities and the rest enter `colortex0` and depth, the pack's terrain program draws
|
||||
every visible section into its `RENDERTARGETS` (the side the next pass reads), tested against that depth; the
|
||||
depth is then copied into `depthtex0` and `depthtex1`;
|
||||
- before the first `composite`, as Iris does, `gbuffers_water` (or the terrain program) draws the translucent
|
||||
layer with the pack's `blend.*` or vanilla's blending; the depth goes into `depthtex0` only.
|
||||
|
||||
The pack's vertex program reads Horizon's own terrain vertices (the arena pages, instanced or flat draws): a glue
|
||||
function written before `main` decodes them into Iris's attributes (`vaPosition`/`gl_Vertex` relative to the camera,
|
||||
`gl_Color` with vanilla's face shading undone, `gl_MultiTexCoord0/1`, the normal and `at_tangent` from the quad's
|
||||
four corners, `mc_midTexCoord`, `at_midBlock`), and `mc_Entity.x` is the block's id from the pack's
|
||||
`block.properties`, matched to the sprite's block by name. `gtexture`/`texture` is a 2D atlas built from Horizon's
|
||||
sprite arrays (animated sprites copied in each frame), `lightmap` is Horizon's lightmap with Iris's texture matrix,
|
||||
and fragments under alpha 0.1 are discarded (`alphaTestRef`). `RGBA16` targets are 16-bit unorm where the GPU can
|
||||
render them (Chocapic13' packs its G-buffer into them; half floats broke it).
|
||||
|
||||
Occlusion culling works as in Horizon's world pass where the GPU has it: the pack's terrain program draws the
|
||||
sections the last test saw, the boxes of the others are tested against that depth (in OpenGL's layout, in a pass of
|
||||
their own since the pack's targets vary; on Metal the test reads the depth in the shader, as Horizon's does), and the
|
||||
pack's program then draws the ones in sight, flat draws included.
|
||||
|
||||
When the pack has a `shadow` program (`shadow_cutout`, `shadow` or `shadow_solid` for the solid layer, which holds
|
||||
cutout blocks; `shadow_water` or `shadow` for the translucent one), it draws a shadow map after `begin*`, as Iris's
|
||||
`ShadowRenderer`:
|
||||
|
||||
- the light's matrices are Iris's `ShadowMatrices`: `shadowModelView` turns camera-relative positions toward the sun
|
||||
(the moon at night) with the pack's `sunPathRotation`, snapped to `shadowIntervalSize`; `shadowProjection` is
|
||||
orthographic, `shadowDistance` each way, between `shadowNearPlane` and `shadowFarPlane` (Iris's defaults when the
|
||||
pack sets none); the shadow programs get them as `gl_ModelViewMatrix` and `gl_ProjectionMatrix`;
|
||||
- the renderer draws every section inside that box whose shadow can reach the camera's view (the box swept away
|
||||
from the light against the view's planes, as Iris's advanced shadow culling), from its own list (cave culling is
|
||||
off as in Iris, and no face is culled), solid then translucent, instanced draws only;
|
||||
- `shadowtex0` holds all of it, `shadowtex1` the depth before translucent terrain; both are bound as depth textures
|
||||
for `sampler2DShadow` (linear comparison, Iris's hardware filtering) and as float copies for `sampler2D` reads;
|
||||
- `shadowcolor0..7` are `shadowMapResolution` square in their `shadowcolor<N>Format`, cleared each frame to
|
||||
`shadowcolor<N>ClearColor` (white by default) unless `shadowcolor<N>Clear` is false.
|
||||
|
||||
A shadow program that doesn't translate or that wgpu refuses leaves the pack without its shadow map, with a warning:
|
||||
the terrain programs still run and every `shadowtex` reads as far (nothing in shadow).
|
||||
|
||||
What makes Horizon look like OpenGL to the pack:
|
||||
|
||||
- **Layout.** The translator ends every vertex shader by flipping `gl_Position.y` and moving depth from `[-w, w]` to
|
||||
@@ -426,12 +471,16 @@ What makes Horizon look like OpenGL to the pack:
|
||||
|
||||
Measured on the Mac mini (M4 Pro) with the packs' default options: BSL v10.1.8 and Complementary Reimagined r5.9.3
|
||||
grade and light Horizon's world as their post-processing would. Sildur's Vibrant v2.02 draws only its sky: its
|
||||
`deferred` reads the albedo `gbuffers_*` write to `colortex4`. Without `gbuffers_*` and `shadow`, no pack has its
|
||||
shadows, water or sky look yet; Distant Horizons' and Voxy's depth read as far. Horizon's world goes into the target
|
||||
the pack's `gbuffers_terrain` (or the program Iris falls back to) draws to first: `colortex0` for most packs,
|
||||
`colortex1` for MakeUp. Not supported yet: images and compute (`.csh`; a pack that needs them doesn't load),
|
||||
mipmapped targets (`colortexNMipmapEnabled` is read but ignored), `.dat` and resource-location custom textures (PNG
|
||||
files in the pack only); shadow matrices are identity.
|
||||
`deferred` reads the albedo `gbuffers_*` write to `colortex4` (not checked again since the terrain programs run).
|
||||
Without the other `gbuffers_*`, no pack has its own sky yet, and only terrain casts shadows; Distant Horizons' and
|
||||
Voxy's depth read as far. Horizon's world goes into the target the pack's `gbuffers_terrain` (or the program Iris falls
|
||||
back to) draws to first: `colortex0` for most packs, `colortex1` for MakeUp. Not supported yet: images and compute
|
||||
(`.csh`; a pack that needs them doesn't load), mipmapped targets (`colortexNMipmapEnabled` is read but ignored), `.dat`
|
||||
and resource-location custom textures (PNG files in the pack only), `shadowcomp` passes, `shadowMapFov` (a perspective
|
||||
shadow map), `shadow.culling` and `shadowDistanceRenderMul` (the box above is the only bound). With the pack's terrain:
|
||||
entities, the sky, the hand, weather and clouds are still Horizon's (in that first target only), block ids come from
|
||||
sprite names rather than block states, distant (LOD) terrain doesn't repeat its textures, and only the overworld has
|
||||
been checked.
|
||||
|
||||
### Compatibility
|
||||
|
||||
@@ -439,19 +488,19 @@ Tested on 2026-10-22 on the Mac mini (M4 Pro, Metal) with each pack's default op
|
||||
the authors' pages (dev-only test inputs, kept outside the repository). "Loads" means every pass the pack enables for
|
||||
the overworld, Nether and End is preprocessed, translated and validated, as the game does when the pack is picked
|
||||
(`java_pack::tests::real_packs_load` with `HORIZON_TEST_SHADERPACKS`); "drawn" means it was also picked in the game
|
||||
and screenshotted. Only full-screen passes run (above), so no pack has its own terrain, water, shadows or sky yet.
|
||||
The MakeUp, Chocapic13' and Photon rows are from 2026-10-23, on Metal and on Vulkan (RTX 4080).
|
||||
and screenshotted. Full-screen passes, `gbuffers_terrain`, `gbuffers_water` and the terrain's shadow map run
|
||||
(above); no pack has its own entities or sky yet.
|
||||
|
||||
| Pack | Version | Result | First thing that stops it |
|
||||
| --- | --- | --- | --- |
|
||||
| BSL | v10.1.8 | drawn: 26 passes | |
|
||||
| BSL | v10.1.8 | drawn: 26 passes and its terrain and water programs | |
|
||||
| Complementary Reimagined | r5.9.3 | drawn: 27 passes | |
|
||||
| Complementary Unbound | r5.8 | loads: 27 passes | |
|
||||
| Sildur's Vibrant Extreme | 2.02 | drawn: 18 passes, only its sky (above) | |
|
||||
| Steadfast | 0.8.0 | drawn: 3 passes | |
|
||||
| MakeUp Ultra Fast | 9.6 | drawn: 6 passes, its fog, light shafts and grade over Horizon's world | |
|
||||
| Chocapic13' | V9 High | drawn: 16 passes; its default TAA upscaling shrinks the world in its `gbuffers_*` vertex programs, which don't run, so the picture is magnified 1/0.7: turn TAA Upscaling off in its settings | |
|
||||
| Photon | v1.4 | loads: 27 passes; draws black: it lights the scene from the G-buffer its `gbuffers_*` write | |
|
||||
| Chocapic13' | V9 High | drawn: 16 passes and its terrain and water programs | |
|
||||
| MakeUp Ultra Fast | 9.6 | drawn: 6 passes and its terrain and water programs | |
|
||||
| Photon | v1.4 | loads: 27 passes and its terrain and water programs; draws black | its sky and light come from a compute pass and `.dat` look-up tables, not loaded yet |
|
||||
| Bliss | 2.1.2 | fails | `world0/composite1.fsh`: a vector component passed to an `inout` parameter (WGSL can't point at one) |
|
||||
| Bloop | 1.8.0-Alpha-3 | fails | `world0/prepare1.fsh`: `mat2(vec4)` and other constructors naga's GLSL front end refuses |
|
||||
| Noble | v1.9.9 | fails (Apple and NVIDIA) | `world0/deferred4.vsh`: a `vec3[9][9]` varying (nested arrays between stages) |
|
||||
@@ -492,11 +541,11 @@ What the translator does for these packs, as Iris (JCPP and its GLSL transformer
|
||||
3. **Full-screen programs**: `deferred*`, `composite*`, `final` with `colortex0..15` (formats from the pack),
|
||||
`depthtex0..2`, `noisetex` and the uniforms (most are already computed for the world shaders). This alone runs
|
||||
Sildur's and BSL post-processing over Horizon's own world.
|
||||
4. **`gbuffers_terrain`, `gbuffers_water`, `gbuffers_sky*`**: Horizon's terrain vertices extended with Iris's
|
||||
attributes (`mc_Entity` from `block.properties`, `mc_midTexCoord`, `at_tangent`, lightmap), a second set of
|
||||
terrain pipelines writing the pack's render targets.
|
||||
4. **`gbuffers_terrain`, `gbuffers_water`** (done, above), **`gbuffers_sky*`**: Horizon's terrain vertices decoded
|
||||
into Iris's attributes (`mc_Entity` from `block.properties`, `mc_midTexCoord`, `at_tangent`, lightmap), a second
|
||||
set of terrain pipelines writing the pack's render targets.
|
||||
5. **Entities, block entities, hand, weather, clouds, particles** `gbuffers_*`.
|
||||
6. **Shadow pass**: terrain and entities from the sun into `shadowtex0/1` and `shadowcolor0/1`, culled from the
|
||||
light's view, with the pack's distortion.
|
||||
6. **Shadow pass** (terrain done, above): entities and block entities from the sun into `shadowtex0/1` and
|
||||
`shadowcolor0/1`, culled from the light's view, with the pack's distortion; `shadowcomp`.
|
||||
7. **The rest**: custom textures, compute programs and images, per-dimension
|
||||
folders, `entity.properties`/`item.properties`.
|
||||
+726
-2
@@ -189,6 +189,39 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
- totem: the host and the guest each take a lethal hit holding a totem of undying in the off
|
||||
hand, then the main hand: the server uses it up and keeps them alive at 1 health with its
|
||||
effects, and each side hears the totem once (entity event 35) (#1292).
|
||||
- keepinv: the host and the guest each die holding diamonds and wearing a helmet: with keep_inventory
|
||||
off both drop where they died and the player respawns without them; with it on nothing
|
||||
drops and the player keeps them (#1284).
|
||||
- signs: the host and the guest each place an oak sign: the editor opens on their own screen
|
||||
(ClientboundOpenSignEditorPacket), the two lines they write reach the server
|
||||
(ServerboundSignUpdatePacket) and both sides show them (#1380).
|
||||
- books: the host and the guest each write in a book and quill on their own screen, sign it
|
||||
(ServerboundEditBookPacket), read the written book the server gave them
|
||||
(ClientboundOpenBookPacket), then drop it: the other side sees its title, author and
|
||||
page (#1366).
|
||||
- elytra: the host and the guest, high up with an Elytra on, jump in the air and glide
|
||||
(ServerboundPlayerCommandPacket START_FALL_FLYING): both their client and the server
|
||||
have them gliding, they go forward far more than down, and the glide ends on both once
|
||||
they land (#1288).
|
||||
- helduse: the host and the guest raise a shield, charge and fire a crossbow, throw a trident
|
||||
and scope with a spyglass: each use starts on their own client and on the server, and
|
||||
letting go ends it on both (RELEASE_USE_ITEM), so the shield lowers, the trident flies
|
||||
and the loaded crossbow fires on the next use (#1300, #1281, #1282, #1301).
|
||||
- wind: the host and the guest each throw a wind charge at their feet: the server's burst
|
||||
launches them about six blocks up (the push in ClientboundExplodePacket) and they land
|
||||
unhurt (#1386).
|
||||
- fishing: the host and the guest each cast a fishing rod: the hook stays out on the server and
|
||||
both sides draw it and it reels back in from the ground, and in open water a fish
|
||||
bites and reeling in catches it (#1348).
|
||||
- maps: the host and the guest each make a map and hold it: their client draws the terrain
|
||||
around them and their marker, from the server's map data (#1382).
|
||||
- body: the host and the guest each walk while drawing a bow (slowed), stand on powder snow
|
||||
with leather boots (held up) and without (sinking), and sprint-swim looking down
|
||||
(diving): their own client decides it (#1278, #1296, #1303).
|
||||
- strikes: a bolt strikes the host and the guest standing in water and hurts them, and a cave
|
||||
spider's bite poisons them (#1480, #1484).
|
||||
- cursor: the host and the guest each close their inventory with a stack on the cursor: it
|
||||
goes back into their inventory, none is thrown and the cursor is empty (#1283).
|
||||
|
||||
Each check is reported on its own (a failing one doesn't stop the others) and the screenshots of
|
||||
both sides go to --out (host-*.png, guest-*.png).
|
||||
@@ -4517,8 +4550,699 @@ def check_totem(host, guest, out):
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['totem']
|
||||
CHECK_FNS.update(totem=check_totem)
|
||||
def check_keep_inventory(host, guest, out):
|
||||
"""ServerPlayer.die's dropAllDeathLoot for each player (Player.dropEquipment): with keep_inventory
|
||||
off, the diamonds and the helmet a player dies with lie where it died and it respawns with
|
||||
neither; with it on, nothing drops and it respawns with both (#1284)."""
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
seen = {}
|
||||
host('command', command=f'setworldspawn {hx} {hy} {hz}')
|
||||
host('command', command='gamerule respawn_radius 0')
|
||||
try:
|
||||
for keep in ('false', 'true'):
|
||||
host('command', command=f'gamerule keep_inventory {keep}')
|
||||
for game, dz in ((host, -3), (guest, 3)):
|
||||
# 14 blocks from the world spawn: nothing dropped is picked up on respawning.
|
||||
x, z = hx + 14, hz + dz
|
||||
host('command', command=f'fill {x - 2} {hy - 1} {z - 1} {x + 2} {hy - 1} {z + 1} minecraft:stone')
|
||||
host('command', command=f'fill {x - 2} {hy} {z - 1} {x + 2} {hy + 2} {z + 1} minecraft:air')
|
||||
host('command', command='kill @e[type=minecraft:item]')
|
||||
game('command', command='gamemode survival')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='give @s minecraft:diamond 7')
|
||||
game('command', command='item replace entity @s armor.head with minecraft:iron_helmet')
|
||||
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5}')
|
||||
game.wait(20)
|
||||
game('command', command='kill @s')
|
||||
game.wait(30)
|
||||
dropped = sorted((e['item']['item'], e['item']['count'])
|
||||
for e in game.raw('entities', 8, 'minecraft:item')['entities'])
|
||||
game.shot(out, f'keepinv-{keep}')
|
||||
respawn_when_ready(game)
|
||||
game.wait(40)
|
||||
back_to_game(game)
|
||||
seen[f'{game.name}-keep-{keep}'] = {
|
||||
'dropped': dropped,
|
||||
'kept': {item: server_count(game, item) for item in ('diamond', 'iron_helmet')}}
|
||||
finally:
|
||||
host('command', command='gamerule keep_inventory false')
|
||||
host('command', command='gamerule respawn_radius 10')
|
||||
host('command', command='kill @e[type=minecraft:item]')
|
||||
for game in (host, guest):
|
||||
game('command', command='clear @s')
|
||||
face_each_other(host, guest)
|
||||
for case, s in seen.items():
|
||||
want = ({'dropped': [], 'kept': {'diamond': 7, 'iron_helmet': 1}} if case.endswith('true') else
|
||||
{'dropped': [('minecraft:diamond', 7), ('minecraft:iron_helmet', 1)],
|
||||
'kept': {'diamond': 0, 'iron_helmet': 0}})
|
||||
assert s == want, f'{case}: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
def check_signs(host, guest, out):
|
||||
"""SignBlock.openTextEdit on the placing player's client, for the host and the guest: placing
|
||||
an oak sign opens its editor there and nowhere else, the lines typed into it go to the server
|
||||
when it closes, and both sides then show them (#1380)."""
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
seen = {}
|
||||
for game, other, dz in ((host, guest, -2), (guest, host, 2)):
|
||||
spot = (hx + 2, hy, hz + dz)
|
||||
game('command', command=f'setblock {spot[0]} {spot[1] - 1} {spot[2]} minecraft:stone')
|
||||
game('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
|
||||
game('command', command='gamemode survival')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='give @s minecraft:oak_sign 1')
|
||||
game.wait(10)
|
||||
game.raw('place', *spot, '--item', 'minecraft:oak_sign')
|
||||
game.wait(10)
|
||||
opened = {g.name: g('state').get('screen') for g in (game, other)}
|
||||
game.shot(out, 'signs-editor')
|
||||
lines = [f'{game.name} wrote', 'line two']
|
||||
game.raw('type', lines[0])
|
||||
game.raw('key', 'enter', 'tap')
|
||||
game.raw('type', lines[1])
|
||||
game.raw('key', 'esc', 'tap')
|
||||
game.wait(20)
|
||||
back_to_game(other)
|
||||
seen[game.name] = {'opened': opened,
|
||||
'shown': {g.name: (g.raw('block', *spot).get('sign') or {}).get('front')
|
||||
for g in (game, other)}}
|
||||
other.shot(out, 'signs-shown')
|
||||
for name, s in seen.items():
|
||||
other = 'guest' if name == 'host' else 'host'
|
||||
want = [f'{name} wrote', 'line two', '', '']
|
||||
assert s['opened'] == {name: 'sign', other: 'playing'}, f'{name}: {seen}'
|
||||
assert s['shown'] == {name: want, other: want}, f'{name}: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
def book_screen(game):
|
||||
"""The open book (`book`'s page and page count), once `game` shows it."""
|
||||
game.raw('wait-for', 'screen', 'book', '--timeout', 10)
|
||||
return game.raw('book')
|
||||
|
||||
|
||||
def check_books(host, guest, out):
|
||||
"""Books in hand for the host and the guest: a book and quill opens its editor on the player's
|
||||
own client (LocalPlayer.openItemGui), what it writes and signs reaches the server
|
||||
(ServerboundEditBookPacket), and the written book opens on the player's client when the server
|
||||
says so (ServerPlayer.openItemGui's ClientboundOpenBookPacket); dropped, the other side sees the
|
||||
server's book (#1366)."""
|
||||
seen = {}
|
||||
for game, other in ((host, guest), (guest, host)):
|
||||
game('command', command='gamemode survival')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='item replace entity @s weapon.mainhand with minecraft:writable_book')
|
||||
x, y, z = pos_of(game)
|
||||
game.raw('look-at', f'{x + 0.5:.2f}', f'{y + 30:.2f}', f'{z:.2f}')
|
||||
game.wait(10)
|
||||
case = seen[game.name] = {}
|
||||
game.raw('use-item')
|
||||
case['edit'] = book_screen(game)
|
||||
case['other_screen'] = other('state').get('screen')
|
||||
game.raw('type', f'{game.name} page one')
|
||||
game.shot(out, 'books-edit')
|
||||
game.raw('book', 'sign')
|
||||
game.raw('type', f'{game.name} book')
|
||||
game.raw('book', 'sign')
|
||||
game.wait(20)
|
||||
case['signed'] = {item: server_count(game, item) for item in ('writable_book', 'written_book')}
|
||||
game.raw('use-item')
|
||||
case['view'] = book_screen(game)
|
||||
game.shot(out, 'books-view')
|
||||
game.raw('book', 'done')
|
||||
game.wait(5)
|
||||
game.raw('key', 'q', 'tap')
|
||||
game.wait(30)
|
||||
books = [e['item'].get('components', '') for e in other.raw('entities', 16, 'minecraft:item')['entities']
|
||||
if e['item']['item'] == 'minecraft:written_book']
|
||||
case['dropped'] = [all(f'{game.name} {t}' in c for t in ('page one', 'book')) for c in books]
|
||||
host('command', command='kill @e[type=minecraft:item]')
|
||||
for name, case in seen.items():
|
||||
assert case['edit'] == {'page': 0, 'pages': 1, 'lectern': None}, f'{name}: {seen}'
|
||||
assert case['other_screen'] == 'playing', f'{name}: {seen}'
|
||||
assert case['signed'] == {'writable_book': 0, 'written_book': 1}, f'{name}: {seen}'
|
||||
assert case['view'] == {'page': 0, 'pages': 1, 'lectern': None}, f'{name}: {seen}'
|
||||
assert case['dropped'] == [True], f'{name}: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['totem', 'keepinv', 'signs', 'books']
|
||||
CHECK_FNS.update(totem=check_totem, keepinv=check_keep_inventory, signs=check_signs, books=check_books)
|
||||
|
||||
|
||||
def check_elytra(host, guest, out):
|
||||
"""LivingEntity.updateFallFlying for each player: a jump while falling with an Elytra on starts a
|
||||
glide that the server takes too (Player.tryToStartFallFlying), the glide carries the player
|
||||
forward rather than down, and landing ends it on both sides (canGlide fails on the ground)."""
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
seen = {}
|
||||
try:
|
||||
for game, dx in ((host, -2), (guest, 2)):
|
||||
x, z = hx + dx, hz
|
||||
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 1} {hy - 1} {z + 1} minecraft:stone')
|
||||
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 1} {hy + 3} {z + 1} minecraft:air')
|
||||
game('command', command='gamemode survival')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='effect give @s minecraft:resistance 60 4 true')
|
||||
game('command', command='item replace entity @s armor.chest with minecraft:elytra')
|
||||
game('command', command=f'tp @s {x + 0.5} {hy + 60} {z + 0.5} 0 0')
|
||||
game.wait(6)
|
||||
start = pos_of(game)
|
||||
game.raw('key', 'space', 'down')
|
||||
game.wait(1)
|
||||
game.raw('key', 'space', 'up')
|
||||
game.wait(30)
|
||||
end = pos_of(game)
|
||||
glide = {'client': own(game).get('fall_flying'),
|
||||
'server': server_player(host, game).get('fall_flying'),
|
||||
'forward': round(math.hypot(end[0] - start[0], end[2] - start[2]), 1),
|
||||
'down': round(start[1] - end[1], 1)}
|
||||
game.shot(out, 'elytra')
|
||||
game('command', command=f'tp @s {x + 0.5} {hy + 1} {z + 0.5}')
|
||||
game.wait(30)
|
||||
landed = {'on_ground': own(game).get('on_ground'),
|
||||
'client': own(game).get('fall_flying'),
|
||||
'server': server_player(host, game).get('fall_flying')}
|
||||
seen[game.name] = {'glide': glide, 'landed': landed}
|
||||
finally:
|
||||
for game in (host, guest):
|
||||
game('command', command='clear @s')
|
||||
game('command', command='effect clear @s')
|
||||
face_each_other(host, guest)
|
||||
for name, s in seen.items():
|
||||
g, l = s['glide'], s['landed']
|
||||
# Falling alone covers about 25 blocks in those 30 ticks and goes nowhere.
|
||||
assert g['client'] and g['server'] and g['forward'] > 8 and g['down'] < 20, f'{name} glide: {seen}'
|
||||
assert l['on_ground'] and not l['client'] and not l['server'], f'{name} landed: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['elytra']
|
||||
CHECK_FNS.update(elytra=check_elytra)
|
||||
|
||||
|
||||
def server_using(host, game):
|
||||
"""The item the server has the player using ({slot, ticks}), or None."""
|
||||
if game is host and not SERVER_THREAD:
|
||||
return host('state').get('using')
|
||||
return server_player(host, game).get('using')
|
||||
|
||||
|
||||
def flying(game, kind):
|
||||
"""How many of the projectiles this side draws are of `kind` (a trident, an arrow)."""
|
||||
return sum(kind in q['kind'].lower() for q in game('status')['projectiles'])
|
||||
|
||||
|
||||
def check_held_use(host, guest, out):
|
||||
"""Items used until the button lets go, for each player: the client starts the use too
|
||||
(Item.use runs on both sides), so letting go sends RELEASE_USE_ITEM and the server ends it."""
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
seen = {}
|
||||
try:
|
||||
for game, dz in ((host, -3), (guest, 3)):
|
||||
x, z = hx, hz + dz
|
||||
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 12} {hy - 1} {z + 1} minecraft:stone')
|
||||
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 12} {hy + 3} {z + 1} minecraft:air')
|
||||
host('command', command=f'fill {x + 12} {hy} {z - 1} {x + 12} {hy + 3} {z + 1} minecraft:stone')
|
||||
game('command', command='gamemode survival')
|
||||
game('command', command='clear @s')
|
||||
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
|
||||
game.wait(10)
|
||||
s = {}
|
||||
# ShieldItem: raised while held, lowered once let go.
|
||||
game('command', command='item replace entity @s weapon.offhand with minecraft:shield')
|
||||
game.wait(5)
|
||||
game.raw('mouse', 'right', 'down')
|
||||
game.wait(10)
|
||||
s['shield'] = {'held': (bool(game('state').get('using')), bool(server_using(host, game)))}
|
||||
game.raw('mouse', 'right', 'up')
|
||||
game.wait(10)
|
||||
s['shield']['let_go'] = (bool(game('state').get('using')), bool(server_using(host, game)))
|
||||
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
|
||||
# SpyglassItem: scoping while held.
|
||||
game('command', command='item replace entity @s weapon.mainhand with minecraft:spyglass')
|
||||
game.wait(5)
|
||||
game.raw('mouse', 'right', 'down')
|
||||
game.wait(10)
|
||||
s['spyglass'] = {'held': (bool(game('state').get('using')), bool(server_using(host, game)))}
|
||||
game.raw('mouse', 'right', 'up')
|
||||
game.wait(10)
|
||||
s['spyglass']['let_go'] = (bool(game('state').get('using')), bool(server_using(host, game)))
|
||||
# TridentItem.releaseUsing after 10 ticks: thrown.
|
||||
host('command', command='kill @e[type=minecraft:trident]')
|
||||
game('command', command='item replace entity @s weapon.mainhand with minecraft:trident')
|
||||
game.wait(5)
|
||||
# /kill finds no thrown trident (Horizon's selectors miss them): count what's new.
|
||||
before = flying(game, 'trident')
|
||||
game.raw('use-item', '--hold', 1.2)
|
||||
game.wait(10)
|
||||
s['trident'] = {'kept': server_count(game, 'trident'),
|
||||
'thrown': flying(game, 'trident') - before}
|
||||
host('command', command='kill @e[type=minecraft:trident]')
|
||||
# CrossbowItem: charged by holding (25 ticks), fired by the next use.
|
||||
host('command', command='kill @e[type=minecraft:arrow]')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='item replace entity @s weapon.mainhand with minecraft:crossbow')
|
||||
game('command', command='give @s minecraft:arrow 16')
|
||||
game.wait(5)
|
||||
before = flying(game, 'arrow')
|
||||
game.raw('use-item', '--hold', 1.6)
|
||||
game.wait(10)
|
||||
held = game('state').get('held') or {}
|
||||
s['crossbow'] = {'loaded': 'charged_projectiles' in str(held.get('components', '')),
|
||||
'arrows': server_count(game, 'arrow')}
|
||||
game.raw('use-item')
|
||||
game.wait(10)
|
||||
held = game('state').get('held') or {}
|
||||
s['crossbow'].update(
|
||||
fired=flying(game, 'arrow') - before,
|
||||
emptied='charged_projectiles' not in str(held.get('components', '')))
|
||||
game.shot(out, 'helduse')
|
||||
seen[game.name] = s
|
||||
finally:
|
||||
host('command', command='kill @e[type=minecraft:arrow]')
|
||||
host('command', command='kill @e[type=minecraft:trident]')
|
||||
for game in (host, guest):
|
||||
back_to_game(game)
|
||||
game.raw('mouse', 'right', 'up')
|
||||
game('command', command='clear @s')
|
||||
face_each_other(host, guest)
|
||||
for name, s in seen.items():
|
||||
for item in ('shield', 'spyglass'):
|
||||
assert s[item] == {'held': (True, True), 'let_go': (False, False)}, f'{name} {item}: {seen}'
|
||||
assert s['trident'] == {'kept': 0, 'thrown': 1}, f'{name} trident: {seen}'
|
||||
assert s['crossbow'] == {'loaded': True, 'arrows': 15, 'fired': 1, 'emptied': True}, \
|
||||
f'{name} crossbow: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['helduse']
|
||||
CHECK_FNS.update(helduse=check_held_use)
|
||||
|
||||
|
||||
def check_wind(host, guest, out):
|
||||
"""A wind charge each player throws at its own feet (ServerExplosion with TRIGGER_BLOCK): the
|
||||
server's burst pushes the thrower through its ClientboundExplodePacket (hitPlayers), which
|
||||
launches it about six blocks up, and onExplosionHit spares it the fall damage where it lands
|
||||
(#1386)."""
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
host('command', command='gamerule natural_health_regeneration false')
|
||||
seen = {}
|
||||
try:
|
||||
for game, dz in ((host, -4), (guest, 4)):
|
||||
x, z = hx, hz + dz
|
||||
host('command', command=f'fill {x - 2} {hy - 1} {z - 2} {x + 2} {hy - 1} {z + 2} minecraft:stone')
|
||||
host('command', command=f'fill {x - 2} {hy} {z - 2} {x + 2} {hy + 10} {z + 2} minecraft:air')
|
||||
game('command', command='gamemode survival')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='effect clear @s')
|
||||
game('command', command='effect give @s minecraft:instant_health 1 5 true')
|
||||
game('command', command='give @s minecraft:wind_charge 2')
|
||||
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} 0 90')
|
||||
game.wait(20)
|
||||
start = pos_of(game)[1]
|
||||
game.raw('use-item')
|
||||
peak = start
|
||||
for _ in range(30):
|
||||
game.wait(2)
|
||||
peak = max(peak, pos_of(game)[1])
|
||||
game.wait(40)
|
||||
game.shot(out, 'wind')
|
||||
me = own(game)
|
||||
seen[game.name] = {'rose': round(peak - start, 1),
|
||||
'landed': round(me['position'][1] - start, 1),
|
||||
'health': me['health'],
|
||||
'server_health': server_player(host, game)['health'],
|
||||
'charges': server_count(game, 'wind_charge')}
|
||||
finally:
|
||||
host('command', command='gamerule natural_health_regeneration true')
|
||||
for game in (host, guest):
|
||||
game('command', command='clear @s')
|
||||
face_each_other(host, guest)
|
||||
for name, s in seen.items():
|
||||
# Vanilla rises about 6 blocks from a charge at the feet.
|
||||
assert 4 < s['rose'] < 8 and s['landed'] == 0, f'{name}: {seen}'
|
||||
assert s['health'] == 20 and s['server_health'] == 20 and s['charges'] == 1, f'{name}: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['wind']
|
||||
CHECK_FNS.update(wind=check_wind)
|
||||
|
||||
|
||||
def bobbers(game):
|
||||
"""The fishing bobbers this side draws, as (owner) ids: 0 for its own player's."""
|
||||
return [q.get('owner') for q in game('status')['projectiles'] if q['kind'] == 'minecraft:fishing_bobber']
|
||||
|
||||
|
||||
def check_fishing(host, guest, out):
|
||||
"""FishingHook for each player, on the server: a cast hook stays out (ticked by the server and
|
||||
tracked to the clients as a fishing bobber under its owner), lands on the ground and reels back
|
||||
in; cast into open water with Lure III, a fish bites and reeling in
|
||||
during the bite catches it for 1 (#1348)."""
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
host('command', command='weather clear')
|
||||
seen = {}
|
||||
try:
|
||||
for game, other, dz in ((host, guest, -5), (guest, host, 5)):
|
||||
x, z = hx, hz + dz
|
||||
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 14} {hy - 1} {z + 1} minecraft:stone')
|
||||
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 14} {hy + 4} {z + 1} minecraft:air')
|
||||
game('command', command='gamemode survival')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
|
||||
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
|
||||
game.wait(10)
|
||||
# On the ground: the floor four blocks ahead.
|
||||
game.raw('look-at', f'{x + 4.5:.2f}', f'{hy:.2f}', f'{z + 0.5:.2f}')
|
||||
game.raw('use-item')
|
||||
game.wait(40)
|
||||
s = {'own': bobbers(game), 'other': bobbers(other)}
|
||||
game.shot(out, 'fishing-cast')
|
||||
game.raw('use-item')
|
||||
game.wait(10)
|
||||
s['reeled'] = {'own': bobbers(game), 'damage': (game('state').get('held') or {}).get('damage')}
|
||||
# Open water: a pool 7x7 and 3 deep, ahead.
|
||||
host('command', command=f'fill {x + 3} {hy - 3} {z - 3} {x + 9} {hy - 1} {z + 3} minecraft:water')
|
||||
game('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod[enchantments={lure:3}]')
|
||||
game.wait(10)
|
||||
game.raw('look-at', f'{x + 6.5:.2f}', f'{hy - 1:.2f}', f'{z + 0.5:.2f}')
|
||||
game.raw('use-item')
|
||||
bit = None
|
||||
for t in range(0, 900, 5):
|
||||
game.wait(5)
|
||||
if (game('status').get('fishing') or {}).get('nibble', 0) > 0:
|
||||
bit = t
|
||||
break
|
||||
game.raw('use-item')
|
||||
game.wait(40)
|
||||
counts = game.raw('inventory')['counts']
|
||||
s['caught'] = {'bit': bit is not None,
|
||||
'damage': (game('state').get('held') or {}).get('damage'),
|
||||
'items': sum(counts.values()) - 1}
|
||||
game.shot(out, 'fishing-caught')
|
||||
host('command', command=f'fill {x + 3} {hy - 3} {z - 3} {x + 9} {hy - 2} {z + 3} minecraft:stone')
|
||||
host('command', command=f'fill {x + 3} {hy - 1} {z - 3} {x + 9} {hy - 1} {z + 3} minecraft:stone')
|
||||
seen[game.name] = s
|
||||
finally:
|
||||
host('command', command='kill @e[type=minecraft:item]')
|
||||
for game in (host, guest):
|
||||
game('command', command='clear @s')
|
||||
face_each_other(host, guest)
|
||||
for name, s in seen.items():
|
||||
other = guest if name == 'host' else host
|
||||
assert s['own'] == [0], f'{name}: {seen}'
|
||||
# The in-frame host draws only its own hook.
|
||||
if other is guest or SERVER_THREAD:
|
||||
assert len(s['other']) == 1 and s['other'][0] != 0, f'{name}: {seen}'
|
||||
# FishingHook.retrieve costs 2 on the ground, but a resting hook's onGround
|
||||
# alternates every tick on the server (Entity.move with no vertical motion
|
||||
# clears it, the next tick's gravity sets it again): 2 or nothing.
|
||||
assert s['reeled']['own'] == [] and s['reeled']['damage'] in (None, 0, 2), f'{name}: {seen}'
|
||||
assert s['caught']['bit'] and s['caught']['damage'] == 1 and s['caught']['items'] >= 1, f'{name}: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['fishing']
|
||||
CHECK_FNS.update(fishing=check_fishing)
|
||||
|
||||
|
||||
def check_maps(host, guest, out):
|
||||
"""MapItem.update for each player, on the server: a map the player makes and holds reveals the
|
||||
terrain around them and shows their marker, which reach their client in
|
||||
ClientboundMapItemDataPacket (#1382)."""
|
||||
seen = {}
|
||||
try:
|
||||
for game in (host, guest):
|
||||
game('command', command='gamemode survival')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='item replace entity @s weapon.mainhand with minecraft:map')
|
||||
x, y, z = pos_of(game)
|
||||
game.raw('look-at', f'{x + 5:.2f}', f'{y + 1.6:.2f}', f'{z:.2f}')
|
||||
game.wait(5)
|
||||
game.raw('use-item')
|
||||
# 16 ticks sweep every column once; the marker goes out every 5th tick.
|
||||
game.wait(60)
|
||||
maps = game('status').get('maps', [])
|
||||
game.raw('look-at', f'{x + 1:.2f}', f'{y:.2f}', f'{z:.2f}')
|
||||
game.wait(5)
|
||||
game.shot(out, 'maps-held')
|
||||
seen[game.name] = [{'pixels': m['pixels'], 'decorations': m['decorations']} for m in maps]
|
||||
finally:
|
||||
for game in (host, guest):
|
||||
game('command', command='clear @s')
|
||||
face_each_other(host, guest)
|
||||
for name, maps in seen.items():
|
||||
# Revealed out to the server's view distance, at least a 32-block disk.
|
||||
assert len(maps) == 1 and maps[0]['pixels'] > 3000, f'{name}: {seen}'
|
||||
assert maps[0]['decorations'] == ['map/decorations/player'], f'{name}: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['maps']
|
||||
CHECK_FNS.update(maps=check_maps)
|
||||
|
||||
|
||||
def check_body(host, guest, out):
|
||||
"""What each player's own client decides for its body (#1278, #1303, #1296): walking while
|
||||
drawing a bow is slowed to a fifth (LocalPlayer.modifyInput), sprint-swimming follows the look
|
||||
down (Player.travel's look angle) and leather boots hold on powder snow
|
||||
(PowderSnowBlock.canEntityWalkOnPowderSnow)."""
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
seen = {}
|
||||
|
||||
def walked(game, x, z, use):
|
||||
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
|
||||
game.wait(10)
|
||||
if use:
|
||||
game.raw('mouse', 'right', 'down')
|
||||
game.wait(5)
|
||||
a = pos_of(game)
|
||||
game.raw('move', 'forward', '0.8')
|
||||
game.wait(10)
|
||||
b = pos_of(game)
|
||||
game.raw('mouse', 'right', 'up')
|
||||
game.wait(5)
|
||||
return round(math.dist((a[0], a[2]), (b[0], b[2])), 2)
|
||||
|
||||
try:
|
||||
for game, dz in ((host, -4), (guest, 4)):
|
||||
x, z = hx, hz + dz
|
||||
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 10} {hy - 1} {z + 1} minecraft:stone')
|
||||
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 10} {hy + 3} {z + 1} minecraft:air')
|
||||
game('command', command='gamemode survival')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='effect clear @s')
|
||||
s = {'walk': walked(game, x, z, False)}
|
||||
game('command', command='item replace entity @s weapon.mainhand with minecraft:bow')
|
||||
game('command', command='give @s minecraft:arrow 4')
|
||||
game.wait(5)
|
||||
s['drawing'] = walked(game, x, z, True)
|
||||
game('command', command='clear @s')
|
||||
# Powder snow under the feet: leather boots stand on it, bare feet sink.
|
||||
host('command', command=f'setblock {x + 3} {hy - 1} {z} minecraft:powder_snow')
|
||||
for boots in ('leather_boots', 'air'):
|
||||
game('command', command=f'item replace entity @s armor.feet with minecraft:{boots}')
|
||||
game('command', command=f'tp @s {x + 3.5} {hy} {z + 0.5} -90 0')
|
||||
game.wait(30)
|
||||
s[boots] = round(pos_of(game)[1] - hy, 2)
|
||||
host('command', command=f'setblock {x + 3} {hy - 1} {z} minecraft:stone')
|
||||
# Deep water: sprint-swimming while looking down dives.
|
||||
wx, wz = x, z + 2 * (1 if dz > 0 else -1) * 3
|
||||
host('command', command=f'fill {wx - 1} {hy - 14} {wz - 2} {wx + 12} {hy + 2} {wz + 2} minecraft:stone')
|
||||
host('command', command=f'fill {wx} {hy - 13} {wz - 1} {wx + 11} {hy - 1} {wz + 1} minecraft:water')
|
||||
game.wait(10)
|
||||
game('command', command=f'tp @s {wx + 1.5} {hy - 6} {wz + 0.5} -90 60')
|
||||
game.wait(10)
|
||||
a = pos_of(game)
|
||||
game.raw('move', 'forward', '1.5', '--sprint')
|
||||
game.wait(5)
|
||||
s['dive'] = round(pos_of(game)[1] - a[1], 2)
|
||||
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
|
||||
game.wait(5)
|
||||
host('command', command=f'fill {wx - 1} {hy - 14} {wz - 2} {wx + 12} {hy + 2} {wz + 2} minecraft:air')
|
||||
host('command', command=f'fill {wx - 1} {hy - 14} {wz - 2} {wx + 12} {hy - 1} {wz + 2} minecraft:stone')
|
||||
game.shot(out, 'body')
|
||||
seen[game.name] = s
|
||||
finally:
|
||||
for game in (host, guest):
|
||||
back_to_game(game)
|
||||
game.raw('mouse', 'right', 'up')
|
||||
game('command', command='clear @s')
|
||||
face_each_other(host, guest)
|
||||
for name, s in seen.items():
|
||||
assert s['walk'] > 2.5 and s['drawing'] < s['walk'] * 0.4, f'{name} drawing a bow: {seen}'
|
||||
assert s['leather_boots'] > -0.01 and s['air'] < -0.5, f'{name} powder snow: {seen}'
|
||||
assert s['dive'] < -2, f'{name} sprint-swimming down: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['body']
|
||||
CHECK_FNS.update(body=check_body)
|
||||
|
||||
|
||||
def check_strikes(host, guest, out):
|
||||
"""What a mob or the sky does to every player in reach, the server's players too (#1480,
|
||||
#1484): a bolt striking a player standing in water costs them health (Entity.thunderHit), and
|
||||
a cave spider's bite poisons them (CaveSpider.doHurtTarget on Normal)."""
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
seen = {}
|
||||
try:
|
||||
host('command', command='difficulty normal')
|
||||
host('command', command='time set 18000')
|
||||
for game, dz in ((host, -5), (guest, 5)):
|
||||
x, z = hx, hz + dz
|
||||
host('command', command=f'fill {x - 3} {hy - 1} {z - 3} {x + 3} {hy + 3} {z + 3} minecraft:stone')
|
||||
host('command', command=f'fill {x - 2} {hy} {z - 2} {x + 2} {hy + 3} {z + 2} minecraft:air')
|
||||
host('command', command=f'fill {x - 2} {hy} {z - 2} {x + 2} {hy} {z + 2} minecraft:water')
|
||||
game('command', command='gamemode survival')
|
||||
game('command', command='clear @s')
|
||||
game('command', command='effect clear @s')
|
||||
game('command', command='effect give @s minecraft:instant_health 1 5')
|
||||
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5}')
|
||||
game.wait(20)
|
||||
before = game('state')['player'].get('health')
|
||||
game('command', command=f'summon minecraft:lightning_bolt {x + 0.5} {hy} {z + 0.5}')
|
||||
game.wait(20)
|
||||
s = {'bolt': (before, game('state')['player'].get('health'))}
|
||||
host('command', command=f'summon minecraft:cave_spider {x + 1.5} {hy} {z + 0.5}')
|
||||
poisoned = False
|
||||
for _ in range(40):
|
||||
game.wait(5)
|
||||
if 'poison' in effect_ids(game):
|
||||
poisoned = True
|
||||
break
|
||||
s['poisoned'] = poisoned
|
||||
s['bitten'] = game('state')['player'].get('health')
|
||||
host('command', command='kill @e[type=minecraft:cave_spider]')
|
||||
game.wait(5)
|
||||
game.shot(out, 'strikes')
|
||||
host('command', command=f'fill {x - 3} {hy - 1} {z - 3} {x + 3} {hy + 3} {z + 3} minecraft:air')
|
||||
host('command', command=f'fill {x - 3} {hy - 1} {z - 3} {x + 3} {hy - 1} {z + 3} minecraft:stone')
|
||||
seen[game.name] = s
|
||||
finally:
|
||||
host('command', command='kill @e[type=minecraft:cave_spider]')
|
||||
host('command', command='time set day')
|
||||
for game in (host, guest):
|
||||
back_to_game(game)
|
||||
game('command', command='effect clear @s')
|
||||
game('command', command='effect give @s minecraft:instant_health 1 5')
|
||||
face_each_other(host, guest)
|
||||
for name, s in seen.items():
|
||||
a, b = s['bolt']
|
||||
assert a is not None and b is not None and a - b >= 3, f'{name} struck by lightning: {seen}'
|
||||
assert s['poisoned'], f'{name} bitten by a cave spider: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['strikes']
|
||||
CHECK_FNS.update(strikes=check_strikes)
|
||||
|
||||
|
||||
def check_cursor(host, guest, out):
|
||||
"""Closing the inventory with a stack on the cursor puts it back into the player's inventory
|
||||
(AbstractContainerMenu.removed, placeItemBackInInventory) and empties the cursor, for each
|
||||
player: nothing is thrown out and no copy stays on the cursor (#1283)."""
|
||||
seen = {}
|
||||
try:
|
||||
for game in (host, guest):
|
||||
game('command', command='clear @s')
|
||||
host('command', command='kill @e[type=minecraft:item]')
|
||||
game('command', command='item replace entity @s hotbar.0 with minecraft:diamond 7')
|
||||
game.wait(10)
|
||||
game.raw('open-inventory')
|
||||
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
|
||||
game.raw('click-slot', 0)
|
||||
game.wait(5)
|
||||
held = (game('inventory').get('carried') or {}).get('count')
|
||||
game.raw('key', 'esc', 'tap')
|
||||
game.wait(40)
|
||||
inv = game('inventory')
|
||||
seen[game.name] = {'held': held, 'carried': inv.get('carried'),
|
||||
'client': inv['counts'].get('minecraft:diamond', 0),
|
||||
'server': server_count(game, 'diamond'),
|
||||
'thrown': len(game.raw('entities', 8, 'minecraft:item')['entities'])}
|
||||
finally:
|
||||
for game in (host, guest):
|
||||
back_to_game(game)
|
||||
game('command', command='clear @s')
|
||||
host('command', command='kill @e[type=minecraft:item]')
|
||||
for name, s in seen.items():
|
||||
assert s == {'held': 7, 'carried': None, 'client': 7, 'server': 7, 'thrown': 0}, f'{name}: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['cursor']
|
||||
CHECK_FNS.update(cursor=check_cursor)
|
||||
|
||||
|
||||
def check_digging(host, guest, out):
|
||||
"""The server breaks a block a player digs only once that player's client is done
|
||||
(ServerPlayerGameMode: STOP_DESTROY_BLOCK, or the delayed destroy), never on its own count, so
|
||||
the player's client always breaks it first, with its particles and sound. The guest holds
|
||||
attack across a wall of six oak logs with a copper axe and hears each one break; then it digs
|
||||
stone with a wooden pickaxe and its game stalls for a second halfway: the stone is still there
|
||||
on both sides when it resumes, and the guest's own crack finishes it."""
|
||||
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
||||
x, y, z = gx + 3, gy, gz + 4
|
||||
heard = lambda sound: guest('status')['heard'].get(sound, 0)
|
||||
at = lambda b: (f'{b[0] + 0.02:.2f}', f'{b[1] + 0.5:.2f}', f'{b[2] + 0.5:.2f}')
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='item replace entity @s weapon.mainhand with minecraft:copper_axe')
|
||||
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
|
||||
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
|
||||
host('command', command=f'fill {x + 2} {y} {z - 1} {x + 2} {y + 1} {z + 1} minecraft:oak_log')
|
||||
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
||||
guest.wait(30)
|
||||
logs = [(x + 2, y + dy, z + dz) for dz in (-1, 0, 1) for dy in (0, 1)]
|
||||
before = heard('minecraft:block.wood.break')
|
||||
guest.raw('look-at', *at(logs[0]))
|
||||
guest.raw('mouse', 'left', 'down')
|
||||
left = list(logs)
|
||||
deadline = time.time() + 20
|
||||
while left and time.time() < deadline:
|
||||
if guest.raw('block', *left[0]).get('block') != 'minecraft:oak_log':
|
||||
left.pop(0)
|
||||
if left:
|
||||
guest.raw('look-at', *at(left[0]))
|
||||
guest.raw('mouse', 'left', 'up')
|
||||
guest.wait(10)
|
||||
seen = {'logs_left': left, 'log_breaks_heard': heard('minecraft:block.wood.break') - before}
|
||||
|
||||
# Stone with a wooden pickaxe: 23 ticks. The guest stalls for a second after about 8.
|
||||
stone = (x + 2, y, z)
|
||||
guest('command', command='item replace entity @s weapon.mainhand with minecraft:wooden_pickaxe')
|
||||
host('command', command=f'setblock {stone[0]} {stone[1]} {stone[2]} minecraft:stone')
|
||||
guest.wait(10)
|
||||
before = heard('minecraft:block.stone.break')
|
||||
guest.raw('look-at', *at(stone))
|
||||
guest.raw('mouse', 'left', 'down')
|
||||
time.sleep(0.4)
|
||||
os.kill(guest.pid, signal.SIGSTOP)
|
||||
try:
|
||||
time.sleep(1.0)
|
||||
finally:
|
||||
os.kill(guest.pid, signal.SIGCONT)
|
||||
resumed = {'guest': guest.raw('block', *stone).get('block'), 'server': host.raw('block', *stone).get('block'),
|
||||
'mining': guest('state').get('mining')}
|
||||
# Let go once it breaks: past it the crosshair reaches the floor.
|
||||
deadline = time.time() + 5
|
||||
while guest.raw('block', *stone).get('block') == 'minecraft:stone' and time.time() < deadline:
|
||||
pass
|
||||
guest.raw('mouse', 'left', 'up')
|
||||
guest.wait(10)
|
||||
seen.update(resumed=resumed, stone_after=host.raw('block', *stone).get('block'),
|
||||
stone_breaks_heard=heard('minecraft:block.stone.break') - before)
|
||||
guest.shot(out, 'digging')
|
||||
assert not left and seen['log_breaks_heard'] == 6, f'the server broke logs before the guest did: {seen}'
|
||||
assert resumed['guest'] == resumed['server'] == 'minecraft:stone', f'the server broke the stone while the guest stalled: {seen}'
|
||||
assert seen['stone_after'] == 'minecraft:air' and seen['stone_breaks_heard'] == 1, f'the guest did not break the stone: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['digging']
|
||||
CHECK_FNS.update(digging=check_digging)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in new issue
Block a user