mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
592da75ee6 | ||
|
|
be2846e9cd | ||
|
|
e27e98e080 | ||
|
|
9895fb4a62 | ||
|
|
02437a837e | ||
|
|
d5ececb85e | ||
|
|
28e0dcd89b | ||
|
|
40e67a39f4 | ||
|
|
096a08874e | ||
|
|
bb150337cd | ||
|
|
29a15797fa | ||
|
|
bc7d87387f | ||
|
|
37dc8edfe9 | ||
|
|
35d09c3dd4 | ||
|
|
bed3e8a6c8 | ||
|
|
b39570a54e | ||
|
|
42cdef0c89 | ||
|
|
015e0d752f | ||
|
|
02e5a79d79 | ||
|
|
4e67f64f61 | ||
|
|
4850c41eec | ||
|
|
102007164f | ||
|
|
d900488d12 | ||
|
|
3497737ecc | ||
|
|
ced6323bb2 | ||
|
|
698e7fd662 | ||
|
|
7808916266 | ||
|
|
53beacfd13 | ||
|
|
ab9ceb6a6d | ||
|
|
46d466f7c3 | ||
|
|
ab640c7646 | ||
|
|
b1d3c9b92b | ||
|
|
2a7ee57bab | ||
|
|
bc33470d5c | ||
|
|
8bb8239603 | ||
|
|
702d7cf2cc | ||
|
|
44d9bddebd | ||
|
|
0e4c964d0a | ||
|
|
2228a7456e | ||
|
|
6c50b7d980 | ||
|
|
1fa4a780f0 | ||
|
|
3c514d5c17 | ||
|
|
b6cd09442e | ||
|
|
e3b1ae512f | ||
|
|
5b4dd8b72e | ||
|
|
fd0aeb5331 | ||
|
|
7211d029ab | ||
|
|
dbb03a42d3 | ||
|
|
99b6b8f3e8 | ||
|
|
fc409d28f4 | ||
|
|
fa727a627a | ||
|
|
511eb49629 | ||
|
|
e2b2b2ccd0 | ||
|
|
2092c5a2e8 | ||
|
|
e36a480063 | ||
|
|
dae67a88cc | ||
|
|
1758b147b8 |
No files matched your search
@@ -653,18 +653,57 @@ pub fn modify_fov_based_on_death_or_fluid(
|
||||
fov
|
||||
}
|
||||
|
||||
/// `ClientAvatarState`: the walked distance and view bob of a player.
|
||||
/// `ClientAvatarState`: the walked distance, view bob and cloak of a player.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct AvatarBob {
|
||||
pub walk_dist: f32,
|
||||
pub walk_dist_o: f32,
|
||||
pub bob: f32,
|
||||
pub bob_o: f32,
|
||||
/// `xCloak`, `yCloak`, `zCloak`: a point trailing the player, which the cape leans from.
|
||||
pub cloak: [f64; 3],
|
||||
pub cloak_o: [f64; 3],
|
||||
}
|
||||
impl AvatarBob {
|
||||
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`).
|
||||
pub fn tick(&mut self) {
|
||||
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`)
|
||||
/// with the player's position then: `moveCloak` eases the cloak a quarter
|
||||
/// of the way there, or puts it there from 10 blocks away.
|
||||
pub fn tick(&mut self, position: [f64; 3]) {
|
||||
self.walk_dist_o = self.walk_dist;
|
||||
self.cloak_o = self.cloak;
|
||||
for i in 0..3 {
|
||||
let d = position[i] - self.cloak[i];
|
||||
if d > 10.0 || d < -10.0 {
|
||||
self.cloak[i] = position[i];
|
||||
self.cloak_o[i] = position[i];
|
||||
} else {
|
||||
self.cloak[i] += d * 0.25;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AvatarRenderer.extractCapeState`: the cape's `capeFlap`, `capeLean`
|
||||
/// and `capeLean2` (degrees) for the player at `position` (interpolated)
|
||||
/// with its body turned `body_yaw` and its `fallFlyingScale`.
|
||||
pub fn cape(
|
||||
&self,
|
||||
partial: f32,
|
||||
position: [f64; 3],
|
||||
body_yaw: f32,
|
||||
fall_flying_scale: f32,
|
||||
) -> [f32; 3] {
|
||||
let d: [f64; 3] = std::array::from_fn(|i| {
|
||||
lerp_d(f64::from(partial), self.cloak_o[i], self.cloak[i]) - position[i]
|
||||
});
|
||||
let rad = f64::from(body_yaw * DEG_TO_RAD);
|
||||
let (sin, cos) = (f64::from(trig::sin(rad)), f64::from(-trig::cos(rad)));
|
||||
let mut flap = (d[1] as f32 * 10.0).clamp(-6.0, 32.0);
|
||||
let lean = ((d[0] * sin + d[2] * cos) as f32 * 100.0 * (1.0 - fall_flying_scale))
|
||||
.clamp(0.0, 150.0);
|
||||
let lean2 = ((d[0] * cos - d[2] * sin) as f32 * 100.0).clamp(-20.0, 20.0);
|
||||
let bob = self.interpolated_bob(partial);
|
||||
let walk = lerp(partial, self.walk_dist_o, self.walk_dist);
|
||||
flap += trig::sin(f64::from(walk * 6.0)) * 32.0 * bob;
|
||||
[flap, lean, lean2]
|
||||
}
|
||||
/// `LocalPlayer.move`: `addWalkedDistance(Mth.length(dx, dz) * 0.6F)`
|
||||
/// with the float horizontal displacement.
|
||||
@@ -795,6 +834,23 @@ pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// `moveCloak` trails a quarter of the way, jumps from 10 blocks away; the
|
||||
/// cape leans back as the player walks forward (south, body yaw 0).
|
||||
#[test]
|
||||
fn the_cloak_trails_the_player() {
|
||||
let mut a = super::AvatarBob::default();
|
||||
a.tick([0.0, 64.0, 0.0]);
|
||||
assert_eq!((a.cloak, a.cloak_o), ([0.0, 64.0, 0.0], [0.0, 64.0, 0.0]));
|
||||
a.tick([0.0, 64.0, 1.0]);
|
||||
assert_eq!(a.cloak, [0.0, 64.0, 0.25]);
|
||||
let [flap, lean, lean2] = a.cape(1.0, [0.0, 64.0, 1.0], 0.0, 0.0);
|
||||
assert_eq!((flap, lean, lean2), (0.0, 75.0, 0.0));
|
||||
// Falling lifts the flap, at most 32 degrees.
|
||||
assert_eq!(a.cape(1.0, [0.0, 60.0, 0.25], 0.0, 0.0)[0], 32.0);
|
||||
// Gliding takes the lean away.
|
||||
assert_eq!(a.cape(1.0, [0.0, 64.0, 1.0], 0.0, 1.0)[1], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_far_covers_the_cloud_range() {
|
||||
// renderDistance 2 with the default Cloud Distance (128 chunks).
|
||||
|
||||
@@ -42,6 +42,45 @@ pub fn recipe_used_key(id: &str) -> String {
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
/// `getDefaultName` of the `BaseContainerBlockEntity` that `block` holds: the
|
||||
/// translation key of its menu's title when it has no custom name. Copper
|
||||
/// chests hold a `ChestBlockEntity`, shulker boxes of every colour a
|
||||
/// `ShulkerBoxBlockEntity`.
|
||||
pub fn default_name_key(block: &str) -> Option<&'static str> {
|
||||
Some(match block {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" => "container.chest",
|
||||
n if n.ends_with("copper_chest") => "container.chest",
|
||||
"minecraft:barrel" => "container.barrel",
|
||||
n if n.ends_with("shulker_box") => "container.shulkerBox",
|
||||
"minecraft:dispenser" => "container.dispenser",
|
||||
"minecraft:dropper" => "container.dropper",
|
||||
"minecraft:hopper" => "container.hopper",
|
||||
"minecraft:furnace" => "container.furnace",
|
||||
"minecraft:smoker" => "container.smoker",
|
||||
"minecraft:blast_furnace" => "container.blast_furnace",
|
||||
"minecraft:brewing_stand" => "container.brewing",
|
||||
"minecraft:crafter" => "container.crafter",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `BaseContainerBlockEntity.getDisplayName` (`getName`), the title of the
|
||||
/// `ClientboundOpenScreenPacket` that opens it: the block entity's custom
|
||||
/// name (`CustomName`, a literal), else its default name.
|
||||
pub fn display_name(custom: Option<&str>, default_key: &str) -> horizon_world::nbt::Tag {
|
||||
match custom {
|
||||
Some(name) => horizon_protocol::text::literal(name),
|
||||
None => horizon_protocol::text::translatable(default_key, &[]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChestBlock.MENU_PROVIDER_COMBINER`'s `getDisplayName` for a double chest:
|
||||
/// the first half's custom name (the right half, `DoubleBlockCombiner`'s
|
||||
/// `FIRST`), else the second's, else `container.chestDouble`.
|
||||
pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_world::nbt::Tag {
|
||||
display_name(first.or(second), "container.chestDouble")
|
||||
}
|
||||
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
@@ -378,6 +417,58 @@ impl BlockEntity {
|
||||
}
|
||||
pub use crate::providers::Providers;
|
||||
|
||||
/// A `ChestBlock`, which `ChestBlock.combine` can join into a double chest:
|
||||
/// chests, trapped chests and copper chests in every weathering state, waxed
|
||||
/// or not (`CopperChestBlock` extends `ChestBlock`). Ender chests are
|
||||
/// `EnderChestBlock`s and always single.
|
||||
pub fn is_chest_block(name: &str) -> bool {
|
||||
matches!(name, "minecraft:chest" | "minecraft:trapped_chest") || name.ends_with("copper_chest")
|
||||
}
|
||||
|
||||
/// A chest block's name with its `type` and `facing` properties.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ChestHalf<'a> {
|
||||
pub block: &'a str,
|
||||
pub kind: &'a str,
|
||||
pub facing: &'a str,
|
||||
}
|
||||
|
||||
impl ChestHalf<'_> {
|
||||
/// `ChestBlock.getConnectedDirection` as an (x, z) step to the other
|
||||
/// half: clockwise from the facing for a left half, counter-clockwise for
|
||||
/// a right one. None for a single chest.
|
||||
pub fn connected_step(&self) -> Option<(i32, i32)> {
|
||||
let step = |facing: &str| match facing {
|
||||
"north" => Some((0, -1)),
|
||||
"south" => Some((0, 1)),
|
||||
"west" => Some((-1, 0)),
|
||||
"east" => Some((1, 0)),
|
||||
_ => None,
|
||||
};
|
||||
let (x, z) = step(self.facing)?;
|
||||
// Turning clockwise seen from above takes (x, z) to (-z, x).
|
||||
match self.kind {
|
||||
"left" => Some((-z, x)),
|
||||
"right" => Some((z, -x)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `DoubleBlockCombiner.combineWithNeigbour` as `ChestBlock.combine` calls
|
||||
/// it: `other`, the block in this half's connected direction, is its other
|
||||
/// half when it is the same block, a half of the other side and faces the
|
||||
/// same way. Copper chests in different weathering states never join
|
||||
/// (`CopperChestBlock` keeps both halves in one state instead).
|
||||
pub fn pairs_with(&self, other: &ChestHalf) -> bool {
|
||||
is_chest_block(self.block)
|
||||
&& self.kind != "single"
|
||||
&& other.block == self.block
|
||||
&& other.kind != "single"
|
||||
&& other.kind != self.kind
|
||||
&& other.facing == self.facing
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
|
||||
/// more with the chance of what is left over (a level random draw only when there is some).
|
||||
pub fn create_experience(
|
||||
@@ -395,6 +486,24 @@ pub fn create_experience(
|
||||
xp
|
||||
}
|
||||
|
||||
/// The `MenuType` the block entity of a one-block storage container opens
|
||||
/// (`createMenu`): `ChestMenu.threeRows` for chests, trapped and copper chests
|
||||
/// and barrels, `ShulkerBoxMenu` for shulker boxes of any colour (its
|
||||
/// `ShulkerBoxSlot`s refuse shulker boxes), `DispenserMenu` for dispensers
|
||||
/// and droppers, `HopperMenu` for hoppers. None for other blocks.
|
||||
pub fn block_menu_type(block: &str) -> Option<&'static str> {
|
||||
Some(match block {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => {
|
||||
"minecraft:generic_9x3"
|
||||
}
|
||||
n if n.ends_with("copper_chest") => "minecraft:generic_9x3",
|
||||
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
|
||||
"minecraft:dispenser" | "minecraft:dropper" => "minecraft:generic_3x3",
|
||||
"minecraft:hopper" => "minecraft:hopper",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -349,6 +349,14 @@ struct BorderUniform {
|
||||
color: [f32; 4],
|
||||
offset: [f32; 4],
|
||||
}
|
||||
/// A frame to save as a PNG: F2's screenshot, an agent's `game_screenshot`,
|
||||
/// or the icon the Select World list shows.
|
||||
pub struct ScreenshotRequest {
|
||||
pub path: PathBuf,
|
||||
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
|
||||
/// and average it down to this size. `None` saves the frame as drawn.
|
||||
pub size: Option<[u32; 2]>,
|
||||
}
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
@@ -533,7 +541,7 @@ pub struct Renderer {
|
||||
block_entity_revision: u64,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// Resolution scale and FXAA (Horizon settings).
|
||||
/// Resolution scale, FXAA and TAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
hdr: Option<hdr::Hdr>,
|
||||
@@ -570,8 +578,8 @@ pub struct Renderer {
|
||||
pub section_draw_calls: usize,
|
||||
pub uploaded_bytes: u64,
|
||||
started: Instant,
|
||||
/// Write the next presented frame to this PNG path (control socket).
|
||||
pub screenshot: Option<PathBuf>,
|
||||
/// The next frame to write to a PNG path (control socket, F2, world icon).
|
||||
pub screenshot: Option<ScreenshotRequest>,
|
||||
/// Outcome of the last screenshot: frame size, or the error.
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// Brightness option (lightmap gamma), 0..1.
|
||||
@@ -3231,9 +3239,12 @@ impl Renderer {
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool) {
|
||||
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
self.world_post.antialias = antialias;
|
||||
self.world_post.antialias = antialias && !taa;
|
||||
self.world_post.taa = taa;
|
||||
self.world_post.upscale = if taa { upscale.clamp(1.0, 4.0) } else { 1.0 };
|
||||
}
|
||||
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
|
||||
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) pair.
|
||||
@@ -3615,6 +3626,16 @@ impl Renderer {
|
||||
frame.boss_darken,
|
||||
);
|
||||
let view_proj = self.view_projection(frame);
|
||||
// TAA runs where the world post pass does, not under a shader pack's own.
|
||||
let taa = !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
|
||||
let jittered = self.world_post.jitter(
|
||||
[self.config.width, self.config.height],
|
||||
taa,
|
||||
view_proj,
|
||||
frame.camera,
|
||||
) * view_proj;
|
||||
if frame.title {
|
||||
self.panorama.prepare(
|
||||
&self.queue,
|
||||
@@ -3666,7 +3687,7 @@ impl Renderer {
|
||||
)
|
||||
};
|
||||
let globals = Globals {
|
||||
view_proj: view_proj.to_cols_array_2d(),
|
||||
view_proj: jittered.to_cols_array_2d(),
|
||||
fog_color,
|
||||
fog,
|
||||
sky_color: [
|
||||
@@ -4298,7 +4319,7 @@ impl Renderer {
|
||||
self.stars.prepare(
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
view_proj * rotation,
|
||||
jittered * rotation,
|
||||
brightness,
|
||||
);
|
||||
}
|
||||
@@ -4471,6 +4492,8 @@ impl Renderer {
|
||||
.flatten();
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
let keep_depth = pack_reads_world || (taa && self.world_post.taa);
|
||||
// The pack's program reads the vertices this frame's draws bind.
|
||||
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
|
||||
// The shadow camera's lists, drawn by the pack's shadow programs.
|
||||
@@ -4622,8 +4645,7 @@ impl Renderer {
|
||||
view: world_depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
// Phase 2 of occlusion culling draws over it; a shader pack reads it.
|
||||
store: if cull.is_some() || pack_reads_world {
|
||||
store: if cull.is_some() || keep_depth {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
@@ -4698,7 +4720,7 @@ impl Renderer {
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_reads_world,
|
||||
keep_depth,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
@@ -4728,7 +4750,7 @@ impl Renderer {
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_reads_world,
|
||||
keep_depth,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
@@ -5041,7 +5063,8 @@ impl Renderer {
|
||||
native.timing.as_mut(),
|
||||
);
|
||||
} else if scaled.is_some() {
|
||||
self.world_post.finish(&mut encoder, &view_target);
|
||||
self.world_post
|
||||
.finish(&self.queue, &mut encoder, &view_target);
|
||||
}
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(&mut encoder);
|
||||
@@ -5086,6 +5109,13 @@ impl Renderer {
|
||||
&self.entity_group,
|
||||
&self.indices,
|
||||
);
|
||||
// The world icon grabs here: after the level and the hand, before
|
||||
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
|
||||
// runs right after `renderLevel`).
|
||||
let readback = self
|
||||
.screenshot
|
||||
.take_if(|request| request.size.is_some())
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
|
||||
if !frame.screen.is_empty() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
@@ -5189,18 +5219,10 @@ impl Renderer {
|
||||
(&view_target, self.config.format.remove_srgb_suffix()),
|
||||
);
|
||||
}
|
||||
let readback = self.screenshot.take().and_then(|path| {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
Some((path, self.copy_frame(&mut encoder, &target_texture)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
None
|
||||
}
|
||||
let readback = readback.or_else(|| {
|
||||
self.screenshot
|
||||
.take()
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture))
|
||||
});
|
||||
self.uploads.finish(&encoder);
|
||||
if has_section_copies {
|
||||
@@ -5227,9 +5249,9 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((path, buffer)) = readback {
|
||||
if let Some((request, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
@@ -5283,6 +5305,26 @@ impl Renderer {
|
||||
fn padded_row(&self) -> u32 {
|
||||
(self.config.width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
}
|
||||
/// Take a screenshot request: queue the frame's copy, or report why the
|
||||
/// frame cannot be read back.
|
||||
fn take_readback(
|
||||
&mut self,
|
||||
request: ScreenshotRequest,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
Some((request, self.copy_frame(encoder, texture)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
None
|
||||
}
|
||||
}
|
||||
fn copy_frame(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
@@ -5312,7 +5354,11 @@ impl Renderer {
|
||||
);
|
||||
buffer
|
||||
}
|
||||
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return Err("this surface does not support frame readback".into());
|
||||
}
|
||||
@@ -5350,8 +5396,18 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, self.config.width, self.config.height, size),
|
||||
),
|
||||
None => (self.config.width, self.config.height, pixels),
|
||||
};
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
@@ -5361,6 +5417,42 @@ impl Renderer {
|
||||
Ok([width, height])
|
||||
}
|
||||
}
|
||||
/// Centre-crop the frame to a square and average it down to `size`.
|
||||
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
|
||||
let (side, cx, cy) = if width > height {
|
||||
(height, (width - height) / 2, 0)
|
||||
} else {
|
||||
(width, 0, (height - width) / 2)
|
||||
};
|
||||
let [tw, th] = size;
|
||||
let mut out = vec![0u8; (tw * th * 4) as usize];
|
||||
for ty in 0..th {
|
||||
let y0 = cy + ty * side / th;
|
||||
let y1 = cy + (ty + 1) * side / th;
|
||||
for tx in 0..tw {
|
||||
let x0 = cx + tx * side / tw;
|
||||
let x1 = cx + (tx + 1) * side / tw;
|
||||
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let p = ((y * width + x) * 4) as usize;
|
||||
r += u32::from(pixels[p]);
|
||||
g += u32::from(pixels[p + 1]);
|
||||
b += u32::from(pixels[p + 2]);
|
||||
}
|
||||
}
|
||||
let count = (x1 - x0) * (y1 - y0);
|
||||
let o = ((ty * tw + tx) * 4) as usize;
|
||||
out[o..o + 4].copy_from_slice(&[
|
||||
(r / count) as u8,
|
||||
(g / count) as u8,
|
||||
(b / count) as u8,
|
||||
255,
|
||||
]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
//! Resolution scale and FXAA (Horizon settings): the world renders into its
|
||||
//! Resolution scale, FXAA and TAA (Horizon settings): the world renders into its
|
||||
//! own target at the scaled size, then one full-screen pass copies or
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. With neither on,
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. With none on,
|
||||
//! the world renders straight to the screen.
|
||||
//!
|
||||
//! TAA jitters the world's projection by a sub-pixel offset each frame and blends
|
||||
//! the frame into a history reprojected through the depth buffer: its motion
|
||||
//! vectors are the camera's, so what moves on its own leans on the neighbourhood
|
||||
//! clamp alone.
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4, Vec2};
|
||||
|
||||
/// The TAA history format: 8 bits would band as frames accumulate.
|
||||
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
struct WorldTarget {
|
||||
size: [u32; 2],
|
||||
color: wgpu::TextureView,
|
||||
depth: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
taa: Option<History>,
|
||||
}
|
||||
|
||||
/// TAA's two history textures at the output size (the world's, times the upscale), written
|
||||
/// in turn: `resolve[i]` writes `history[i]` from the world and `history[1 - i]`; `show[i]`
|
||||
/// copies `history[i]` to the screen.
|
||||
struct History {
|
||||
size: [u32; 2],
|
||||
history: [wgpu::TextureView; 2],
|
||||
resolve: [wgpu::BindGroup; 2],
|
||||
show: [wgpu::BindGroup; 2],
|
||||
}
|
||||
|
||||
/// `world_post.wgsl`'s `Taa`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct TaaUniform {
|
||||
/// This frame's unjittered clip space to the previous frame's.
|
||||
reproject: [[f32; 4]; 4],
|
||||
/// xy: this frame's jitter in clip space; z: 1 when the history is unusable.
|
||||
jitter: [f32; 4],
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
@@ -19,7 +50,21 @@ pub struct Post {
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
pub antialias: bool,
|
||||
pub taa: bool,
|
||||
/// Output pixels per rendered pixel while TAA is on (1: native): the world renders
|
||||
/// this much smaller and TAA rebuilds it at the resolution scale's size.
|
||||
pub upscale: f32,
|
||||
/// TAA runs this frame (`jitter`): not on the title screen or under a shader pack.
|
||||
taa_on: bool,
|
||||
target: Option<WorldTarget>,
|
||||
taa_layout: wgpu::BindGroupLayout,
|
||||
taa_pipeline: wgpu::RenderPipeline,
|
||||
taa_uniform: wgpu::Buffer,
|
||||
/// Frames jittered so far (the Halton index), and which history the next resolve writes.
|
||||
taa_frame: u32,
|
||||
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
|
||||
taa_previous: Option<(DMat4, DVec3)>,
|
||||
taa_this_frame: TaaUniform,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
@@ -49,15 +94,55 @@ impl Post {
|
||||
},
|
||||
],
|
||||
});
|
||||
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon TAA bindings"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
texture(2, float),
|
||||
texture(3, wgpu::TextureSampleType::Depth),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = |entry: &str| {
|
||||
let taa_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = |entry: &str, layout: &wgpu::PipelineLayout, format| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
layout: Some(&pipeline_layout),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_post"),
|
||||
@@ -88,41 +173,102 @@ impl Post {
|
||||
..Default::default()
|
||||
});
|
||||
Self {
|
||||
copy: pipeline("fs_copy"),
|
||||
fxaa: pipeline("fs_fxaa"),
|
||||
copy: pipeline("fs_copy", &pipeline_layout, format),
|
||||
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
|
||||
taa_pipeline: pipeline("fs_taa", &taa_pipeline_layout, HISTORY_FORMAT),
|
||||
taa_uniform: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
size: std::mem::size_of::<TaaUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
layout,
|
||||
taa_layout,
|
||||
sampler,
|
||||
format,
|
||||
scale: 1.0,
|
||||
antialias: false,
|
||||
taa: false,
|
||||
upscale: 1.0,
|
||||
taa_on: false,
|
||||
target: None,
|
||||
taa_frame: 0,
|
||||
taa_previous: None,
|
||||
taa_this_frame: bytemuck::Zeroable::zeroed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The world's rendered size and the size it is shown at (TAA's history).
|
||||
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
|
||||
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
let upscale = if self.taa_on { self.upscale } else { 1.0 };
|
||||
(
|
||||
output.map(|v| ((v as f32 / upscale).round() as u32).max(1)),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
/// TAA's sub-pixel jitter this frame, as a clip-space matrix to multiply into the
|
||||
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
|
||||
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
|
||||
pub fn jitter(&mut self, window: [u32; 2], on: bool, view_proj: Mat4, camera: DVec3) -> Mat4 {
|
||||
self.taa_on = on && self.taa;
|
||||
if !self.taa_on {
|
||||
self.taa_previous = None;
|
||||
return Mat4::IDENTITY;
|
||||
}
|
||||
self.taa_frame = self.taa_frame.wrapping_add(1);
|
||||
let (size, _) = self.sizes(window);
|
||||
// Halton (2, 3) over 8 frames per output pixel (FSR's count), in rendered
|
||||
// pixels around the centre.
|
||||
let phases = (8.0 * self.upscale * self.upscale).ceil() as u32;
|
||||
let i = self.taa_frame % phases + 1;
|
||||
let offset = Vec2::new(halton(i, 2), halton(i, 3)) - 0.5;
|
||||
let jitter = offset * 2.0 / Vec2::new(size[0] as f32, size[1] as f32);
|
||||
let current = view_proj.as_dmat4();
|
||||
let (reproject, reset) = match self.taa_previous {
|
||||
Some((previous, previous_camera)) => (
|
||||
reprojection(previous, previous_camera, current, camera),
|
||||
0.0,
|
||||
),
|
||||
None => (DMat4::IDENTITY, 1.0),
|
||||
};
|
||||
self.taa_previous = Some((current, camera));
|
||||
self.taa_this_frame = TaaUniform {
|
||||
reproject: reproject.as_mat4().to_cols_array_2d(),
|
||||
jitter: [jitter.x, jitter.y, reset, 0.0],
|
||||
};
|
||||
Mat4::from_translation(jitter.extend(0.0))
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen.
|
||||
/// `always`: a target even at scale 1 without FXAA (a shader pack reads it).
|
||||
/// `always`: a target even at scale 1 without anti-aliasing (a shader pack reads it).
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
window: [u32; 2],
|
||||
always: bool,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
if self.scale == 1.0 && !self.antialias && !always {
|
||||
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
if self.target.as_ref().is_none_or(|t| t.size != size) {
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let make_tex = |label, format, usage| {
|
||||
let (size, output) = self.sizes(window);
|
||||
let history_size = self.taa_on.then_some(output);
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
|
||||
{
|
||||
let make_tex = |label, size: [u32; 2], format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent,
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
@@ -131,25 +277,68 @@ impl Post {
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = make_tex(
|
||||
"Scaled world",
|
||||
self.format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
// TEXTURE_BINDING: occlusion culling reads it.
|
||||
let sampled =
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = make_tex("Scaled world", size, self.format, sampled)
|
||||
.create_view(&Default::default());
|
||||
// TEXTURE_BINDING: occlusion culling and TAA read it.
|
||||
let depth = make_tex(
|
||||
"Scaled world depth",
|
||||
size,
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
sampled,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
let taa = history_size.map(|output| {
|
||||
let history = [0, 1].map(|_| {
|
||||
make_tex("TAA history", output, HISTORY_FORMAT, sampled)
|
||||
.create_view(&Default::default())
|
||||
});
|
||||
let resolve = [0, 1].map(|i| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: &self.taa_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&history[1 - i]),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.taa_uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
let show = [0, 1].map(|i| self.bind(device, &history[i]));
|
||||
History {
|
||||
size: output,
|
||||
history,
|
||||
resolve,
|
||||
show,
|
||||
}
|
||||
});
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
self.target = Some(WorldTarget {
|
||||
size,
|
||||
color,
|
||||
depth,
|
||||
group,
|
||||
taa,
|
||||
});
|
||||
}
|
||||
self.target
|
||||
@@ -180,10 +369,43 @@ impl Post {
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw the scaled world onto `screen`.
|
||||
pub fn finish(&self, encoder: &mut wgpu::CommandEncoder, screen: &wgpu::TextureView) {
|
||||
/// Draw the scaled world onto `screen`, through TAA when this frame's `jitter` had it on.
|
||||
pub fn finish(
|
||||
&self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
screen: &wgpu::TextureView,
|
||||
) {
|
||||
let Some(target) = &self.target else { return };
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
let Some(taa) = target.taa.as_ref().filter(|_| self.taa_previous.is_some()) else {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let i = (self.taa_frame % 2) as usize;
|
||||
queue.write_buffer(
|
||||
&self.taa_uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&self.taa_this_frame),
|
||||
);
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &taa.history[i],
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.taa_pipeline);
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
self.finish_with(encoder, screen, &taa.show[i]);
|
||||
}
|
||||
|
||||
/// Draw what `group` binds (`bind`) onto `screen`.
|
||||
@@ -216,3 +438,51 @@ impl Post {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
/// This frame's clip space to the previous frame's, for camera-relative projections:
|
||||
/// a point `camera - previous_camera` further from the previous camera than from this one.
|
||||
fn reprojection(previous: DMat4, previous_camera: DVec3, current: DMat4, camera: DVec3) -> DMat4 {
|
||||
previous * DMat4::from_translation(camera - previous_camera) * current.inverse()
|
||||
}
|
||||
|
||||
/// The radical inverse of `i` in `base`: a low-discrepancy sequence in 0..1.
|
||||
fn halton(mut i: u32, base: u32) -> f32 {
|
||||
let (mut f, mut r) = (1.0, 0.0);
|
||||
while i > 0 {
|
||||
f /= base as f32;
|
||||
r += f * (i % base) as f32;
|
||||
i /= base;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reprojection_finds_where_a_block_was_last_frame() {
|
||||
let projection = DMat4::perspective_rh(1.2, 16.0 / 9.0, 1024.0, 0.05);
|
||||
let previous = projection * DMat4::from_rotation_y(0.3);
|
||||
let current = projection * DMat4::from_rotation_y(0.35);
|
||||
let (previous_camera, camera) = (DVec3::new(10.0, 64.0, 5.0), DVec3::new(10.5, 64.2, 4.0));
|
||||
let block = DVec3::new(14.0, 66.0, -8.0);
|
||||
let clip = |m: DMat4, eye: DVec3| m * (block - eye).extend(1.0);
|
||||
let now = clip(current, camera);
|
||||
let then = reprojection(previous, previous_camera, current, camera) * (now / now.w);
|
||||
let expected = clip(previous, previous_camera);
|
||||
assert!((then.truncate() / then.w - expected.truncate() / expected.w).length() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_stays_inside_the_pixel() {
|
||||
for i in 1..=8 {
|
||||
let (x, y) = (halton(i, 2), halton(i, 3));
|
||||
assert!((0.0..1.0).contains(&x) && (0.0..1.0).contains(&y));
|
||||
}
|
||||
assert_eq!(
|
||||
[halton(1, 2), halton(2, 2), halton(3, 2)],
|
||||
[0.5, 0.25, 0.75]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,61 @@ impl FarDetail {
|
||||
}
|
||||
}
|
||||
|
||||
/// TAA upscaling: the world renders this much smaller than the resolution scale's size,
|
||||
/// and TAA builds the full-size picture from the jittered frames (FSR's and DLSS's ratios).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Upscaling {
|
||||
#[default]
|
||||
Native,
|
||||
Quality,
|
||||
Balanced,
|
||||
Performance,
|
||||
UltraPerformance,
|
||||
}
|
||||
impl Upscaling {
|
||||
pub const ALL: [Upscaling; 5] = [
|
||||
Upscaling::Native,
|
||||
Upscaling::Quality,
|
||||
Upscaling::Balanced,
|
||||
Upscaling::Performance,
|
||||
Upscaling::UltraPerformance,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Upscaling::Native => "native",
|
||||
Upscaling::Quality => "quality",
|
||||
Upscaling::Balanced => "balanced",
|
||||
Upscaling::Performance => "performance",
|
||||
Upscaling::UltraPerformance => "ultraPerformance",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Upscaling::Native => "Native",
|
||||
Upscaling::Quality => "Quality",
|
||||
Upscaling::Balanced => "Balanced",
|
||||
Upscaling::Performance => "Performance",
|
||||
Upscaling::UltraPerformance => "Ultra Performance",
|
||||
}
|
||||
}
|
||||
/// Output pixels per rendered pixel, along each axis.
|
||||
pub fn ratio(self) -> f32 {
|
||||
match self {
|
||||
Upscaling::Native => 1.0,
|
||||
Upscaling::Quality => 1.5,
|
||||
Upscaling::Balanced => 1.7,
|
||||
Upscaling::Performance => 2.0,
|
||||
Upscaling::UltraPerformance => 3.0,
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|u| u.name() == s)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
static REDUCED_SUGGESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the settings suggest Reduced far detail: set when the renderer
|
||||
/// starts, on the GPUs `lod_capped` names (far terrain slows them most).
|
||||
@@ -262,6 +317,10 @@ pub struct Graphics {
|
||||
pub resolution_scale: u32,
|
||||
/// FXAA over the world.
|
||||
pub fxaa: bool,
|
||||
/// TAA over the world (FXAA is off while it is on).
|
||||
pub taa: bool,
|
||||
/// TAA upscaling; anything but `Native` comes with `taa` on.
|
||||
pub upscaling: Upscaling,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits.
|
||||
@@ -299,6 +358,8 @@ impl Default for Graphics {
|
||||
overlay: Overlay::Off,
|
||||
resolution_scale: 100,
|
||||
fxaa: false,
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
hdr: false,
|
||||
hdr_paper_white: 200,
|
||||
hdr_peak: 1000,
|
||||
@@ -361,6 +422,8 @@ impl Graphics {
|
||||
"workers" => g.workers = number(g.workers).min(64),
|
||||
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"taa" => g.taa = flag(g.taa),
|
||||
"upscaling" => g.upscaling = Upscaling::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => g.hdr_paper_white = number(g.hdr_paper_white).clamp(80, 480),
|
||||
"hdrPeak" => g.hdr_peak = number(g.hdr_peak).clamp(200, 2000),
|
||||
@@ -381,7 +444,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -406,6 +469,8 @@ impl Graphics {
|
||||
},
|
||||
self.resolution_scale,
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.taa),
|
||||
self.upscaling.name(),
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
@@ -506,6 +571,8 @@ mod tests {
|
||||
overlay: Overlay::Graph,
|
||||
resolution_scale: 75,
|
||||
fxaa: true,
|
||||
taa: true,
|
||||
upscaling: Upscaling::Balanced,
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset},
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
settings::{Opt, Settings},
|
||||
@@ -36,6 +36,7 @@ pub enum H {
|
||||
Overlay,
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
Hdr,
|
||||
HdrPaperWhite,
|
||||
HdrPeak,
|
||||
@@ -221,7 +222,12 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
h(
|
||||
H::Antialiasing,
|
||||
Medium,
|
||||
"FXAA smooths jagged edges of the world; slightly softer image.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.",
|
||||
),
|
||||
h(
|
||||
H::Upscaling,
|
||||
High,
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
),
|
||||
h(
|
||||
H::Hdr,
|
||||
@@ -303,9 +309,13 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
|
||||
/// Horizon's graphics screens supplement the vanilla options on Video Settings.
|
||||
fn extra_rows(level: Level) -> Vec<Row> {
|
||||
/// Upscaling is only there while TAA is, which does it.
|
||||
fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
|
||||
let mut rows = rows(level);
|
||||
rows.retain(|row| !matches!(row, Row::Setting(s) if matches!(s.item, Item::Vanilla(_))));
|
||||
if !settings.horizon.taa {
|
||||
rows.retain(|row| !matches!(row, Row::Setting(s) if s.item == Item::Horizon(H::Upscaling)));
|
||||
}
|
||||
// Drop headings whose settings were all on the parent screens.
|
||||
let mut has_setting = false;
|
||||
rows.reverse();
|
||||
@@ -383,7 +393,15 @@ impl H {
|
||||
n => labelled("Worker Threads", n),
|
||||
},
|
||||
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
|
||||
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
|
||||
H::Antialiasing => labelled(
|
||||
"Anti-aliasing",
|
||||
match (g.fxaa, g.taa) {
|
||||
(_, true) => "TAA",
|
||||
(true, false) => "FXAA",
|
||||
(false, false) => "Off",
|
||||
},
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
|
||||
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
|
||||
@@ -441,7 +459,22 @@ impl H {
|
||||
H::Weather => g.weather = !g.weather,
|
||||
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
|
||||
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
|
||||
H::Antialiasing => g.fxaa = !g.fxaa,
|
||||
// Off, FXAA, TAA.
|
||||
H::Antialiasing => {
|
||||
let i = if g.taa { 2 } else { usize::from(g.fxaa) };
|
||||
let i = (i + if back { 2 } else { 1 }) % 3;
|
||||
(g.fxaa, g.taa) = (i == 1, i == 2);
|
||||
if !g.taa {
|
||||
g.upscaling = Upscaling::Native;
|
||||
}
|
||||
}
|
||||
// Upscaling is TAA's: picking a ratio turns it on.
|
||||
H::Upscaling => {
|
||||
g.upscaling = step(&Upscaling::ALL, g.upscaling, back);
|
||||
if g.upscaling != Upscaling::Native {
|
||||
(g.fxaa, g.taa) = (false, true);
|
||||
}
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::GraphicsApi => g.api = step(&GraphicsApi::ALL, g.api, back),
|
||||
@@ -481,7 +514,6 @@ impl Item {
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
Item::Horizon(H::Weather) => g.weather,
|
||||
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
|
||||
Item::Horizon(H::Antialiasing) => g.fxaa,
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
|
||||
@@ -603,8 +635,8 @@ impl GraphicsUi {
|
||||
fn bounds(h: f32) -> (f32, f32) {
|
||||
(STYLE.band, h - STYLE.band)
|
||||
}
|
||||
fn content_height(level: Level) -> f32 {
|
||||
extra_rows(level).iter().fold(4.0, |height, row| {
|
||||
fn content_height(level: Level, s: &Settings) -> f32 {
|
||||
extra_rows(level, s).iter().fold(4.0, |height, row| {
|
||||
height
|
||||
+ match row {
|
||||
Row::Header(_) => STYLE.header_height,
|
||||
@@ -616,17 +648,18 @@ impl GraphicsUi {
|
||||
fn layout(
|
||||
&mut self,
|
||||
level: Level,
|
||||
s: &Settings,
|
||||
w: f32,
|
||||
h: f32,
|
||||
) -> (Vec<Widget>, Vec<(f32, &'static str)>, f32) {
|
||||
let content = Self::content_height(level);
|
||||
let content = Self::content_height(level, s);
|
||||
self.clamp_scroll(h, content);
|
||||
let (top, _) = Self::bounds(h);
|
||||
let x = STYLE.row_x(w);
|
||||
let mut y = top + 4.0 - self.scroll;
|
||||
let mut widgets = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
for row in extra_rows(level) {
|
||||
for row in extra_rows(level, s) {
|
||||
match row {
|
||||
Row::Header(name) => {
|
||||
headers.push((y, name));
|
||||
@@ -680,7 +713,7 @@ impl GraphicsUi {
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let (widgets, headers, content) = self.layout(level, w, h);
|
||||
let (widgets, headers, content) = self.layout(level, s, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
STYLE.background(batch, atlas, top, bottom, in_world);
|
||||
let shown = |y: f32, height: f32| y >= top && y + height <= bottom;
|
||||
@@ -690,7 +723,7 @@ impl GraphicsUi {
|
||||
STYLE.header(batch, atlas, &Text::literal(name), x, y);
|
||||
}
|
||||
}
|
||||
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level)
|
||||
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level, s)
|
||||
.into_iter()
|
||||
.filter_map(|r| match r {
|
||||
Row::Setting(s) => Some((s.cost, s.text)),
|
||||
@@ -791,7 +824,7 @@ impl GraphicsUi {
|
||||
drag: bool,
|
||||
right: bool,
|
||||
) -> Vec<Action> {
|
||||
let (widgets, _, _) = self.layout(level, w, h);
|
||||
let (widgets, _, _) = self.layout(level, s, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
if drag {
|
||||
if let Some(item) = self.dragging
|
||||
@@ -835,8 +868,8 @@ impl GraphicsUi {
|
||||
pub fn release(&mut self) -> bool {
|
||||
self.dragging.take().is_some()
|
||||
}
|
||||
pub fn scroll(&mut self, level: Level, h: f32, amount: f32) {
|
||||
let content = Self::content_height(level);
|
||||
pub fn scroll(&mut self, level: Level, s: &Settings, h: f32, amount: f32) {
|
||||
let content = Self::content_height(level, s);
|
||||
self.clamp_scroll(h, content);
|
||||
self.scroll -= amount * 18.0;
|
||||
self.clamp_scroll(h, content);
|
||||
@@ -852,7 +885,7 @@ mod tests {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 320.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
|
||||
let button = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Button(_)))
|
||||
@@ -917,45 +950,47 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn scrolling_stops_at_both_limits_before_layout() {
|
||||
let s = Settings::default();
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let mut ui = GraphicsUi::default();
|
||||
let h = 2.0 * STYLE.band + 10.0;
|
||||
let (top, bottom) = GraphicsUi::bounds(h);
|
||||
let max = GraphicsUi::content_height(level) - (bottom - top);
|
||||
let max = GraphicsUi::content_height(level, &s) - (bottom - top);
|
||||
assert!(max > 4.5);
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, h, 1000.0);
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, h, -1000.0);
|
||||
ui.scroll(level, &s, h, -1000.0);
|
||||
assert_eq!(ui.scroll, max);
|
||||
}
|
||||
ui.scroll(level, h, 0.25);
|
||||
ui.scroll(level, &s, h, 0.25);
|
||||
assert_eq!(ui.scroll, max - 4.5);
|
||||
ui.scroll(level, h, 1000.0);
|
||||
ui.scroll(level, h, -0.25);
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
ui.scroll(level, &s, h, -0.25);
|
||||
assert_eq!(ui.scroll, 4.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_clamps_before_positioning_graphics_rows() {
|
||||
let s = Settings::default();
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let mut ui = GraphicsUi::default();
|
||||
ui.scroll(level, 2.0 * STYLE.band + 10.0, -1000.0);
|
||||
ui.scroll(level, &s, 2.0 * STYLE.band + 10.0, -1000.0);
|
||||
assert!(ui.scroll > 0.0);
|
||||
let (widgets, headers, _) = ui.layout(level, 480.0, 4000.0);
|
||||
let (widgets, headers, _) = ui.layout(level, &s, 480.0, 4000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
if let Some((y, _)) = headers.first() {
|
||||
assert_eq!(*y, STYLE.band + 4.0);
|
||||
}
|
||||
let (expected, _, _) = GraphicsUi::default().layout(level, 480.0, 4000.0);
|
||||
let (expected, _, _) = GraphicsUi::default().layout(level, &s, 480.0, 4000.0);
|
||||
assert_eq!(
|
||||
widgets.iter().map(|w| w.y).collect::<Vec<_>>(),
|
||||
expected.iter().map(|w| w.y).collect::<Vec<_>>()
|
||||
);
|
||||
ui.scroll(level, 4000.0, -1000.0);
|
||||
ui.scroll(level, &s, 4000.0, -1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -963,7 +998,7 @@ mod tests {
|
||||
#[test]
|
||||
fn classic_submenus_only_add_horizon_settings_without_empty_groups() {
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let rows = extra_rows(level);
|
||||
let rows = extra_rows(level, &Settings::default());
|
||||
assert!(rows.iter().any(|row| matches!(row, Row::Setting(_))));
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
match row {
|
||||
@@ -1051,6 +1086,37 @@ mod tests {
|
||||
assert_eq!(s.horizon.upload_budget_kib, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn antialiasing_cycles_off_fxaa_taa() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::Antialiasing);
|
||||
for label in ["FXAA", "TAA", "Off"] {
|
||||
item.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(item.label(&s).plain(), format!("Anti-aliasing: {label}"));
|
||||
}
|
||||
item.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!((s.horizon.fxaa, s.horizon.taa), (false, true));
|
||||
|
||||
// Upscaling needs TAA: a ratio turns it on, leaving TAA goes back to Native.
|
||||
s.horizon.taa = false;
|
||||
let upscaling = Item::Horizon(H::Upscaling);
|
||||
upscaling.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(upscaling.label(&s).plain(), "Upscaling: Quality");
|
||||
assert!(s.horizon.taa);
|
||||
item.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(s.horizon.upscaling, Upscaling::Native);
|
||||
|
||||
// The Upscaling row is only on the screen while TAA is on.
|
||||
let shown = |s: &Settings| {
|
||||
extra_rows(Level::Advanced, s)
|
||||
.iter()
|
||||
.any(|row| matches!(row, Row::Setting(r) if r.item == upscaling))
|
||||
};
|
||||
assert!(!shown(&s));
|
||||
s.horizon.taa = true;
|
||||
assert!(shown(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
@@ -1067,7 +1133,7 @@ mod tests {
|
||||
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 4000.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
|
||||
let switch = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
|
||||
@@ -2692,7 +2692,7 @@ impl OptionsUi {
|
||||
return;
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
self.graphics.scroll(level, size.1, amount);
|
||||
self.graphics.scroll(level, s, size.1, amount);
|
||||
return;
|
||||
}
|
||||
match &mut self.pack_screen {
|
||||
|
||||
@@ -491,15 +491,69 @@ pub fn texture_hash(url: &str) -> Option<String> {
|
||||
/// `SkinManager.TextureCache.registerTexture`: the texture id of a skin,
|
||||
/// `skins/` and the SHA-1 of its hash's UTF-16 code units.
|
||||
pub fn skin_location(hash: &str) -> String {
|
||||
location("skins/", hash)
|
||||
}
|
||||
/// `registerTexture` in the cape cache: `capes/` and the same SHA-1.
|
||||
pub fn cape_location(hash: &str) -> String {
|
||||
location("capes/", hash)
|
||||
}
|
||||
fn location(prefix: &str, hash: &str) -> String {
|
||||
let units: Vec<u8> = hash.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let digest = sha1(&units);
|
||||
let mut out = String::from("skins/");
|
||||
let mut out = String::from(prefix);
|
||||
for b in digest {
|
||||
out.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `PlayerSkin.cape`: the cape texture of textures whose lookup gave a skin
|
||||
/// (every download ready), if they name one.
|
||||
pub fn cape(textures: &Textures) -> Option<String> {
|
||||
textures
|
||||
.cape
|
||||
.as_deref()
|
||||
.and_then(texture_hash)
|
||||
.map(|hash| cape_location(&hash))
|
||||
}
|
||||
|
||||
/// A cape in a 64x64 skin slot: the 64x32 picture (or the older 22x17,
|
||||
/// which has the same layout) in its top half. Vanilla draws a cape of any
|
||||
/// size; a larger one is scaled down to 64x32 here.
|
||||
pub fn process_cape(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
|
||||
return None;
|
||||
}
|
||||
let mut img = vec![0u8; 64 * 64 * 4];
|
||||
let (fit_w, fit_h) = if w <= 64 && h <= 32 { (w, h) } else { (64, 32) };
|
||||
for y in 0..fit_h {
|
||||
for x in 0..fit_w {
|
||||
let (sx, sy) = (x * w / fit_w, y * h / fit_h);
|
||||
let (s, d) = ((sy * w + sx) * 4, (y * 64 + x) * 4);
|
||||
img[d..d + 4].copy_from_slice(&rgba[s..s + 4]);
|
||||
}
|
||||
}
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// The cape the launcher gives an offline name (`HORIZON_OFFLINE_CAPE`, a
|
||||
/// PNG's path; launcher/src/skins.rs), read once as `process_cape` lays it out.
|
||||
pub fn offline_cape() -> Option<&'static [u8]> {
|
||||
static CAPE: std::sync::OnceLock<Option<Vec<u8>>> = std::sync::OnceLock::new();
|
||||
CAPE.get_or_init(|| {
|
||||
let path = std::env::var_os("HORIZON_OFFLINE_CAPE").filter(|p| !p.is_empty())?;
|
||||
let image = std::fs::read(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| crate::hud::decode_bytes(&bytes).map_err(|e| e.to_string()))
|
||||
.and_then(|(w, h, rgba)| process_cape(w, h, &rgba).ok_or("not an image".into()));
|
||||
image
|
||||
.inspect_err(|e| eprintln!("Horizon: no offline cape ({}): {e}", path.display()))
|
||||
.ok()
|
||||
})
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
/// Where a texture download stands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Download {
|
||||
@@ -650,9 +704,10 @@ impl Downloader {
|
||||
while let Ok((key, image)) = self.done.try_recv() {
|
||||
let state = match image {
|
||||
Some(rgba) => {
|
||||
if key.0 == Kind::Skin {
|
||||
let location = skin_location(&key.1);
|
||||
self.queue(location, rgba);
|
||||
match key.0 {
|
||||
Kind::Skin => self.queue(skin_location(&key.1), rgba),
|
||||
Kind::Cape => self.queue(cape_location(&key.1), rgba),
|
||||
Kind::Elytra => {}
|
||||
}
|
||||
Download::Ready
|
||||
}
|
||||
@@ -685,7 +740,8 @@ impl Downloader {
|
||||
.and_then(|b| crate::hud::decode_bytes(&b).ok())
|
||||
.and_then(|(w, h, rgba)| match kind {
|
||||
Kind::Skin => process_legacy_skin(w, h, &rgba),
|
||||
_ => Some(rgba),
|
||||
Kind::Cape => process_cape(w, h, &rgba),
|
||||
Kind::Elytra => Some(rgba),
|
||||
});
|
||||
let _ = send.send((key, image));
|
||||
});
|
||||
@@ -716,16 +772,21 @@ impl Downloader {
|
||||
if self.queued.contains(location) {
|
||||
return true;
|
||||
}
|
||||
let Some(digest) = location.strip_prefix("skins/") else {
|
||||
return false;
|
||||
};
|
||||
let (digest, process): (_, fn(u32, u32, &[u8]) -> Option<Vec<u8>>) =
|
||||
if let Some(digest) = location.strip_prefix("skins/") {
|
||||
(digest, process_legacy_skin)
|
||||
} else if let Some(digest) = location.strip_prefix("capes/") {
|
||||
(digest, process_cape)
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let image = self.images.get(location).cloned().or_else(|| {
|
||||
let file = self
|
||||
.root
|
||||
.join(if digest.len() > 2 { &digest[..2] } else { "xx" })
|
||||
.join(digest);
|
||||
let (w, h, rgba) = crate::hud::decode_bytes(&std::fs::read(&file).ok()?).ok()?;
|
||||
process_legacy_skin(w, h, &rgba)
|
||||
process(w, h, &rgba)
|
||||
});
|
||||
let Some(image) = image else {
|
||||
return false;
|
||||
@@ -961,6 +1022,31 @@ mod tests {
|
||||
assert_eq!(d.take_ready(), vec![("skins/def".to_owned(), vec![4])]);
|
||||
}
|
||||
|
||||
/// A cape fills the top half of a 64x64 slot; a larger one is scaled to 64x32.
|
||||
#[test]
|
||||
fn capes_fill_the_top_half_of_a_slot() {
|
||||
let at = |x: usize, y: usize| (y * 64 + x) * 4;
|
||||
let legacy = super::process_cape(22, 17, &[7; 22 * 17 * 4]).unwrap();
|
||||
assert_eq!(legacy.len(), 64 * 64 * 4);
|
||||
assert_eq!(legacy[at(21, 16)], 7);
|
||||
assert_eq!(legacy[at(22, 16)], 0);
|
||||
let mut hd = vec![0u8; 128 * 64 * 4];
|
||||
hd[(62 * 128 + 126) * 4] = 9;
|
||||
let hd = super::process_cape(128, 64, &hd).unwrap();
|
||||
assert_eq!(hd[at(63, 31)], 9);
|
||||
assert_eq!(hd[at(0, 32)], 0);
|
||||
assert!(super::process_cape(64, 32, &[0; 4]).is_none());
|
||||
let textures = super::Textures {
|
||||
cape: Some("http://textures.minecraft.net/texture/abc".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let cape = super::cape(&textures).unwrap();
|
||||
assert_eq!(
|
||||
cape["capes/".len()..],
|
||||
super::skin_location("abc")["skins/".len()..]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_components_supply_unsigned_head_skins() {
|
||||
let value = "eyJ0ZXh0dXJlcyI6IHsiU0tJTiI6IHsidXJsIjogImh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2FiYzEyMyJ9fX0=";
|
||||
|
||||
@@ -55,3 +55,79 @@ fn luma(c: vec3<f32>) -> f32 {
|
||||
}
|
||||
return vec4(b, centre.a);
|
||||
}
|
||||
|
||||
// TAA (`world_post.rs`): blend this frame into the history, reprojected from the
|
||||
// depth buffer through the camera's motion and clamped to this frame's 3x3
|
||||
// neighbourhood so that what moved or appeared does not ghost.
|
||||
struct Taa {
|
||||
reproject: mat4x4<f32>,
|
||||
// xy: this frame's jitter in clip space; z: 1 when the history is unusable.
|
||||
jitter: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(2) var history: texture_2d<f32>;
|
||||
@group(0) @binding(3) var depth: texture_depth_2d;
|
||||
@group(0) @binding(4) var<uniform> taa: Taa;
|
||||
|
||||
// Catmull-Rom over the history in 5 bilinear taps: sharper than one under motion.
|
||||
fn sample_history(uv: vec2<f32>) -> vec3<f32> {
|
||||
let size = vec2<f32>(textureDimensions(history));
|
||||
let pos = uv * size;
|
||||
let centre = floor(pos - 0.5) + 0.5;
|
||||
let f = pos - centre;
|
||||
let w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
||||
let w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
||||
let w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
||||
let w3 = f * f * (-0.5 + 0.5 * f);
|
||||
let w12 = w1 + w2;
|
||||
let t0 = (centre - 1.0) / size;
|
||||
let t3 = (centre + 2.0) / size;
|
||||
let t12 = (centre + w2 / w12) / size;
|
||||
let c = textureSampleLevel(history, world_sampler, vec2(t12.x, t0.y), 0.0).rgb * (w12.x * w0.y)
|
||||
+ textureSampleLevel(history, world_sampler, vec2(t0.x, t12.y), 0.0).rgb * (w0.x * w12.y)
|
||||
+ textureSampleLevel(history, world_sampler, t12, 0.0).rgb * (w12.x * w12.y)
|
||||
+ textureSampleLevel(history, world_sampler, vec2(t3.x, t12.y), 0.0).rgb * (w3.x * w12.y)
|
||||
+ textureSampleLevel(history, world_sampler, vec2(t12.x, t3.y), 0.0).rgb * (w12.x * w3.y);
|
||||
let weight = w12.x * w0.y + w0.x * w12.y + w12.x * w12.y + w3.x * w12.y + w12.x * w3.y;
|
||||
return max(c / weight, vec3(0.0));
|
||||
}
|
||||
|
||||
// It runs at the history's size, which upscaling makes larger than the world's.
|
||||
// The history is unjittered: each output pixel takes the world pixel whose
|
||||
// jittered sample lands nearest its centre, weighted by how near it lands.
|
||||
@fragment fn fs_taa(in: PostOut) -> @location(0) vec4<f32> {
|
||||
let size = vec2<f32>(textureDimensions(world));
|
||||
// The jitter moved the scene by `jitter` in clip space: where this pixel's
|
||||
// point was drawn, in world pixels.
|
||||
let drawn = (in.uv + taa.jitter.xy * vec2(0.5, -0.5)) * size;
|
||||
let last = vec2<i32>(size) - 1;
|
||||
let p = clamp(vec2<i32>(floor(drawn)), vec2(0), last);
|
||||
let centre = textureLoad(world, p, 0);
|
||||
var lo = centre.rgb;
|
||||
var hi = centre.rgb;
|
||||
// Reversed depth: the nearest of the 3x3 is the largest, and its motion keeps
|
||||
// the edges of what is in front from trailing.
|
||||
var near = 0.0;
|
||||
for (var y = -1; y <= 1; y++) {
|
||||
for (var x = -1; x <= 1; x++) {
|
||||
let q = clamp(p + vec2(x, y), vec2(0), last);
|
||||
let c = textureLoad(world, q, 0).rgb;
|
||||
lo = min(lo, c);
|
||||
hi = max(hi, c);
|
||||
near = max(near, textureLoad(depth, q, 0));
|
||||
}
|
||||
}
|
||||
let ndc = vec2(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
|
||||
let previous = taa.reproject * vec4(ndc, near, 1.0);
|
||||
let uv = vec2(0.5, -0.5) * previous.xy / previous.w + 0.5;
|
||||
if taa.jitter.z > 0.0 || any(uv != clamp(uv, vec2(0.0), vec2(1.0))) {
|
||||
return vec4(centre.rgb, centre.a);
|
||||
}
|
||||
let past = clamp(sample_history(uv), lo, hi);
|
||||
// How far the sample landed from this pixel's centre, in output pixels: a
|
||||
// Gaussian of it scales the new frame's share, so that frames whose sample
|
||||
// fell far away (most of them when upscaling) barely move the pixel.
|
||||
let off = (drawn - (vec2<f32>(p) + 0.5)) * vec2<f32>(textureDimensions(history)) / size;
|
||||
let share = 0.1 * exp(-2.0 * dot(off, off));
|
||||
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
|
||||
return vec4(mix(past, centre.rgb, share), centre.a);
|
||||
}
|
||||
@@ -302,7 +302,7 @@ fn avatar(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
for op in c["ops"].as_array().unwrap() {
|
||||
let name = op[0].as_str().unwrap();
|
||||
match name {
|
||||
"tick" => state.tick(),
|
||||
"tick" => state.tick([0.0; 3]),
|
||||
"move" => state.add_walked(fl(&op[1]), fl(&op[2])),
|
||||
"bob" => state.update_bob(b(&op[1]) && !b(&op[2]) && !b(&op[3]), vec3(&op[4])),
|
||||
"reset" => state.reset_bob(),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Double chests for tools/test_chest_pair_parity.py: reads one JSON case per
|
||||
//! line (see VanillaChestPairOracle), a chest's block state at the origin and
|
||||
//! another block state at the offset "at", and prints what
|
||||
//! `horizon_client::containers::ChestHalf` pairs them into, as the game's
|
||||
//! menus and hoppers do: "double" with the right half's x,z then the left
|
||||
//! half's, or "single".
|
||||
//!
|
||||
//! usage: chest_pair_fixture INPUT OUTPUT
|
||||
use horizon_client::containers::{ChestHalf, is_chest_block};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
|
||||
/// `minecraft:chest[facing=north,type=left]` as its name and properties.
|
||||
fn parse(state: &str) -> Option<(&str, BTreeMap<&str, &str>)> {
|
||||
let (name, props) = match state.split_once('[') {
|
||||
Some((name, rest)) => (name, rest.strip_suffix(']')?),
|
||||
None => (state, ""),
|
||||
};
|
||||
let props = props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| p.split_once('='))
|
||||
.collect::<Option<_>>()?;
|
||||
Some((name, props))
|
||||
}
|
||||
|
||||
fn half<'a>(name: &'a str, props: &BTreeMap<&str, &'a str>) -> Option<ChestHalf<'a>> {
|
||||
Some(ChestHalf {
|
||||
block: name,
|
||||
kind: props.get("type")?,
|
||||
facing: props.get("facing")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn case(line: &str) -> Option<String> {
|
||||
let case: Value = serde_json::from_str(line).ok()?;
|
||||
let (name, props) = parse(case["state"].as_str()?)?;
|
||||
let (other_name, other_props) = parse(case["other"].as_str()?)?;
|
||||
let at = (
|
||||
i32::try_from(case["at"][0].as_i64()?).ok()?,
|
||||
i32::try_from(case["at"][1].as_i64()?).ok()?,
|
||||
);
|
||||
if !is_chest_block(name) {
|
||||
return None;
|
||||
}
|
||||
let own = half(name, &props)?;
|
||||
let paired = own.connected_step() == Some(at)
|
||||
&& half(other_name, &other_props).is_some_and(|other| own.pairs_with(&other));
|
||||
Some(if !paired {
|
||||
"single".to_owned()
|
||||
} else if own.kind == "right" {
|
||||
format!("double 0,0 {},{}", at.0, at.1)
|
||||
} else {
|
||||
format!("double {},{} 0,0", at.0, at.1)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: chest_pair_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Container menu titles for tools/test_container_title_parity.py: reads one
|
||||
//! JSON case per line (see VanillaContainerTitleOracle) and prints the title
|
||||
//! the server opens the container's menu with, as canonical NBT. A single
|
||||
//! block entity is placed as the game places it (`block_entity_item::place`,
|
||||
//! whose `CustomName` the game keeps in `World::block_names`), then named by
|
||||
//! `containers::display_name`; a double chest's halves by
|
||||
//! `containers::double_chest_name`.
|
||||
//!
|
||||
//! usage: container_title_fixture INPUT OUTPUT
|
||||
use horizon_client::block_entity_item::place;
|
||||
use horizon_client::block_entity_nbt::canonical;
|
||||
use horizon_client::containers::{default_name_key, display_name, double_chest_name};
|
||||
use horizon_client::save::text;
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
/// ParityData.canon of a text component: sorted keys.
|
||||
fn canon(tag: &Tag, out: &mut String) {
|
||||
match tag {
|
||||
Tag::Compound(map) => {
|
||||
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
|
||||
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out.push('{');
|
||||
for (i, (k, v)) in keys.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&k);
|
||||
out.push(':');
|
||||
canon(v, out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Tag::List(items) => {
|
||||
out.push('[');
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
canon(item, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn snbt(value: &Value) -> Option<Tag> {
|
||||
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
|
||||
}
|
||||
|
||||
/// The custom name the game keeps for a block entity saved as `value`.
|
||||
fn custom_name(value: &Value) -> Option<Option<String>> {
|
||||
let saved = canonical(&snbt(value)?)?;
|
||||
Some(text(saved.get("CustomName")))
|
||||
}
|
||||
|
||||
fn case(line: &str) -> Option<String> {
|
||||
let case: Value = serde_json::from_str(line).ok()?;
|
||||
let title = if let Some(first) = case.get("first") {
|
||||
let first = custom_name(first)?;
|
||||
let second = custom_name(&case["second"])?;
|
||||
double_chest_name(first.as_deref(), second.as_deref())
|
||||
} else {
|
||||
let saved = canonical(&snbt(&case["nbt"])?)?;
|
||||
// BlockItem.updateBlockEntityComponents, as Game::apply_placed_item runs it.
|
||||
let placed = match case.get("patch") {
|
||||
Some(p) => match snbt(p)? {
|
||||
Tag::Compound(patch) => place(&saved, &patch, true),
|
||||
_ => return None,
|
||||
},
|
||||
None => saved,
|
||||
};
|
||||
let key = default_name_key(case["block"].as_str()?)?;
|
||||
display_name(text(placed.get("CustomName")).as_deref(), key)
|
||||
};
|
||||
let mut out = String::new();
|
||||
canon(&title, &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: container_title_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -24,9 +24,11 @@ mod breaking_fixture;
|
||||
mod camera_probe;
|
||||
mod cauldron_fixture;
|
||||
mod chat_screen_fixture;
|
||||
mod chest_pair_fixture;
|
||||
mod cloud_fixture;
|
||||
mod command_parse_fixture;
|
||||
mod container_screen_probe;
|
||||
mod container_title_fixture;
|
||||
mod creative_mining_probe;
|
||||
mod creative_search_fixture;
|
||||
mod cushion_placement_probe;
|
||||
@@ -86,6 +88,7 @@ mod sound_event_fixture;
|
||||
mod spawn_rules_fixture;
|
||||
mod sprint_probe;
|
||||
mod squid_client_probe;
|
||||
mod storage_menu_fixture;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
@@ -115,9 +118,11 @@ probes! {
|
||||
cushion_placement_probe,
|
||||
cauldron_fixture,
|
||||
chat_screen_fixture,
|
||||
chest_pair_fixture,
|
||||
cloud_fixture,
|
||||
command_parse_fixture,
|
||||
container_screen_probe,
|
||||
container_title_fixture,
|
||||
creative_search_fixture,
|
||||
emitter_particles_probe,
|
||||
enchanting_fixture,
|
||||
@@ -174,6 +179,7 @@ probes! {
|
||||
sound_event_fixture,
|
||||
spawn_rules_fixture,
|
||||
sprint_probe,
|
||||
storage_menu_fixture,
|
||||
subtitle_fixture,
|
||||
tab_list_fixture,
|
||||
team_rules_fixture,
|
||||
|
||||
@@ -67,8 +67,9 @@ struct Scenario {
|
||||
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
|
||||
hurts: Vec<(i32, usize)>,
|
||||
/// The player feeds an animal at the start of a tick (Animal.mobInteract):
|
||||
/// (tick, mob index); the scenario only feeds each animal its food.
|
||||
feeds: Vec<(i32, usize)>,
|
||||
/// (tick, mob index, None); the scenario only feeds each animal its food.
|
||||
/// `interact` uses another item on a horse: (tick, mob index, Some(item)).
|
||||
feeds: Vec<(i32, usize, Option<String>)>,
|
||||
hand: Option<String>,
|
||||
mobs: Vec<MobSpec>,
|
||||
}
|
||||
@@ -108,7 +109,14 @@ fn parse(text: &str) -> Vec<Scenario> {
|
||||
}
|
||||
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
|
||||
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"feed" => s.feeds.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"feed" => s
|
||||
.feeds
|
||||
.push((f[1].parse().unwrap(), f[2].parse().unwrap(), None)),
|
||||
"interact" => s.feeds.push((
|
||||
f[1].parse().unwrap(),
|
||||
f[2].parse().unwrap(),
|
||||
Some(f[3].to_owned()),
|
||||
)),
|
||||
"player" => {
|
||||
s.player = Some([
|
||||
f[1].parse().unwrap(),
|
||||
@@ -1209,10 +1217,22 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
events.append(&mut mobs[i].outbox);
|
||||
}
|
||||
for &(tick, i) in &s.feeds {
|
||||
for (tick, i, item) in &s.feeds {
|
||||
let (tick, i) = (*tick, *i);
|
||||
if tick != t || i >= mobs.len() || mobs[i].removed {
|
||||
continue;
|
||||
}
|
||||
// Horse.mobInteract's held-item branch (makeMad).
|
||||
if let Some(item) = item {
|
||||
horizon_client::mobs::equine::held_item(
|
||||
&mut mobs[i],
|
||||
item,
|
||||
false,
|
||||
item == "minecraft:golden_dandelion",
|
||||
&mut events,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(Some((name, volume, pitch))) =
|
||||
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Storage block menus for tools/test_storage_menu_parity.py: reads one
|
||||
//! block id per line and prints the menu type the server opens it with
|
||||
//! (`horizon_client::containers::block_menu_type`, as `lan_open_container`
|
||||
//! sends it) and whether that menu's slots take a shulker box: the server
|
||||
//! refuses them in a `minecraft:shulker_box` menu (`ShulkerBoxSlot.mayPlace`,
|
||||
//! `lan::containers`).
|
||||
//!
|
||||
//! usage: storage_menu_fixture INPUT OUTPUT
|
||||
use horizon_client::containers::block_menu_type;
|
||||
use std::fs;
|
||||
|
||||
fn case(block: &str) -> Option<String> {
|
||||
let menu = block_menu_type(block.trim())?;
|
||||
let accepts = if menu == "minecraft:shulker_box" {
|
||||
"refuses"
|
||||
} else {
|
||||
"accepts"
|
||||
};
|
||||
Some(format!("{menu} {accepts} shulker_box"))
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: storage_menu_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -68,6 +68,36 @@ impl AttackStrength {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.attack`'s extra knockback from the weapon's knockback enchantment
|
||||
/// (`getKnockback`, halved) and a sprinting full-strength hit (0.5), and
|
||||
/// whether the hit is that sprinting knockback attack.
|
||||
pub fn extra_knockback(enchanted: f32, strength: f32, sprinting: bool) -> (f32, bool) {
|
||||
let knockback_attack = strength > 0.9 && sprinting;
|
||||
let bonus = if knockback_attack { 0.5 } else { 0.0 };
|
||||
(enchanted / 2.0 + bonus, knockback_attack)
|
||||
}
|
||||
|
||||
/// What the attacking client's own `Player.attack` does to itself on a hit
|
||||
/// at another player, whose `RemotePlayer.hurtClient` lets the hit through:
|
||||
/// whether `causeExtraKnockback` cuts its horizontal speed to 0.6 and stops
|
||||
/// its sprint. `attack_damage` is the ATTACK_DAMAGE value before clamping
|
||||
/// (the hand and weapon plus strength and weakness, or a riptide spin's),
|
||||
/// `enchant_damage` the weapon's enchantment damage, `strength`
|
||||
/// `getAttackStrengthScale(0.5)` and `knockback` from [`extra_knockback`].
|
||||
pub fn attacker_slows_after_hit(
|
||||
attack_damage: f32,
|
||||
enchant_damage: f32,
|
||||
strength: f32,
|
||||
knockback: f32,
|
||||
) -> bool {
|
||||
// The attribute's range is [0, 2048]: weakness can't make it negative.
|
||||
let base = attack_damage.clamp(0.0, 2048.0) * (0.2 + strength * strength * 0.8);
|
||||
let enchant = enchant_damage * strength;
|
||||
// Player.attack stops before the hurt and the knockback when neither
|
||||
// part of the damage is left.
|
||||
(base > 0.0 || enchant > 0.0) && knockback > 0.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -107,4 +137,52 @@ mod tests {
|
||||
s.reset_attack();
|
||||
assert_eq!((s.ticker, s.swap_ticker), (0, 1));
|
||||
}
|
||||
/// The attacker's side of a hit on another player, as the client sees it.
|
||||
fn slows(damage: f32, enchant: f32, strength: f32, sprinting: bool, kb_enchant: f32) -> bool {
|
||||
let (knockback, _) = extra_knockback(kb_enchant, strength, sprinting);
|
||||
attacker_slows_after_hit(damage, enchant, strength, knockback)
|
||||
}
|
||||
#[test]
|
||||
fn a_charged_sprint_hit_slows_the_attacker() {
|
||||
// A diamond sword (1 + 6), full strength, sprinting.
|
||||
assert!(slows(7.0, 0.0, 1.0, true, 0.0));
|
||||
assert_eq!(extra_knockback(0.0, 1.0, true), (0.5, true));
|
||||
}
|
||||
#[test]
|
||||
fn an_uncharged_sprint_hit_does_not() {
|
||||
assert!(!slows(7.0, 0.0, 0.5, true, 0.0));
|
||||
assert!(
|
||||
!slows(7.0, 0.0, 0.9, true, 0.0),
|
||||
"the bonus needs more than 0.9"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_hit_without_sprinting_or_knockback_does_not() {
|
||||
assert!(!slows(7.0, 0.0, 1.0, false, 0.0));
|
||||
}
|
||||
#[test]
|
||||
fn a_knockback_weapon_slows_even_uncharged() {
|
||||
// Knockback I: getKnockback 1, halved; no sprint needed.
|
||||
assert!(slows(7.0, 0.0, 0.2, false, 1.0));
|
||||
}
|
||||
#[test]
|
||||
fn a_hit_that_does_no_damage_stops_first() {
|
||||
// An empty hand (1) under Weakness I (-4): the attribute clamps to 0,
|
||||
// so Player.attack ends before causeExtraKnockback.
|
||||
assert!(!slows(1.0 - 4.0, 0.0, 1.0, true, 0.0));
|
||||
assert!(!slows(0.0, 0.0, 1.0, true, 1.0));
|
||||
// Enchantment damage alone still lands the hit.
|
||||
assert!(slows(0.0, 1.25, 1.0, true, 0.0));
|
||||
}
|
||||
#[test]
|
||||
fn a_server_attack_speed_charges_at_once() {
|
||||
// 1.8-style servers raise ATTACK_SPEED: with 1024 the delay is under
|
||||
// a tick, so getAttackStrengthScale(0.5) is full right after a hit.
|
||||
let mut s = AttackStrength::default();
|
||||
s.reset_attack();
|
||||
assert_eq!(s.scale(0.5, 1024.0), 1.0);
|
||||
assert!(slows(7.0, 0.0, s.scale(0.5, 1024.0), true, 0.0));
|
||||
// A sword's own 1.6 leaves it uncharged.
|
||||
assert!(!slows(7.0, 0.0, s.scale(0.5, 1.6), true, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -1692,6 +1692,33 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// `FireworkRocketEntity.readAdditionalSaveData`: Life, LifeTime (0 when
|
||||
/// absent: a bare `/summon` explodes at once), FireworksItem and
|
||||
/// ShotAtAngle, with `Projectile`'s Owner and LeftOwner.
|
||||
pub struct RocketNbt {
|
||||
pub position: Vec3,
|
||||
pub motion: Vec3,
|
||||
pub life: i32,
|
||||
pub lifetime: i32,
|
||||
pub item: Option<crate::items::ItemStack>,
|
||||
pub angled: bool,
|
||||
pub owner: Option<Who>,
|
||||
pub left_owner: bool,
|
||||
}
|
||||
pub fn firework_rocket_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<RocketNbt> {
|
||||
let owner = uuid_from_tag(tag.get("Owner"));
|
||||
Some(RocketNbt {
|
||||
position: position(tag)?,
|
||||
motion: motion(tag),
|
||||
life: number(tag.get("Life")).unwrap_or(0) as i32,
|
||||
lifetime: number(tag.get("LifeTime")).unwrap_or(0) as i32,
|
||||
item: tag.get("FireworksItem").and_then(item_from_nbt),
|
||||
angled: flag(tag, "ShotAtAngle"),
|
||||
owner: (player.is_some() && owner == player).then_some(Who::Player),
|
||||
left_owner: flag(tag, "LeftOwner"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn falling_block_to_nbt(
|
||||
|
||||
@@ -1399,6 +1399,14 @@ fn select_creative_tab(
|
||||
clicks.skip_release();
|
||||
}
|
||||
|
||||
/// The icon the Select World list shows for the world in play
|
||||
/// (`GameRenderer.tryTakeScreenshotIfNeeded`): its path in the save, and when
|
||||
/// the last frame grab was asked for.
|
||||
struct WorldIcon {
|
||||
path: PathBuf,
|
||||
last_attempt: Option<Instant>,
|
||||
}
|
||||
|
||||
struct Game<'a> {
|
||||
/// Shared with an integrated server; `collision` and `motion` borrow from it.
|
||||
res: Arc<Resources>,
|
||||
@@ -1887,6 +1895,9 @@ struct Game<'a> {
|
||||
/// The world was saved for the application ending (`save_for_exit`).
|
||||
saved_for_exit: bool,
|
||||
pending_save: Option<saving::ClosingSave>,
|
||||
/// The world list's icon in progress (`GameRenderer.tryTakeScreenshotIfNeeded`):
|
||||
/// `None` without a local world, or once the save has its icon.png.
|
||||
world_icon: Option<WorldIcon>,
|
||||
/// The block a guest is acting on (game/lan_actions.rs).
|
||||
forced_hit: Option<Hit>,
|
||||
/// The part of a right click a guest's packet asks for (game/lan_actions.rs).
|
||||
@@ -4215,8 +4226,15 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Minecraft.startUseItem`: the main hand, then the off hand.
|
||||
fn use_item_as_player(&mut self) -> Result<Option<Hand>> {
|
||||
// Spectators only look (MultiPlayerGameMode.useItemOn/useItem pass).
|
||||
// Spectators only look (MultiPlayerGameMode.useItemOn/useItem pass),
|
||||
// but on a server a spectator's use on a block opens the block's
|
||||
// menu (ServerPlayerGameMode.useItemOn).
|
||||
if self.spectator() {
|
||||
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
|
||||
&& let Some(hit) = self.pick()
|
||||
{
|
||||
self.spectator_open_menu(hit.pos);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
// A carrot or warped fungus on a stick boosts the ridden pig or strider.
|
||||
@@ -4767,7 +4785,9 @@ impl Game<'_> {
|
||||
self.tick_crossbow(slot, ticks, &stack);
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:trident" {
|
||||
// TridentItem and EnderEyeItem: used until released (an eye's
|
||||
// getUseDuration 0 never counts down to finishUsingItem).
|
||||
if matches!(&*stack.item, "minecraft:trident" | "minecraft:ender_eye") {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
@@ -6633,9 +6653,15 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
part(batch, tex, 0.0, 0.0, 176.0, 166.0, ox, oy);
|
||||
let title = self.container_title(self.translate(key, fallback));
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(
|
||||
// A server's furnace menu has the title it was given.
|
||||
let title = self.remote_menu_title().unwrap_or_else(|| {
|
||||
horizon_client::text::Text::literal(
|
||||
self.container_title(self.translate(key, fallback)),
|
||||
)
|
||||
.resolve_legacy()
|
||||
});
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&title,
|
||||
ox + (176.0 - tw) / 2.0,
|
||||
@@ -7329,6 +7355,13 @@ impl Game<'_> {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
} else if self.creative_inventory_open() {
|
||||
// CreativeModeInventoryScreen.slotClicked's THROW on the Survival Inventory tab: the
|
||||
// stack goes to the server (handleCreativeModeItemDrop), the slot follows with the
|
||||
// others (SetCreativeModeSlot).
|
||||
for stack in dropped {
|
||||
self.remote_creative_throw(stack);
|
||||
}
|
||||
}
|
||||
if kind == Kind::Loom {
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
@@ -7528,7 +7561,8 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(moved) = self.last_quick_moved.clone() {
|
||||
if self.remote.is_some() {
|
||||
// The creative screen clicks its own menu, on a server too.
|
||||
if self.remote.is_some() && self.screen != Screen::Creative {
|
||||
self.remote_quick_move_all(slot, &moved);
|
||||
} else {
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
@@ -9228,6 +9262,12 @@ impl Game<'_> {
|
||||
book.to_be_displayed.retain(|r| index.contains(r));
|
||||
self.dim = dim;
|
||||
self.world_root = save_root.clone();
|
||||
// LevelStorageAccess.getIconFile: this save's icon.png is what the
|
||||
// Select World list shows.
|
||||
self.world_icon = Some(WorldIcon {
|
||||
path: save_root.join("icon.png"),
|
||||
last_attempt: None,
|
||||
});
|
||||
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
|
||||
self.block_ticks = Ticks::new(self.level.seed as i32);
|
||||
self.tick_chunks.clear();
|
||||
@@ -9554,10 +9594,12 @@ impl Game<'_> {
|
||||
self.music.stop_playing(None, &mut self.audio);
|
||||
self.audio.stop_all();
|
||||
if self.remote.is_some() {
|
||||
self.world_icon = None;
|
||||
return self.leave_server(None);
|
||||
}
|
||||
// The save screen has finished every write before releasing storage.
|
||||
self.session_lock = None;
|
||||
self.world_icon = None;
|
||||
horizon_client::crash::clear_detail("World");
|
||||
self.debug_charts.reset();
|
||||
self.boss_overlay.reset();
|
||||
@@ -11673,6 +11715,55 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `GameRenderer.tryTakeScreenshotIfNeeded`: while a local world without a
|
||||
/// saved icon is on the screen, grab the rendered frame once a second until
|
||||
/// the save has one. The Select World list shows it as the world's preview.
|
||||
fn tick_world_icon(&mut self) {
|
||||
let Some(icon) = self.world_icon.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if icon
|
||||
.last_attempt
|
||||
.is_some_and(|at| at.elapsed() < Duration::from_secs(1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if icon.path.is_file() {
|
||||
// tryTakeScreenshotIfNeeded's hasWorldScreenshot: it has one, stop asking.
|
||||
self.world_icon = None;
|
||||
return;
|
||||
}
|
||||
// takeAutoScreenshot only saves with every loaded section rendered, or
|
||||
// the icon would picture the holes the streamer has not filled yet.
|
||||
if self.streaming.pending() != 0
|
||||
|| !self.pending_uploads.is_empty()
|
||||
|| self.remote_backlog() != 0
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(renderer) = self.renderer.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// countRenderedSections() > 10: a client that joined its server
|
||||
// thread has nothing pending before the first chunks arrive, and the
|
||||
// icon would be only sky.
|
||||
if renderer.drawn_sections <= 10 {
|
||||
return;
|
||||
}
|
||||
if renderer.screenshot.is_some() {
|
||||
// F2's or an agent's screenshot owns the frame this time; ours
|
||||
// comes around again with the next attempt.
|
||||
return;
|
||||
}
|
||||
let path = icon.path.clone();
|
||||
if let Some(icon) = self.world_icon.as_mut() {
|
||||
icon.last_attempt = Some(Instant::now());
|
||||
}
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path,
|
||||
size: Some([64, 64]),
|
||||
});
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
|
||||
// server starts. Its chunk map and progress are the server's, which
|
||||
@@ -13640,6 +13731,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.apply_mouse_look();
|
||||
self.pad_look(elapsed.as_secs_f32());
|
||||
self.advance_flight();
|
||||
self.tick_world_icon();
|
||||
let ticked = Instant::now();
|
||||
let rendered = self.render()?;
|
||||
self.apply_cursor_icon();
|
||||
@@ -13693,6 +13785,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
);
|
||||
}
|
||||
self.clock.end_frame(self.pause);
|
||||
let mut consumed = false;
|
||||
let finished = self
|
||||
.renderer
|
||||
.as_mut()
|
||||
@@ -13701,8 +13794,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
&& self.ui.screenshot_message.is_some()
|
||||
{
|
||||
self.finish_screenshot(result);
|
||||
consumed = true;
|
||||
}
|
||||
if let Some(result) = finished
|
||||
if let Some(result) = &finished
|
||||
&& let Some(reply) = self.pending_screenshot.take()
|
||||
{
|
||||
let _ = reply.send(
|
||||
@@ -13714,6 +13808,13 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
consumed = true;
|
||||
}
|
||||
// A result nobody asked for was the world icon's
|
||||
// (`tick_world_icon`): a failure tries again a second
|
||||
// later, as `tryTakeScreenshotIfNeeded` does.
|
||||
if !consumed && let Some(Err(error)) = finished {
|
||||
log::warn!("Horizon: couldn't save the world icon: {error}");
|
||||
}
|
||||
if rendered {
|
||||
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
|
||||
@@ -14222,6 +14323,7 @@ impl<'a> Game<'a> {
|
||||
text_selection: Default::default(),
|
||||
saved_for_exit: false,
|
||||
pending_save: None,
|
||||
world_icon: None,
|
||||
forced_hit: None,
|
||||
guest_use: None,
|
||||
lan_events: Vec::new(),
|
||||
|
||||
@@ -658,15 +658,30 @@ impl Game<'_> {
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some((wood, chest)) = any_boat_wood(&stack.item) else {
|
||||
let Some(boat) = self.boat_to_place(stack) else {
|
||||
return false;
|
||||
};
|
||||
if !self.boat_models.has(wood, chest) {
|
||||
return false;
|
||||
self.next_entity += 1;
|
||||
let key = (
|
||||
boat.position.x.floor() as i32 >> 4,
|
||||
boat.position.z.floor() as i32 >> 4,
|
||||
);
|
||||
self.world.modified.insert(key);
|
||||
self.boats.push(boat);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
let Some((eye, dir)) = self.aim() else {
|
||||
return false;
|
||||
};
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// The boat `BoatItem.use` would place for `stack` (its SUCCESS), or
|
||||
/// None when it aims at nothing (PASS) or the spot is taken (FAIL).
|
||||
pub(super) fn boat_to_place(&self, stack: &ItemStack) -> Option<Boat> {
|
||||
let (wood, chest) = any_boat_wood(&stack.item)?;
|
||||
if !self.boat_models.has(wood, chest) {
|
||||
return None;
|
||||
}
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach = self.reach();
|
||||
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
|
||||
// block along the ray.
|
||||
@@ -706,7 +721,7 @@ impl Game<'_> {
|
||||
}
|
||||
(Some(b), None) => b.0,
|
||||
(None, Some(w)) => w.0,
|
||||
(None, None) => return false,
|
||||
(None, None) => return None,
|
||||
};
|
||||
let mut boat = Boat::new(
|
||||
self.next_entity + 1,
|
||||
@@ -730,17 +745,9 @@ impl Game<'_> {
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
let key = (hit[0].floor() as i32 >> 4, hit[2].floor() as i32 >> 4);
|
||||
self.world.modified.insert(key);
|
||||
self.boats.push(boat);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
Some(boat)
|
||||
}
|
||||
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
|
||||
let (eye, dir) = self.aim()?;
|
||||
|
||||
@@ -1149,7 +1149,10 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let path = dir.join(name);
|
||||
renderer.screenshot = Some(path.clone());
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
});
|
||||
self.ui.screenshot_message = Some(path);
|
||||
}
|
||||
/// After a frame: report a finished F2 screenshot in chat.
|
||||
|
||||
@@ -57,6 +57,10 @@ pub(super) struct CombatTracker {
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
/// The exploding mob named itself (`MobEvent::ExplosionSource`), so the
|
||||
/// blast leaves it out (`getEntities(source, box)`). A source guessed
|
||||
/// from the mobs near the centre is still hurt: it may be a bystander.
|
||||
pub(super) explosion_source_named: bool,
|
||||
/// The blast now going off comes from a primed TNT, creeper, wither
|
||||
/// skull, wither or TNT minecart, the sources that empty a hive they
|
||||
/// destroy (`BeehiveBlock.getDrops`).
|
||||
|
||||
@@ -1929,7 +1929,10 @@ impl Game<'_> {
|
||||
fn spawned_mark(&self) -> (u32, usize) {
|
||||
(
|
||||
self.next_entity,
|
||||
self.tnts.len() + self.falling.len() + self.creatures.projectiles.len(),
|
||||
self.tnts.len()
|
||||
+ self.falling.len()
|
||||
+ self.creatures.projectiles.len()
|
||||
+ self.rockets.len(),
|
||||
)
|
||||
}
|
||||
/// BlockStateArgument -> registry state.
|
||||
|
||||
@@ -587,7 +587,10 @@ impl Game<'_> {
|
||||
arg(1)?;
|
||||
// The rest of the line: a path may hold spaces.
|
||||
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
|
||||
renderer.screenshot = Some(path.trim().into());
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.trim().into(),
|
||||
size: None,
|
||||
});
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -1301,6 +1301,7 @@ impl Game<'_> {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
self.combat.explosion_source = source_mob;
|
||||
self.combat.explosion_source_named = noted;
|
||||
// Only a creeper or a wither empties the hives it blows up.
|
||||
// The creeper is already gone from the list, so the event
|
||||
// carries its kind.
|
||||
@@ -1310,6 +1311,7 @@ impl Game<'_> {
|
||||
self.explode(at, radius)
|
||||
};
|
||||
self.combat.explosion_source = None;
|
||||
self.combat.explosion_source_named = false;
|
||||
result?
|
||||
}
|
||||
MobEvent::AttackMob {
|
||||
@@ -4552,15 +4554,10 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
if &*mob.kind.id == "minecraft:player" {
|
||||
// PlayerRenderer's WingsLayer and SpinAttackEffectLayer.
|
||||
self.player_elytra_geometry(
|
||||
mob,
|
||||
mob.extra.chest.as_deref(),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
// PlayerRenderer's CapeLayer, WingsLayer and SpinAttackEffectLayer.
|
||||
let chest = mob.extra.chest.as_deref();
|
||||
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
|
||||
@@ -203,7 +203,7 @@ impl Game<'_> {
|
||||
power,
|
||||
1.0,
|
||||
);
|
||||
self.launch_angled_rocket(at, shot.velocity, ammo);
|
||||
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
|
||||
}
|
||||
if !rocket {
|
||||
let kind = ProjectileKind::Arrow {
|
||||
|
||||
@@ -987,7 +987,7 @@ impl Game<'_> {
|
||||
"minecraft:firework_rocket" => {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
self.launch_angled_rocket(at, shot.velocity, &one);
|
||||
self.launch_angled_rocket(at, shot.velocity, &one, None);
|
||||
}
|
||||
_ => {
|
||||
let kind = match item.as_str() {
|
||||
|
||||
@@ -119,7 +119,7 @@ impl Game<'_> {
|
||||
/// enchantment, halved) plus 0.5 for a sprinting full-strength hit, and
|
||||
/// whether the hit is that knockback attack.
|
||||
pub(super) fn extra_knockback(&mut self, target_kind: &str, scale: f32) -> (f32, bool) {
|
||||
let knockback_attack = scale > 0.9 && self.player.as_ref().is_some_and(|p| p.sprinting);
|
||||
let sprinting = self.player.as_ref().is_some_and(|p| p.sprinting);
|
||||
let enchanted = match self.held().cloned() {
|
||||
Some(held) => {
|
||||
let ctx = EffectContext {
|
||||
@@ -140,8 +140,7 @@ impl Game<'_> {
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
let bonus = if knockback_attack { 0.5 } else { 0.0 };
|
||||
(enchanted / 2.0 + bonus, knockback_attack)
|
||||
horizon_client::attack_strength::extra_knockback(enchanted, scale, sprinting)
|
||||
}
|
||||
/// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)` from Mth's
|
||||
/// sine table: the target flies where the attacker looks, not away from
|
||||
@@ -152,9 +151,11 @@ impl Game<'_> {
|
||||
(f64::from(trig::sin(yaw)), -f64::from(trig::cos(yaw)))
|
||||
}
|
||||
/// The attacker's side of `causeExtraKnockback` as the attacking client
|
||||
/// sees it: the server's player stops sprinting, and its flag reaches the
|
||||
/// client. (The server's 0.6 speed cut never does: the client's own
|
||||
/// `Player.attack` simulates no hurt, so it skips the knockback.)
|
||||
/// sees it after a hit on a mob: the server's player stops sprinting, and
|
||||
/// its flag reaches the client. (The server's 0.6 speed cut never does:
|
||||
/// the client's own `Player.attack` simulates no hurt on a mob, so it
|
||||
/// skips the knockback. A hit on another player does simulate one:
|
||||
/// `remote_player_attack`.)
|
||||
pub(super) fn stop_sprint_after_extra_knockback(&mut self) {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
let _ = p.set_sprinting(false);
|
||||
|
||||
@@ -329,6 +329,13 @@ impl Game<'_> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// EnderEyeItem.use's `getPlayerPOVHitResult`: whether the block aimed
|
||||
/// at is an End portal frame (the use passes there).
|
||||
pub(super) fn aims_at_end_portal_frame(&self) -> bool {
|
||||
self.pick()
|
||||
.is_some_and(|hit| &*self.block_name(hit.pos) == "minecraft:end_portal_frame")
|
||||
}
|
||||
|
||||
/// EnderEyeItem.useOn: fill an empty End portal frame, then light the
|
||||
/// portal when the ring of twelve filled frames is complete.
|
||||
pub(super) fn use_ender_eye(&mut self, stack: &ItemStack, use_on_block: bool) -> Result<bool> {
|
||||
@@ -345,7 +352,12 @@ impl Game<'_> {
|
||||
if matches!(self.guest_use, Some(super::GuestUse::OnBlock(_))) {
|
||||
return Ok(false);
|
||||
}
|
||||
// EnderEyeItem.use: throw the eye towards the nearest stronghold.
|
||||
// EnderEyeItem.use: startUsingItem (getUseDuration 0, so the use
|
||||
// lasts until the button lets go), then the eye is thrown towards
|
||||
// the nearest stronghold if there is one (CONSUME without).
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
self.throw_ender_eye();
|
||||
return Ok(true);
|
||||
};
|
||||
|
||||
@@ -382,9 +382,14 @@ impl Game<'_> {
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
// Mobs.
|
||||
// Mobs, but not the one exploding (`getEntities(source, box)`): a
|
||||
// wither's spawn blast neither hurts nor pushes it.
|
||||
let spared = exploding.filter(|_| self.combat.explosion_source_named);
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
for m in mobs.iter_mut().filter(|m| m.alive()) {
|
||||
for m in mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.alive() && spared != Some(m.id))
|
||||
{
|
||||
let hurtable = teams_allow(self, self.mob_team(m.id));
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
|
||||
@@ -7,10 +7,12 @@ use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light,
|
||||
collisions::SceneCollisions,
|
||||
entity_nbt::RocketNbt,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Damage, DamageKind},
|
||||
mobs::{Damage, DamageKind, Species, Who},
|
||||
particles::{FireworkExplosion, ParticleOptions, ParticleType},
|
||||
projectiles::ProjectileWorld,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -30,9 +32,14 @@ pub(super) struct Rocket {
|
||||
pub(super) explosions: Vec<FireworkExplosion>,
|
||||
/// Boosting the player (attachedToEntity).
|
||||
pub(super) attached: bool,
|
||||
/// On a server, the player it boosts when not this game's own: a guest's
|
||||
/// connection.
|
||||
/// On a server, the guest (by connection) who launched it, or whom it
|
||||
/// boosts: `owner`'s `Who::Player` then means them, not the host.
|
||||
pub(super) guest: Option<u32>,
|
||||
/// `Projectile.getOwner`: the player who launched it, credited with
|
||||
/// what its blast hurts.
|
||||
pub(super) owner: Option<Who>,
|
||||
/// `Projectile.leftOwner`: until then it can't hit its owner.
|
||||
left_owner: bool,
|
||||
/// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows;
|
||||
/// boosting happens when it is the local player.
|
||||
pub(super) attached_to: Option<u32>,
|
||||
@@ -57,11 +64,36 @@ impl Rocket {
|
||||
explosions,
|
||||
attached: false,
|
||||
guest: None,
|
||||
owner: None,
|
||||
left_owner: false,
|
||||
attached_to: None,
|
||||
angled: false,
|
||||
item,
|
||||
}
|
||||
}
|
||||
/// A rocket loaded from its NBT (`/summon`, a saved chunk).
|
||||
pub(super) fn from_nbt(r: RocketNbt) -> Self {
|
||||
let item = r
|
||||
.item
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:firework_rocket", 1));
|
||||
let (_, explosions) = fireworks_of(&item);
|
||||
Self {
|
||||
id: 0,
|
||||
position: r.position,
|
||||
previous: r.position,
|
||||
velocity: r.motion,
|
||||
life: r.life,
|
||||
lifetime: r.lifetime,
|
||||
explosions,
|
||||
attached: false,
|
||||
guest: None,
|
||||
owner: r.owner,
|
||||
left_owner: r.left_owner,
|
||||
attached_to: None,
|
||||
angled: r.angled,
|
||||
item,
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: the rocket flies on by itself between
|
||||
/// the server's updates (a rocket that follows an entity is placed by the
|
||||
/// caller).
|
||||
@@ -72,6 +104,19 @@ impl Rocket {
|
||||
}
|
||||
self.life += 1;
|
||||
}
|
||||
/// The move of a rocket attached to no entity: sped up and rising, or an
|
||||
/// angled one's own motion.
|
||||
fn next_motion(&self) -> Vec3 {
|
||||
if self.angled {
|
||||
self.velocity
|
||||
} else {
|
||||
Vec3::new(
|
||||
self.velocity.x * 1.15,
|
||||
self.velocity.y + 0.04,
|
||||
self.velocity.z * 1.15,
|
||||
)
|
||||
}
|
||||
}
|
||||
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
|
||||
/// up (an angled one keeps its motion) and rise, then move. True when a
|
||||
/// block stopped it.
|
||||
@@ -85,15 +130,7 @@ impl Rocket {
|
||||
self.position.z + h,
|
||||
),
|
||||
);
|
||||
let wanted = if self.angled {
|
||||
self.velocity
|
||||
} else {
|
||||
Vec3::new(
|
||||
self.velocity.x * 1.15,
|
||||
self.velocity.y + 0.04,
|
||||
self.velocity.z * 1.15,
|
||||
)
|
||||
};
|
||||
let wanted = self.next_motion();
|
||||
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
|
||||
return false;
|
||||
};
|
||||
@@ -157,6 +194,8 @@ impl Game<'_> {
|
||||
explosions,
|
||||
attached,
|
||||
guest: None,
|
||||
owner: None,
|
||||
left_owner: false,
|
||||
attached_to: None,
|
||||
angled: false,
|
||||
item: stack.clone(),
|
||||
@@ -172,7 +211,9 @@ impl Game<'_> {
|
||||
hit.point[1] + f64::from(hit.face[1]) * 0.15,
|
||||
hit.point[2] + f64::from(hit.face[2]) * 0.15,
|
||||
);
|
||||
let rocket = self.new_rocket(at, stack, false);
|
||||
let mut rocket = self.new_rocket(at, stack, false);
|
||||
rocket.owner = Some(Who::Player);
|
||||
rocket.guest = self.lan_acting;
|
||||
self.rockets.push(rocket);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
@@ -182,10 +223,18 @@ impl Game<'_> {
|
||||
}
|
||||
/// A rocket shot at an angle (`DATA_SHOT_AT_ANGLE`): out of a dispenser
|
||||
/// (`FireworkRocketItem.asProjectile`) or a crossbow.
|
||||
pub(super) fn launch_angled_rocket(&mut self, at: Vec3, velocity: Vec3, stack: &ItemStack) {
|
||||
pub(super) fn launch_angled_rocket(
|
||||
&mut self,
|
||||
at: Vec3,
|
||||
velocity: Vec3,
|
||||
stack: &ItemStack,
|
||||
owner: Option<Who>,
|
||||
) {
|
||||
let mut rocket = self.new_rocket(at, stack, false);
|
||||
rocket.velocity = velocity;
|
||||
rocket.angled = true;
|
||||
rocket.owner = owner;
|
||||
rocket.guest = self.lan_acting.filter(|_| owner == Some(Who::Player));
|
||||
self.rockets.push(rocket);
|
||||
}
|
||||
/// `FireworkRocketItem.use` while gliding: a rocket attached to the player.
|
||||
@@ -197,6 +246,7 @@ impl Game<'_> {
|
||||
}
|
||||
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let mut rocket = self.new_rocket(at, stack, true);
|
||||
rocket.owner = Some(Who::Player);
|
||||
rocket.guest = self.lan_acting;
|
||||
self.rockets.push(rocket);
|
||||
if !self.creative {
|
||||
@@ -206,10 +256,33 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
pub(super) fn tick_rockets(&mut self) {
|
||||
let targets = if self.rockets.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.rocket_targets()
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < self.rockets.len() {
|
||||
let mut r = self.rockets.remove(i);
|
||||
r.previous = r.position;
|
||||
// Projectile.tick: whether it has left its owner, before it moves.
|
||||
let owner = self.rocket_owner(&r);
|
||||
if !r.left_owner {
|
||||
let h = 0.125;
|
||||
let reach = Aabb::new(
|
||||
Vec3::new(r.position.x - h, r.position.y, r.position.z - h),
|
||||
Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h),
|
||||
)
|
||||
.expand_towards(r.velocity)
|
||||
.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
r.left_owner = owner.is_none_or(|o| {
|
||||
!targets
|
||||
.iter()
|
||||
.any(|(who, b)| *who == o && b.intersects(reach))
|
||||
});
|
||||
}
|
||||
let skip = owner.filter(|_| !r.left_owner);
|
||||
let mut hit = None;
|
||||
if r.attached
|
||||
&& let Some(conn) = r.guest
|
||||
{
|
||||
@@ -235,7 +308,10 @@ impl Game<'_> {
|
||||
r.position = p.position;
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
hit = self.rocket_struck(&targets, &r, r.velocity, skip);
|
||||
} else {
|
||||
// getHitResultOnMoveVector before the move.
|
||||
hit = self.rocket_struck(&targets, &r, r.next_motion(), skip);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
@@ -246,6 +322,20 @@ impl Game<'_> {
|
||||
r.life = r.lifetime;
|
||||
}
|
||||
}
|
||||
// hitTargetOrDeflectSelf on an entity: a breeze turns it back
|
||||
// (ProjectileDeflection.REVERSE), anything else sets it off.
|
||||
match hit {
|
||||
Some(Who::Mob(id))
|
||||
if self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == id && m.species() == Species::Breeze) =>
|
||||
{
|
||||
r.velocity = r.velocity.scale(-0.5);
|
||||
}
|
||||
Some(_) => r.life = r.lifetime,
|
||||
None => {}
|
||||
}
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
if r.life == 0 {
|
||||
self.audio.play(
|
||||
@@ -327,17 +417,28 @@ impl Game<'_> {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (id, amount) in hits {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.damage(
|
||||
amount,
|
||||
Damage {
|
||||
kind: DamageKind::Explosion,
|
||||
attacker: None,
|
||||
from: Some(at),
|
||||
},
|
||||
);
|
||||
// damageSources().fireworks(this, getOwner()): a guest's rocket
|
||||
// hurts as theirs, and the mobs it hit answer to them.
|
||||
let owner = r.owner;
|
||||
if let Some(conn) = r.guest.filter(|_| owner.is_some())
|
||||
&& let Some(mut host) = self.lan.take()
|
||||
{
|
||||
let ran = self
|
||||
.as_guest(&mut host, conn, |g| g.rocket_hurt_mobs(&hits, owner, at))
|
||||
.is_some();
|
||||
if ran {
|
||||
for (id, _) in &hits {
|
||||
if self.mobs.iter().any(|m| m.id == *id) {
|
||||
host.mob_players.insert(*id, conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
if !ran {
|
||||
self.rocket_hurt_mobs(&hits, owner, at);
|
||||
}
|
||||
} else {
|
||||
self.rocket_hurt_mobs(&hits, owner, at);
|
||||
}
|
||||
}
|
||||
let camera = self.eye_position().unwrap_or(at);
|
||||
@@ -345,6 +446,87 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera);
|
||||
}
|
||||
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
|
||||
for &(id, amount) in hits {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.damage(
|
||||
amount,
|
||||
Damage {
|
||||
kind: DamageKind::Explosion,
|
||||
attacker,
|
||||
from: Some(at),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The owner as `rocket_targets` names it: a guest by their entity id.
|
||||
fn rocket_owner(&self, r: &Rocket) -> Option<Who> {
|
||||
let owner = r.owner?;
|
||||
let Some(conn) = r.guest.filter(|_| owner == Who::Player) else {
|
||||
return Some(owner);
|
||||
};
|
||||
let host = self.lan.as_ref()?;
|
||||
let g = host.hub.guests.iter().find(|g| g.conn == conn)?;
|
||||
Some(Who::Mob(g.entity_id as u32))
|
||||
}
|
||||
/// What a rocket can hit (`Projectile.canHitEntity`): living mobs, the
|
||||
/// dragon's parts and crystals, the player and the guests.
|
||||
fn rocket_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.map(|m| (Who::Mob(m.id), m.bounds()))
|
||||
.collect();
|
||||
if let Some(p) = self.player_view()
|
||||
&& p.alive
|
||||
&& !self.spectator()
|
||||
{
|
||||
targets.push((Who::Player, p.bounds));
|
||||
}
|
||||
targets.extend(
|
||||
self.special_targets()
|
||||
.into_iter()
|
||||
.map(|(id, b)| (Who::Mob(id), b)),
|
||||
);
|
||||
targets.extend(self.lan_projectile_targets());
|
||||
targets
|
||||
}
|
||||
/// `ProjectileUtil.getHitResultOnMoveVector`'s entity half: the nearest
|
||||
/// target on the move, short of the block it clips, each box grown by
|
||||
/// `computeMargin` (`tickCount` is the rocket's life plus one).
|
||||
fn rocket_struck(
|
||||
&self,
|
||||
targets: &[(Who, Aabb)],
|
||||
r: &Rocket,
|
||||
motion: Vec3,
|
||||
skip: Option<Who>,
|
||||
) -> Option<Who> {
|
||||
let from = r.position;
|
||||
let mut to = from.add_vector(motion);
|
||||
let w = self.mob_world_base(&[]);
|
||||
if let Some((at, ..)) = (super::creatures::ProjWorld {
|
||||
w: &w,
|
||||
targets: Vec::new(),
|
||||
})
|
||||
.clip(from, to)
|
||||
{
|
||||
to = at;
|
||||
}
|
||||
let margin = (f64::from(r.life - 1) / 20.0).clamp(0.0, 0.3);
|
||||
targets
|
||||
.iter()
|
||||
.filter(|(who, _)| Some(*who) != skip)
|
||||
.filter_map(|(who, b)| {
|
||||
let at = b
|
||||
.inflate(Vec3::new(margin, margin, margin))
|
||||
.clip(from, to)?;
|
||||
Some((*who, from.distance_squared(at)))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(who, _)| who)
|
||||
}
|
||||
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
|
||||
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
|
||||
let camera = self.eye_position();
|
||||
|
||||
@@ -164,7 +164,7 @@ impl Game<'_> {
|
||||
(p.position.x - start[0]) as f32,
|
||||
(p.position.z - start[2]) as f32,
|
||||
);
|
||||
self.avatar.tick();
|
||||
self.avatar.tick(start);
|
||||
self.avatar
|
||||
.update_bob(ground && alive && !swimming, velocity);
|
||||
if riding {
|
||||
|
||||
@@ -324,12 +324,12 @@ impl Game<'_> {
|
||||
let Some(hit) = self.pick() else {
|
||||
return false;
|
||||
};
|
||||
let name = &*self
|
||||
let id = &*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
|
||||
.name;
|
||||
let name = name.trim_start_matches("minecraft:");
|
||||
let name = id.trim_start_matches("minecraft:");
|
||||
match name {
|
||||
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
|
||||
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
|
||||
@@ -338,8 +338,9 @@ impl Game<'_> {
|
||||
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
|
||||
| "end_portal" | "end_gateway" => true,
|
||||
// ChestBlock.getMenuProvider: none while a redstone conductor
|
||||
// sits on top of either half (a sitting cat aside).
|
||||
"chest" | "trapped_chest" => {
|
||||
// sits on top of either half (a sitting cat aside). Copper
|
||||
// chests are `ChestBlock`s too.
|
||||
_ if horizon_client::containers::is_chest_block(id) => {
|
||||
let key = (hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let blocked = |p: (i32, i32, i32)| {
|
||||
let above = self.world.block(p.0, p.1 + 1, p.2);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! escape hatch. LAN guests and dedicated servers use the same code either
|
||||
//! way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
Game, Options, PackResources, Resources, UiResources, WorldIcon,
|
||||
execution_role::ExecutionRole,
|
||||
lan::{LanSettings, Owner},
|
||||
world_loading::WorldRequest,
|
||||
@@ -177,6 +177,8 @@ pub(super) struct Starting {
|
||||
handle: Integrated,
|
||||
ready: Receiver<Ready>,
|
||||
player: String,
|
||||
/// The save's folder in `saves/`, for the world icon once joined.
|
||||
folder: String,
|
||||
}
|
||||
|
||||
/// The world to open and the player who owns it.
|
||||
@@ -287,6 +289,7 @@ impl Game<'_> {
|
||||
handle,
|
||||
ready,
|
||||
player,
|
||||
folder: folder.to_owned(),
|
||||
});
|
||||
// Harnesses, control scripts and runs without a window go on with the world open.
|
||||
if self.window.is_none() || super::synchronous_startup(&self.options) {
|
||||
@@ -318,13 +321,25 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let Some(Starting {
|
||||
mut handle, player, ..
|
||||
mut handle,
|
||||
player,
|
||||
folder,
|
||||
..
|
||||
}) = self.integrated_starting.take()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
handle.world_name = world_name;
|
||||
self.connect_local(pipe, &player)?;
|
||||
// IntegratedServer.getWorldScreenshotFile: the client grabs the frame
|
||||
// the Select World list shows, and needs the save folder for it.
|
||||
self.world_icon = Some(WorldIcon {
|
||||
path: std::env::current_dir()?
|
||||
.join("saves")
|
||||
.join(&folder)
|
||||
.join("icon.png"),
|
||||
last_attempt: None,
|
||||
});
|
||||
self.integrated = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
@@ -425,6 +440,7 @@ impl Game<'_> {
|
||||
return result.and(stopped);
|
||||
}
|
||||
let mut next = Instant::now();
|
||||
let mut was_paused = false;
|
||||
while !flags.stop.load(Ordering::Relaxed) {
|
||||
self.run_tasks();
|
||||
if self.agent.hold_server {
|
||||
@@ -434,6 +450,14 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let paused = flags.paused.load(Ordering::Relaxed);
|
||||
if paused && !was_paused {
|
||||
// IntegratedServer.tickServer: the tick the game pauses saves
|
||||
// everything (saveEverything, without flushing), as an
|
||||
// autosave does.
|
||||
log::info!("Saving and pausing game...");
|
||||
self.save_game_with(false)?;
|
||||
}
|
||||
was_paused = paused;
|
||||
if paused {
|
||||
// Only the connections run.
|
||||
self.tick_server_timed(true)?;
|
||||
|
||||
@@ -833,35 +833,23 @@ impl Game<'_> {
|
||||
.facts()
|
||||
.get(self.world.block(pos.0, pos.1, pos.2))
|
||||
.name;
|
||||
let (menu, title, slots) = if &**name == "minecraft:ender_chest" {
|
||||
let (menu, slots) = if &**name == "minecraft:ender_chest" {
|
||||
// EnderChestBlock.useWithoutItem: the player's own inventory.
|
||||
(
|
||||
"minecraft:generic_9x3",
|
||||
"container.enderchest",
|
||||
Slots::Ender,
|
||||
)
|
||||
("minecraft:generic_9x3", Slots::Ender)
|
||||
} else if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get(&pos) {
|
||||
// AbstractFurnaceBlock.openContainer: FurnaceMenu, BlastFurnaceMenu, SmokerMenu.
|
||||
let (menu, title) = match f.kind.recipe_type() {
|
||||
"minecraft:blasting" => ("minecraft:blast_furnace", "container.blast_furnace"),
|
||||
"minecraft:smoking" => ("minecraft:smoker", "container.smoker"),
|
||||
_ => ("minecraft:furnace", "container.furnace"),
|
||||
let menu = match f.kind.recipe_type() {
|
||||
"minecraft:blasting" => "minecraft:blast_furnace",
|
||||
"minecraft:smoking" => "minecraft:smoker",
|
||||
_ => "minecraft:furnace",
|
||||
};
|
||||
(menu, title, Slots::Block(pos))
|
||||
(menu, Slots::Block(pos))
|
||||
} else if let Some(BlockEntity::Crafter(_)) = self.world.block_entities.get(&pos) {
|
||||
// CrafterBlock.useWithoutItem: CrafterMenu over the block entity.
|
||||
(
|
||||
"minecraft:crafter_3x3",
|
||||
"container.crafter",
|
||||
Slots::Block(pos),
|
||||
)
|
||||
("minecraft:crafter_3x3", Slots::Block(pos))
|
||||
} else if let Some(BlockEntity::Brewing(_)) = self.world.block_entities.get(&pos) {
|
||||
// BrewingStandBlock.useWithoutItem: BrewingStandMenu.
|
||||
(
|
||||
"minecraft:brewing_stand",
|
||||
"container.brewing",
|
||||
Slots::Block(pos),
|
||||
)
|
||||
("minecraft:brewing_stand", Slots::Block(pos))
|
||||
} else if let Some((other, right)) = self.chest_partner(pos) {
|
||||
// ChestBlock.combine: the right half's slots come first.
|
||||
let halves = if right {
|
||||
@@ -869,24 +857,33 @@ impl Game<'_> {
|
||||
} else {
|
||||
Slots::Double(other, pos)
|
||||
};
|
||||
("minecraft:generic_9x6", "container.chestDouble", halves)
|
||||
("minecraft:generic_9x6", halves)
|
||||
} else {
|
||||
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else {
|
||||
return false;
|
||||
};
|
||||
let (menu, title) = match items.len() {
|
||||
27 => ("minecraft:generic_9x3", "container.chest"),
|
||||
9 => ("minecraft:generic_3x3", "container.dispenser"),
|
||||
5 => ("minecraft:hopper", "container.hopper"),
|
||||
let menu = match items.len() {
|
||||
27 => "minecraft:generic_9x3",
|
||||
9 => "minecraft:generic_3x3",
|
||||
5 => "minecraft:hopper",
|
||||
_ => return false,
|
||||
};
|
||||
let title = match &**name {
|
||||
"minecraft:barrel" => "container.barrel",
|
||||
"minecraft:dropper" => "container.dropper",
|
||||
n if n.ends_with("shulker_box") => "container.shulkerBox",
|
||||
_ => title,
|
||||
};
|
||||
(menu, title, Slots::Block(pos))
|
||||
(menu, Slots::Block(pos))
|
||||
};
|
||||
// The menu provider's getDisplayName: a container's custom name
|
||||
// (BaseContainerBlockEntity.getName) or its default name.
|
||||
let custom = |k: (i32, i32, i32)| self.world.block_names.get(&k).map(String::as_str);
|
||||
let title = match slots {
|
||||
Slots::Ender => horizon_protocol::text::translatable("container.enderchest", &[]),
|
||||
Slots::Double(first, second) => {
|
||||
horizon_client::containers::double_chest_name(custom(first), custom(second))
|
||||
}
|
||||
Slots::Block(k) => {
|
||||
let Some(key) = horizon_client::containers::default_name_key(name) else {
|
||||
return false;
|
||||
};
|
||||
horizon_client::containers::display_name(custom(k), key)
|
||||
}
|
||||
};
|
||||
let Some(items) = host
|
||||
.hub
|
||||
@@ -895,17 +892,17 @@ impl Game<'_> {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// The block entity's createMenu: a shulker box opens a ShulkerBoxMenu
|
||||
// (not a 9x3 ChestMenu), whose slots refuse shulker boxes.
|
||||
let menu = match slots {
|
||||
Slots::Block(_) => horizon_client::containers::block_menu_type(name).unwrap_or(menu),
|
||||
_ => menu,
|
||||
};
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
host.hub.open_container(
|
||||
conn,
|
||||
menu,
|
||||
horizon_protocol::text::translatable(title, &[]),
|
||||
pos,
|
||||
slots,
|
||||
items,
|
||||
);
|
||||
host.hub
|
||||
.open_container(conn, menu, title, pos, slots, items);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1186,9 +1183,6 @@ impl Game<'_> {
|
||||
}
|
||||
S::UseItemOn(p) => {
|
||||
host.hub.ack(conn, p.sequence);
|
||||
if spectator {
|
||||
return;
|
||||
}
|
||||
// handleUseItemOn: within reach, and the hit point within the block.
|
||||
let inside = p
|
||||
.cursor
|
||||
@@ -1331,6 +1325,9 @@ impl Game<'_> {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let old = g.menu_slot(slot);
|
||||
g.set_menu_slot(slot, item.clone());
|
||||
// InventoryMenu.setRemoteSlot: the client already has it, so broadcastChanges
|
||||
// sends nothing back that could undo a click made since.
|
||||
g.menu_sent[slot] = item.clone();
|
||||
// InventoryMenu's ArmorSlot and off-hand slot: setByPlayer.
|
||||
let equipment = match slot {
|
||||
5..=8 => Some(["head", "chest", "legs", "feet"][slot - 5]),
|
||||
@@ -1667,9 +1664,7 @@ impl Game<'_> {
|
||||
host.hub.teleport(gi, Some(at));
|
||||
}
|
||||
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.death_time += 1;
|
||||
if g.death_time >= 20 {
|
||||
if host.hub.guests[gi].tick_death() {
|
||||
self.guest_drop_carried(host, gi);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,9 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
};
|
||||
if host.hub.guests[gi].spectator() {
|
||||
// handleContainerClick: nothing changes, and sendAllDataToRemote
|
||||
// takes back what the client predicted.
|
||||
host.hub.resend_menu(conn, p.container, &self.world);
|
||||
return true;
|
||||
}
|
||||
let mut menu = host
|
||||
|
||||
@@ -109,16 +109,6 @@ fn offset(p: Pos, d: Direction) -> Pos {
|
||||
let s = step(d);
|
||||
(p.0 + s[0], p.1 + s[1], p.2 + s[2])
|
||||
}
|
||||
fn clockwise(d: Direction) -> Direction {
|
||||
match d {
|
||||
Direction::North => Direction::East,
|
||||
Direction::East => Direction::South,
|
||||
Direction::South => Direction::West,
|
||||
Direction::West => Direction::North,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// State-level container queries shared by the game and comparator signals.
|
||||
struct Containers<'a> {
|
||||
world: &'a World,
|
||||
@@ -176,7 +166,7 @@ impl Containers<'_> {
|
||||
_ => Kind::Generic,
|
||||
};
|
||||
let size = entity.slots().len();
|
||||
if matches!(name, "minecraft:chest" | "minecraft:trapped_chest") {
|
||||
if horizon_client::containers::is_chest_block(name) {
|
||||
let is_blocked = |p: Pos| {
|
||||
let above = self.world.block(p.0, p.1 + 1, p.2);
|
||||
self.behaviours.facts(above).flags
|
||||
@@ -186,22 +176,19 @@ impl Containers<'_> {
|
||||
if blocked_chests && is_blocked(pos) {
|
||||
return None;
|
||||
}
|
||||
let kind_prop = self.prop(state, "type").unwrap_or("single");
|
||||
if kind_prop != "single"
|
||||
&& let Some(facing) = self.prop(state, "facing").and_then(dir_from_name)
|
||||
let half = |s: BlockStateId| {
|
||||
Some(horizon_client::containers::ChestHalf {
|
||||
block: self.name(s),
|
||||
kind: self.prop(s, "type")?,
|
||||
facing: self.prop(s, "facing")?,
|
||||
})
|
||||
};
|
||||
if let Some(own) = half(state)
|
||||
&& let Some((dx, dz)) = own.connected_step()
|
||||
{
|
||||
// ChestBlock.getConnectedDirection
|
||||
let connected = if kind_prop == "left" {
|
||||
clockwise(facing)
|
||||
} else {
|
||||
clockwise(clockwise(clockwise(facing)))
|
||||
};
|
||||
let other = offset(pos, connected);
|
||||
let other = (pos.0 + dx, pos.1, pos.2 + dz);
|
||||
let other_state = self.world.block(other.0, other.1, other.2);
|
||||
let paired = self.name(other_state) == name
|
||||
&& self
|
||||
.prop(other_state, "type")
|
||||
.is_some_and(|t| t != "single" && t != kind_prop)
|
||||
let paired = half(other_state).is_some_and(|o| own.pairs_with(&o))
|
||||
&& self
|
||||
.world
|
||||
.block_entities
|
||||
@@ -211,7 +198,7 @@ impl Containers<'_> {
|
||||
if blocked_chests && is_blocked(other) {
|
||||
return None;
|
||||
}
|
||||
let parts = if kind_prop == "right" {
|
||||
let parts = if own.kind == "right" {
|
||||
vec![(pos, size), (other, 27)]
|
||||
} else {
|
||||
vec![(other, 27), (pos, size)]
|
||||
|
||||
@@ -246,9 +246,15 @@ impl Game<'_> {
|
||||
self.paint_screen(batch, ox, oy, background, true);
|
||||
let atlas = &self.gui.atlas;
|
||||
let grey = rgba(64, 64, 64, 255);
|
||||
let title = self.container_title(self.translate("container.crafter", "Crafter"));
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(
|
||||
// A server's crafter menu has the title it was given.
|
||||
let title = self.remote_menu_title().unwrap_or_else(|| {
|
||||
horizon_client::text::Text::literal(
|
||||
self.container_title(self.translate("container.crafter", "Crafter")),
|
||||
)
|
||||
.resolve_legacy()
|
||||
});
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&title,
|
||||
ox + ((176.0 - tw) / 2.0).trunc(),
|
||||
|
||||
@@ -142,13 +142,36 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// SkeletonHorse.mobInteract: nothing for an untamed one.
|
||||
if species == Species::SkeletonHorse && !m.extra.tame {
|
||||
return false;
|
||||
}
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|
||||
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
|
||||
// Horse / ZombieHorse.mobInteract: food is fed, and anything else in
|
||||
// hand makes an untamed one mad instead of seating the player.
|
||||
let dandelion = [self.held(), self.offhand.as_ref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|s| &*s.item == "minecraft:golden_dandelion");
|
||||
let mut events = Vec::new();
|
||||
match equine::held_item(
|
||||
&mut self.mobs[index],
|
||||
item,
|
||||
sneaking,
|
||||
dandelion,
|
||||
&mut events,
|
||||
) {
|
||||
Some(equine::HeldItem::Feed) => return self.feed_mount(index, item),
|
||||
Some(equine::HeldItem::Refused) => {
|
||||
let _ = self.handle_mob_events(events);
|
||||
return true;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let m = &self.mobs[index];
|
||||
// AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory.
|
||||
if !m.is_baby()
|
||||
&& m.extra.tame
|
||||
&& (self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|
||||
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight))
|
||||
{
|
||||
if !m.is_baby() && m.extra.tame && sneaking {
|
||||
self.open_mount_menu(index);
|
||||
return true;
|
||||
}
|
||||
@@ -489,6 +512,22 @@ impl Game<'_> {
|
||||
MobEvent::Buck { mob } => {
|
||||
if self.riding == Some(mob) {
|
||||
self.dismount_mount();
|
||||
} else {
|
||||
// ejectPassengers: a server's player gets off too
|
||||
// (leave_guest tells their client).
|
||||
let rider = self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.vehicle == Some(mob))
|
||||
.map(|g| g.conn)
|
||||
});
|
||||
if let Some(conn) = rider
|
||||
&& let Some(mut host) = self.lan.take()
|
||||
{
|
||||
self.as_guest(&mut host, conn, |g| g.dismount_mount());
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
// RunAroundLikeCrazyGoal.tick: entity event 6, smoke.
|
||||
self.mob_event_particles(mob, 6);
|
||||
|
||||
@@ -269,7 +269,12 @@ impl Game<'_> {
|
||||
r.set_present_mode(s.vsync, s.horizon.present_mode);
|
||||
r.animated_textures = s.horizon.animated_textures;
|
||||
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
|
||||
r.set_world_post(s.horizon.resolution_scale as f32 / 100.0, s.horizon.fxaa);
|
||||
r.set_world_post(
|
||||
s.horizon.resolution_scale as f32 / 100.0,
|
||||
s.horizon.fxaa,
|
||||
s.horizon.taa,
|
||||
s.horizon.upscaling.ratio(),
|
||||
);
|
||||
r.set_hdr(
|
||||
s.horizon.hdr,
|
||||
s.horizon.hdr_paper_white as f32,
|
||||
|
||||
@@ -228,6 +228,11 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
"minecraft:firework_rocket" => {
|
||||
if let Some(r) = entity_nbt::firework_rocket_from_nbt(&tag, player_uuid) {
|
||||
self.rockets.push(super::fireworks::Rocket::from_nbt(r));
|
||||
}
|
||||
}
|
||||
// PrimedTnt.readAdditionalSaveData: its fuse (80 when absent) and motion.
|
||||
"minecraft:tnt" => {
|
||||
if let Some(position) = entity_nbt::position(&tag) {
|
||||
|
||||
@@ -124,7 +124,8 @@ impl CameraType {
|
||||
}
|
||||
/// The player's model type (`PlayerModel` with the default skin).
|
||||
pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
|
||||
horizon_client::mobs::load_model(root, "minecraft:player#main").map(|model| {
|
||||
let models = horizon_client::mobs::EntityModelDocument::load(root).ok()?;
|
||||
models.model("minecraft:player#main").map(|model| {
|
||||
Arc::new(MobType {
|
||||
id: "minecraft:player".into(),
|
||||
// Players render as a humanoid; the species only drives local AI.
|
||||
@@ -158,10 +159,14 @@ pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
|
||||
ambient_interval: 80,
|
||||
flags: Default::default(),
|
||||
model: Arc::new(model),
|
||||
// PlayerModel's slim arms, for slim skins.
|
||||
layers: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
|
||||
.map(|slim| [("player_slim#main".to_owned(), Arc::new(slim))].into())
|
||||
.unwrap_or_default(),
|
||||
// PlayerModel's slim arms, for slim skins, and PlayerCapeModel.
|
||||
layers: ["player_slim#main", "player#cape"]
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
let model = models.model(&format!("minecraft:{key}"))?;
|
||||
Some((key.to_owned(), Arc::new(model)))
|
||||
})
|
||||
.collect(),
|
||||
texture: "entity/player/wide/steve".into(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Humanoid { zombie_arms: false },
|
||||
@@ -357,6 +362,7 @@ impl Game<'_> {
|
||||
let off = self.offhand.as_ref().map(|s| s.item.clone());
|
||||
let riding = self.riding.is_some();
|
||||
let hurt = p.health.hurt_animation_time;
|
||||
let avatar = self.avatar;
|
||||
// LivingEntity.getBedOrientation: the model lies in the bed as
|
||||
// another player's does (`villager::sleep_pose`).
|
||||
let bed = self.sleeping.map_or(0, |head| self.bed_facing(head));
|
||||
@@ -431,6 +437,13 @@ impl Game<'_> {
|
||||
m.extra.ints[5] = charge_duration;
|
||||
m.extra.elytra.tick(fall_flying, crouching, velocity);
|
||||
m.extra.swim.tick(visually_swimming, in_water);
|
||||
// The local player's ClientAvatarState, its cloak in the model's frame
|
||||
// (the model stands 0.125 lower while crouching).
|
||||
m.extra.avatar = avatar;
|
||||
if crouching {
|
||||
m.extra.avatar.cloak[1] -= 0.125;
|
||||
m.extra.avatar.cloak_o[1] -= 0.125;
|
||||
}
|
||||
m.extra.swing_right_arm = swing_right_arm;
|
||||
m.hurt_time = hurt;
|
||||
}
|
||||
@@ -482,14 +495,9 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
out,
|
||||
);
|
||||
self.player_elytra_geometry(
|
||||
m,
|
||||
self.armor[1].as_ref().map(|s| &*s.item),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
let chest = self.armor[1].as_ref().map(|s| &*s.item);
|
||||
self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is
|
||||
@@ -554,14 +562,9 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
&mut verts,
|
||||
);
|
||||
self.player_elytra_geometry(
|
||||
&m,
|
||||
self.armor[1].as_ref().map(|s| &*s.item),
|
||||
camera,
|
||||
1.0,
|
||||
240 | 240 << 8,
|
||||
&mut verts,
|
||||
);
|
||||
let chest = self.armor[1].as_ref().map(|s| &*s.item);
|
||||
self.player_cape_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
let height = if m.extra.crouching { 1.5 } else { 1.8 };
|
||||
// PictureInPictureRenderer: the box centre, (size, size, -size);
|
||||
@@ -620,9 +623,12 @@ impl Game<'_> {
|
||||
let Some((texture, _)) = assets.equipment.get(asset).and_then(|k| k.get("wings")) else {
|
||||
return;
|
||||
};
|
||||
// WingsLayer.getPlayerElytraTexture: a player's cape texture has its elytra.
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let rect = m.cape.as_deref().and_then(|c| sprites.get(c));
|
||||
let (Some(model), Some(rect)) = (
|
||||
assets.player.get("elytra"),
|
||||
self.gui.atlas.sprites.get(texture),
|
||||
rect.or_else(|| sprites.get(texture)),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -649,6 +655,48 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
}
|
||||
/// `CapeLayer` for a player wearing `chest`: no cape under an elytra's
|
||||
/// wings, and moved out over a chestplate.
|
||||
pub(super) fn player_cape_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
chest: Option<&str>,
|
||||
camera: glam::DVec3,
|
||||
partial: f32,
|
||||
light: u32,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
let Some(rect) = m
|
||||
.cape
|
||||
.as_deref()
|
||||
.and_then(|c| self.gui.atlas.sprites.get(c))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if horizon_client::mobs::trader::is_invisible(m) {
|
||||
return;
|
||||
}
|
||||
let layers = chest
|
||||
.and_then(|item| self.res.items().get(item))
|
||||
.and_then(|i| i.equip_asset.as_ref())
|
||||
.and_then(|asset| self.armor_stand_assets.equipment.get(asset));
|
||||
if layers.is_some_and(|k| k.contains_key("wings")) {
|
||||
return;
|
||||
}
|
||||
let mut base = horizon_client::mobs::render::base_transform(m, partial, camera);
|
||||
if layers.is_some_and(|k| k.contains_key("humanoid")) {
|
||||
base *= glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.053125, 0.06875));
|
||||
}
|
||||
horizon_client::mobs::render::cape(
|
||||
m,
|
||||
partial,
|
||||
base,
|
||||
[rect.u0, rect.v0, rect.u1, rect.v1],
|
||||
light,
|
||||
self.res.mob_data(),
|
||||
out,
|
||||
);
|
||||
}
|
||||
/// `SpinAttackEffectLayer`: Riptide's two boxes turning around a spinning
|
||||
/// player (`SpinAttackEffectModel.setupAnim`).
|
||||
pub(super) fn spin_attack_geometry(
|
||||
|
||||
@@ -11,6 +11,7 @@ use super::{Dim, Game, Mining, Screen};
|
||||
use horizon_client::first_person::{Hand, SwingAnimation};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
blocks::Fluid,
|
||||
chunk::Chunk,
|
||||
containers::{BlockEntity, Furnace, FurnaceKind},
|
||||
entities::{Experience, ExperienceOrb, ItemEntity},
|
||||
@@ -216,8 +217,8 @@ pub(super) struct Remote {
|
||||
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
|
||||
show_death_screen: bool,
|
||||
player_type: Option<Arc<MobType>>,
|
||||
/// Inventory (then the off hand) as last synchronised, for creative-mode
|
||||
/// slot updates.
|
||||
/// Inventory, off hand and armour as last synchronised, for creative-mode
|
||||
/// slot updates (`creative_sync_stacks`).
|
||||
synced: Vec<Option<ItemStack>>,
|
||||
decode_errors: u32,
|
||||
clock: Option<i32>,
|
||||
@@ -516,7 +517,7 @@ impl Game<'_> {
|
||||
death_message: None,
|
||||
show_death_screen: true,
|
||||
player_type,
|
||||
synced: vec![None; 37],
|
||||
synced: vec![None; CREATIVE_SYNCED],
|
||||
decode_errors: 0,
|
||||
clock: None,
|
||||
clock_rate: 1.0,
|
||||
@@ -548,12 +549,20 @@ impl Game<'_> {
|
||||
}
|
||||
self.clear_waypoints();
|
||||
self.boss_overlay.reset();
|
||||
// A world is leaving either way: its icon attempt goes with it.
|
||||
self.world_icon = None;
|
||||
if let Some(mut r) = self.remote.take() {
|
||||
r.session.disconnect();
|
||||
}
|
||||
self.server_packs_disconnect();
|
||||
self.code_of_conduct = None;
|
||||
self.close_dialogs();
|
||||
// Gui.setScreen -> KeyMapping.releaseAll: the title or disconnect
|
||||
// screen releases every held key. Its release goes to that screen,
|
||||
// so a key kept here would still be down in the next world (#3246).
|
||||
self.keys.clear();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
// Single player's server thread saves the world before the title shows.
|
||||
let single_player = match self.integrated.take() {
|
||||
Some(server) => {
|
||||
@@ -1805,6 +1814,12 @@ impl Game<'_> {
|
||||
// again) and with no item in use.
|
||||
self.potions.effects.clear();
|
||||
self.using = None;
|
||||
// After a death (no KEEP_ENTITY_DATA) the new LocalPlayer
|
||||
// rides nothing: the server pairs a vehicle again with its
|
||||
// passengers. A dimension change keeps the vehicle it travels in.
|
||||
if p.keep & 2 == 0 {
|
||||
self.riding = None;
|
||||
}
|
||||
let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension);
|
||||
self.enter_remote_dimension(&p.spawn, true);
|
||||
if dimension_changed {
|
||||
@@ -2725,12 +2740,14 @@ impl Game<'_> {
|
||||
self.hotbar_pop[pop] = 5;
|
||||
}
|
||||
}
|
||||
let before = self.creative_stacks();
|
||||
self.set_menu_slot(p.container, i32::from(p.slot), stack);
|
||||
self.remote_synced();
|
||||
self.remote_synced_since(&before);
|
||||
}
|
||||
C::SetCursorItem(p) => self.carried = self.to_stack(&p.item),
|
||||
C::SetPlayerInventory(p) => {
|
||||
let stack = self.to_stack(&p.item);
|
||||
let before = self.creative_stacks();
|
||||
match p.slot {
|
||||
0..=35 => self.inventory[p.slot as usize] = stack,
|
||||
36..=39 => self.armor[3 - (p.slot - 36) as usize] = stack,
|
||||
@@ -2738,7 +2755,7 @@ impl Game<'_> {
|
||||
40 => self.offhand = stack,
|
||||
_ => {}
|
||||
}
|
||||
self.remote_synced();
|
||||
self.remote_synced_since(&before);
|
||||
}
|
||||
C::ContainerSetData(p) if self.open_menu_name(p.container) == "minecraft:lectern" => {
|
||||
self.remote_lectern_page(i32::from(p.value));
|
||||
@@ -3214,6 +3231,8 @@ impl Game<'_> {
|
||||
let mut resolved: Vec<(u128, String)> = Vec::new();
|
||||
// PlayerSkin.model of each resolved skin: the textures' `slim`.
|
||||
let mut slim = std::collections::HashSet::new();
|
||||
// PlayerSkin.cape of each resolved skin.
|
||||
let mut capes: HashMap<u128, String> = HashMap::new();
|
||||
for (uuid, textures) in entries {
|
||||
// PlayerInfo.createSkinLookup: only the local player's own skin
|
||||
// may be unsigned.
|
||||
@@ -3227,10 +3246,24 @@ impl Game<'_> {
|
||||
if textures.slim {
|
||||
slim.insert(uuid);
|
||||
}
|
||||
if let Some(cape) = horizon_client::skins::cape(&textures) {
|
||||
capes.insert(uuid, cape);
|
||||
}
|
||||
resolved.push((uuid, skin));
|
||||
}
|
||||
}
|
||||
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin));
|
||||
// An offline name's cape from the launcher, where the profile has none.
|
||||
if !capes.contains_key(&local)
|
||||
&& let Some(rgba) = horizon_client::skins::offline_cape()
|
||||
{
|
||||
const OFFLINE: &str = "capes/offline";
|
||||
if !self.show_skin(OFFLINE) {
|
||||
self.store_skin(OFFLINE.into(), rgba.to_vec());
|
||||
}
|
||||
capes.insert(local, OFFLINE.into());
|
||||
}
|
||||
capes.retain(|_, cape| self.again_skin(cape));
|
||||
// PlayerInfo.getSkin: the downloaded skin and its model, else the
|
||||
// profile's default skin (DefaultPlayerSkin.get), slim or wide.
|
||||
let skin_of = |skins: &HashMap<u128, String>, uuid: u128| match skins.get(&uuid) {
|
||||
@@ -3262,12 +3295,18 @@ impl Game<'_> {
|
||||
{
|
||||
mob.skin = Some(skin);
|
||||
mob.slim = is_slim;
|
||||
mob.cape = capes.get(&uuid).cloned();
|
||||
}
|
||||
}
|
||||
if let Some(mob) = self.player_model.as_mut() {
|
||||
let (skin, is_slim) = skin_of(&skins, own);
|
||||
mob.skin = Some(skin);
|
||||
mob.slim = is_slim;
|
||||
// Avatar.isModelPartShown(CAPE): the Skin Customization option.
|
||||
mob.cape = capes
|
||||
.get(&own)
|
||||
.filter(|_| self.settings.file.bool("modelPart_cape"))
|
||||
.cloned();
|
||||
}
|
||||
// ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a
|
||||
// mannequin's DATA_PROFILE `textures` property resolves like a
|
||||
@@ -3351,7 +3390,7 @@ impl Game<'_> {
|
||||
self.gui.atlas.sprites.remove(&old);
|
||||
slot
|
||||
};
|
||||
let Some(rect) = self
|
||||
let Some(mut rect) = self
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
@@ -3368,6 +3407,10 @@ impl Game<'_> {
|
||||
height: 64,
|
||||
rgba,
|
||||
});
|
||||
// A cape is a 64x32 texture in the top half of the slot (skins::process_cape).
|
||||
if location.starts_with("capes/") {
|
||||
rect.v1 = (rect.v0 + rect.v1) / 2.0;
|
||||
}
|
||||
self.gui.atlas.sprites.insert(location.clone(), rect);
|
||||
self.skin_slots.push((location, slot));
|
||||
}
|
||||
@@ -3827,6 +3870,17 @@ impl Game<'_> {
|
||||
// player too (controlled_simulation_tick does it otherwise): left
|
||||
// at 10, the hurt tilt restarted every frame and shook the camera.
|
||||
player.health.advance_clock();
|
||||
if riding && !dead {
|
||||
MotionWorld {
|
||||
entities: &entity_collisions,
|
||||
player: &mut *player,
|
||||
scene: &mut self.world,
|
||||
assets: &self.pack.assets,
|
||||
collision_binding: self.collision,
|
||||
resources: self.motion,
|
||||
}
|
||||
.passenger_pose_tick()?;
|
||||
}
|
||||
}
|
||||
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
|
||||
// jump, a spectator, landing) is sent to the server.
|
||||
@@ -4441,41 +4495,44 @@ impl Game<'_> {
|
||||
{
|
||||
out.push(packet);
|
||||
}
|
||||
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot);
|
||||
// `synced[36]` is the off hand.
|
||||
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot).
|
||||
let mut creative_slots = Vec::new();
|
||||
if self.creative {
|
||||
for i in 0..37 {
|
||||
let stack = self.inventory.get(i).unwrap_or(&self.offhand);
|
||||
if *stack != r.synced[i] {
|
||||
let stacks = creative_sync_stacks(&self.inventory, &self.offhand, &self.armor);
|
||||
for (i, stack) in stacks.into_iter().enumerate() {
|
||||
if stack != r.synced[i] {
|
||||
r.synced[i] = stack.clone();
|
||||
creative_slots.push(i);
|
||||
creative_slots.push((creative_sync_slot(i), stack));
|
||||
}
|
||||
}
|
||||
}
|
||||
for packet in out {
|
||||
self.send(packet);
|
||||
}
|
||||
for i in creative_slots {
|
||||
// InventoryMenu's slots: the hotbar is 36..=44, the off hand 45.
|
||||
let slot = match i {
|
||||
0..9 => 36 + i,
|
||||
36 => 45,
|
||||
_ => i,
|
||||
} as i16;
|
||||
let item = self.to_network(self.inventory.get(i).unwrap_or(&self.offhand));
|
||||
for (slot, stack) in creative_slots {
|
||||
let item = self.to_network(&stack);
|
||||
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot,
|
||||
item,
|
||||
}));
|
||||
}
|
||||
}
|
||||
fn creative_stacks(&self) -> Vec<Option<ItemStack>> {
|
||||
creative_sync_stacks(&self.inventory, &self.offhand, &self.armor)
|
||||
}
|
||||
/// Inventory as the server last described it.
|
||||
fn remote_synced(&mut self) {
|
||||
let mut inventory = self.inventory.clone();
|
||||
inventory.push(self.offhand.clone());
|
||||
let stacks = self.creative_stacks();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.synced = inventory;
|
||||
r.synced = stacks;
|
||||
}
|
||||
}
|
||||
/// The slots a server packet changed since `before` are as the server has them; the
|
||||
/// others keep what was last sent, so a creative click made since still goes out.
|
||||
fn remote_synced_since(&mut self, before: &[Option<ItemStack>]) {
|
||||
let now = self.creative_stacks();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
mark_synced(&mut r.synced, before, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4748,10 +4805,17 @@ impl Game<'_> {
|
||||
.and_then(|h| self.res.items().get(&h.item))
|
||||
.is_some_and(|i| i.block.is_some());
|
||||
if let Some(hit) = self.pick() {
|
||||
let (reacts, changes) = if hand == Hand::Main {
|
||||
let spectator = self.spectator();
|
||||
let (reacts, changes) = if hand == Hand::Main && !spectator {
|
||||
self.remote_block_reacts(&hit, held.as_ref())
|
||||
} else {
|
||||
(false, DryUse::default())
|
||||
// useItemOn runs for the off hand too (useWithoutItem doesn't):
|
||||
// RespawnAnchorBlock.useItemOn charges an anchor with its glowstone.
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
|
||||
let charges =
|
||||
!(sneaking && hands_full) && self.anchor_takes(hit.pos, held.as_ref());
|
||||
(charges, DryUse::default())
|
||||
};
|
||||
let seq = self.next_sequence();
|
||||
let cursor = [
|
||||
@@ -4768,6 +4832,12 @@ impl Game<'_> {
|
||||
world_border: false,
|
||||
sequence: seq,
|
||||
}));
|
||||
// MultiPlayerGameMode.performUseItemOn: a spectator's use predicts
|
||||
// nothing and ends the click (CONSUME, no swing); the server opens
|
||||
// the block's menu.
|
||||
if spectator {
|
||||
return true;
|
||||
}
|
||||
// Only a predicted placement (predict_place) marks a cell whose
|
||||
// resend plays a place sound: an empty hand or a non-block item
|
||||
// places nothing (BlockItem.place is where the sound comes from).
|
||||
@@ -4916,6 +4986,12 @@ impl Game<'_> {
|
||||
if self.jukebox_takes(hit.pos, held) {
|
||||
return (true, DryUse::default());
|
||||
}
|
||||
// RespawnAnchorBlock.useItemOn: PASS when the off hand's glowstone is to
|
||||
// charge the anchor, so useWithoutItem doesn't run and the off hand's turn
|
||||
// comes (glowstone in this hand charges it in `use_item_on`).
|
||||
if !self.anchor_takes(hit.pos, held) && self.anchor_takes(hit.pos, self.offhand.as_ref()) {
|
||||
return (false, DryUse::default());
|
||||
}
|
||||
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
|
||||
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
|
||||
// no UseItem follows (the server does the harvest, see `use_beehive`).
|
||||
@@ -5020,7 +5096,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// MultiPlayerGameMode.useItem for `hand`, if it holds something: whether
|
||||
/// the click ends there, as far as this client can tell (`item_has_use`).
|
||||
/// the click ends there, as far as this client can tell (`use_ends_click`).
|
||||
fn remote_use_item(&mut self, hand: Hand) -> bool {
|
||||
let Some(held) = self.with_hand(hand, |g| g.held().cloned()) else {
|
||||
return false;
|
||||
@@ -5049,6 +5125,12 @@ impl Game<'_> {
|
||||
if info.food.is_some() && !eating {
|
||||
return false;
|
||||
}
|
||||
// A bucket, a glass bottle or a boat with nothing to fill, empty or
|
||||
// place PASSes or FAILs: startUseItem goes on to the off hand (a
|
||||
// Riptide trident there charges).
|
||||
if self.with_hand(hand, |g| g.use_finds_no_target(&held)) {
|
||||
return false;
|
||||
}
|
||||
// It runs the use here too: ItemStack.use starts the `use_cooldown`
|
||||
// of an item with no use time, and the server's
|
||||
// ClientboundCooldownPacket confirms it; one held in use starts it at
|
||||
@@ -5060,9 +5142,65 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
self.with_hand(hand, |g| g.remote_start_held_use(&held));
|
||||
item_has_use(&info)
|
||||
let gliding = self.player.as_ref().is_some_and(|p| p.fall_flying);
|
||||
use_ends_click(&info, gliding)
|
||||
}
|
||||
|
||||
/// Whether `held`'s `Item.use` needs a target this client sees none of
|
||||
/// (not SUCCESS): BucketItem.use (a fluid source or powder snow to pick
|
||||
/// up through `ClipContext.Fluid.SOURCE_ONLY`, a block to empty a full
|
||||
/// one against, `mayUseItemAt`), BottleItem.use (water to fill from) and
|
||||
/// BoatItem.use (a block or fluid surface with room for the boat).
|
||||
fn use_finds_no_target(&self, held: &ItemStack) -> bool {
|
||||
let id = &*held.item;
|
||||
let facts = |pos: [i32; 3]| {
|
||||
self.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]))
|
||||
};
|
||||
let source = |want: Option<Fluid>| {
|
||||
let fluid_hit = self.pick_fluid();
|
||||
// pick_fluid steps through waterlogged blocks, but their water is
|
||||
// a source the SOURCE_ONLY ray stops at (and a BucketPickup).
|
||||
if let Some(hit) = self.pick()
|
||||
&& facts(hit.pos).waterlogged
|
||||
{
|
||||
let eye = self.aim().map_or(hit.point, |(eye, _)| eye);
|
||||
let d = |p: [f64; 3]| (0..3).map(|a| (p[a] - eye[a]).powi(2)).sum::<f64>();
|
||||
if fluid_hit
|
||||
.as_ref()
|
||||
.is_none_or(|fluid| d(fluid.point) >= d(hit.point))
|
||||
{
|
||||
return want.is_none_or(|want| want == Fluid::Water);
|
||||
}
|
||||
}
|
||||
fluid_hit.is_some_and(|hit| {
|
||||
let f = facts(hit.pos);
|
||||
let fluid = f.fluid != Fluid::None && f.fluid_level == 8 && !f.fluid_falling;
|
||||
match want {
|
||||
Some(want) => fluid && f.fluid == want,
|
||||
None => fluid || self.is_powder_snow(hit.pos),
|
||||
}
|
||||
})
|
||||
};
|
||||
// SolidBucketItem only has useOn: its use is Item.use's PASS.
|
||||
if id == "minecraft:powder_snow_bucket" {
|
||||
return true;
|
||||
}
|
||||
if id == "minecraft:bucket" {
|
||||
return !self.may_build() || !source(None);
|
||||
}
|
||||
if horizon_client::items::bucket_contents(id).is_some() {
|
||||
return !self.may_build() || self.pick().is_none();
|
||||
}
|
||||
if id == "minecraft:glass_bottle" {
|
||||
return !source(Some(Fluid::Water));
|
||||
}
|
||||
if super::boats::any_boat_wood(id).is_some() {
|
||||
return self.boat_to_place(held).is_none();
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `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
|
||||
@@ -5092,6 +5230,9 @@ impl Game<'_> {
|
||||
}
|
||||
// SpyglassItem.use (its sound is the server's).
|
||||
"minecraft:spyglass" => true,
|
||||
// EnderEyeItem.use: startUsingItem unless an End portal frame is
|
||||
// aimed at, with or without a stronghold to throw the eye at.
|
||||
"minecraft:ender_eye" => !self.aims_at_end_portal_frame(),
|
||||
// Item.use: a kinetic weapon starts being used.
|
||||
id if horizon_client::spear::Kinetic::of(id).is_some() => true,
|
||||
// ShieldItem.use: anything that blocks attacks, off cooldown.
|
||||
@@ -5105,11 +5246,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Whether `stack` stays in use until the button lets go (bows,
|
||||
/// crossbows, tridents, spyglasses, shields): its use only counts ticks here.
|
||||
/// crossbows, tridents, spyglasses, shields, eyes of ender, whose
|
||||
/// `getUseDuration` 0 never counts down to the end): 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"
|
||||
"minecraft:bow"
|
||||
| "minecraft:crossbow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:spyglass"
|
||||
| "minecraft:ender_eye"
|
||||
) || horizon_client::spear::Kinetic::of(&stack.item).is_some()
|
||||
|| self.blocks_attacks(stack).is_some()
|
||||
}
|
||||
@@ -5414,6 +5561,7 @@ impl Game<'_> {
|
||||
self.send(S::Attack(play::Attack {
|
||||
id: self.mobs[i].id as i32,
|
||||
}));
|
||||
self.remote_player_attack(i);
|
||||
self.reset_attack_strength();
|
||||
} else if !self.entity_targeted()
|
||||
&& let Some(hit) = self.pick()
|
||||
@@ -5434,6 +5582,58 @@ impl Game<'_> {
|
||||
instant
|
||||
}
|
||||
|
||||
/// `MultiPlayerGameMode.attack` runs `Player.attack` on this client too.
|
||||
/// For a mob `hurtOrSimulate` fails here (`Entity.hurtClient`), so the
|
||||
/// hit has no attacker side; but `RemotePlayer.hurtClient` is true, so a
|
||||
/// hit on another player with extra knockback runs the attacker's side of
|
||||
/// `causeExtraKnockback`: the 0.6 horizontal speed cut and the sprint
|
||||
/// stop. Without it the next move outruns the server's player.
|
||||
fn remote_player_attack(&mut self, mob: usize) {
|
||||
let id = self.mobs[mob].id as i32;
|
||||
if !self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// `getAttackStrengthScale(0.5)`: here the ticker is `hand_strength`'s
|
||||
// (`attack_ticks` only counts in the local world), and the attack
|
||||
// speed is the player's synced attribute, which a server may change.
|
||||
let speed = self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|p| {
|
||||
let key = p.attributes.key("minecraft:attack_speed")?;
|
||||
p.attributes.lookup(Some(&key))?;
|
||||
p.attributes
|
||||
.value(Some(key), horizon_world::attribute_map::ValueKind::Computed)
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or_else(|| self.main_hand_attack_speed());
|
||||
let scale = self.hand_strength.scale(0.5, speed);
|
||||
// Player.attack's damage, in its order: the ATTACK_DAMAGE value (or a
|
||||
// riptide spin's), the enchantment damage, then the knockback.
|
||||
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
|
||||
let damage = if spin > 0.0 {
|
||||
spin
|
||||
} else {
|
||||
1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage)
|
||||
+ self.effect_attack_bonus()
|
||||
};
|
||||
let enchant = self.enchant_damage_bonus("minecraft:player");
|
||||
let (knockback, _) = self.extra_knockback("minecraft:player", scale);
|
||||
if !horizon_client::attack_strength::attacker_slows_after_hit(
|
||||
damage, enchant, scale, knockback,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = Vec3::new(p.velocity.x * 0.6, p.velocity.y, p.velocity.z * 0.6);
|
||||
}
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
|
||||
fn remote_continue_attack(&mut self, down: bool) {
|
||||
if !down {
|
||||
self.clicks.miss_time = 0;
|
||||
@@ -5593,6 +5793,14 @@ impl Game<'_> {
|
||||
} else {
|
||||
Input::Pickup
|
||||
};
|
||||
if self.screen == Screen::Creative {
|
||||
// CreativeModeInventoryScreen.slotClicked: the Survival Inventory tab clicks the
|
||||
// player's own InventoryMenu, and its CreativeInventoryListener sends each slot that
|
||||
// changed (SetCreativeModeSlot). No ContainerClick: a server holding the carried
|
||||
// stack would give it back after the Destroy Item slot cleared it (#3307).
|
||||
self.menu_click(Some(internal), i32::from(right), input);
|
||||
return;
|
||||
}
|
||||
self.remote_menu_ops(&[(Some(internal), i32::from(right), input)]);
|
||||
}
|
||||
/// The server's open menu: its window id and menu type (0, "inventory"
|
||||
@@ -5632,22 +5840,23 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|&(slot, button, input)| (slot, button, input, Vec::new(), self.carried.clone()))
|
||||
.collect();
|
||||
let mut threw = false;
|
||||
// The engine's slot indices are the menu's own, as on the network.
|
||||
self.menu_session(|m, env, _| {
|
||||
let mut predicted = Vec::with_capacity(clicks.len());
|
||||
for &(slot, button, input) in &clicks {
|
||||
let before = menu_items(m);
|
||||
if m.click(env, i32::from(slot), button, input).is_err() {
|
||||
// The server throws on it too: nothing is predicted.
|
||||
return Some(false);
|
||||
}
|
||||
let changed = changed_slots(&before, m);
|
||||
predicted.push((slot, button, input, changed, m.carried.clone()));
|
||||
}
|
||||
let Some((predicted, dropped)) = predict_clicks(m, env, &clicks) else {
|
||||
// The server throws on it too: nothing is predicted.
|
||||
return Some(false);
|
||||
};
|
||||
sent = predicted;
|
||||
threw = dropped;
|
||||
Some(true)
|
||||
});
|
||||
self.send_clicks(container, sent);
|
||||
// LivingEntity.drop(.., Prediction.PREDICTED): the client swings its own
|
||||
// main hand; the server shows the swing only to the other players.
|
||||
if threw {
|
||||
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A Shift double click on a server (`AbstractContainerScreen.mouseReleased`):
|
||||
@@ -5749,6 +5958,12 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.carried.take().expect("carried")
|
||||
};
|
||||
self.remote_creative_throw(thrown);
|
||||
}
|
||||
/// `MultiPlayerGameMode.handleCreativeModeItemDrop`: a stack thrown out of the creative
|
||||
/// screen goes to the server as a SetCreativeModeSlot to slot -1, which drops it from the
|
||||
/// player's hand; the client only swings (`Player.drop` on a client).
|
||||
pub(super) fn remote_creative_throw(&mut self, thrown: ItemStack) {
|
||||
if !self.creative {
|
||||
return;
|
||||
}
|
||||
@@ -6240,6 +6455,74 @@ type Predicted = (
|
||||
Option<ItemStack>,
|
||||
);
|
||||
|
||||
/// How many stacks the creative screen keeps in step with the server (`Remote::synced`).
|
||||
const CREATIVE_SYNCED: usize = 41;
|
||||
|
||||
/// The player's stacks in `Remote::synced` order: the 36 inventory slots, the off hand, then
|
||||
/// the armour from the head down. `CreativeInventoryListener.slotChanged` sends every
|
||||
/// InventoryMenu slot the creative screen changes, the armour slots too.
|
||||
fn creative_sync_stacks(
|
||||
inventory: &[Option<ItemStack>],
|
||||
offhand: &Option<ItemStack>,
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
) -> Vec<Option<ItemStack>> {
|
||||
let mut stacks: Vec<Option<ItemStack>> = inventory.iter().take(36).cloned().collect();
|
||||
stacks.resize(36, None);
|
||||
stacks.push(offhand.clone());
|
||||
stacks.extend(armor.iter().cloned());
|
||||
stacks
|
||||
}
|
||||
|
||||
/// Entries of `creative_sync_stacks` that went from `before` to `now` are synced as `now`.
|
||||
fn mark_synced(
|
||||
synced: &mut [Option<ItemStack>],
|
||||
before: &[Option<ItemStack>],
|
||||
now: Vec<Option<ItemStack>>,
|
||||
) {
|
||||
for (i, stack) in now.into_iter().enumerate() {
|
||||
if before.get(i) != Some(&stack)
|
||||
&& let Some(s) = synced.get_mut(i)
|
||||
{
|
||||
*s = stack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// InventoryMenu's slot for entry `i` of `creative_sync_stacks`: the hotbar is 36..=44, the
|
||||
/// main inventory 9..=35, the off hand 45 and the armour 5..=8 (head first).
|
||||
fn creative_sync_slot(i: usize) -> i16 {
|
||||
(match i {
|
||||
0..9 => 36 + i,
|
||||
36 => 45,
|
||||
37.. => 5 + (i - 37),
|
||||
_ => i,
|
||||
}) as i16
|
||||
}
|
||||
|
||||
/// `MultiPlayerGameMode.handleContainerInput` for each click on the engine's
|
||||
/// copy of the menu: the click with the slots it changed and the carried
|
||||
/// stack after it, and whether a click dropped a stack. Every drop of
|
||||
/// `AbstractContainerMenu.doClick` (a THROW, a click outside the window, a
|
||||
/// swap or a result move with no room left) is `Prediction.PREDICTED`.
|
||||
/// None when the engine refuses a click.
|
||||
fn predict_clicks(
|
||||
m: &mut horizon_client::menu::Menu,
|
||||
env: &horizon_client::menu::Env,
|
||||
clicks: &[(i16, i32, Input)],
|
||||
) -> Option<(Vec<Predicted>, bool)> {
|
||||
let mut predicted = Vec::with_capacity(clicks.len());
|
||||
let mut dropped = false;
|
||||
for &(slot, button, input) in clicks {
|
||||
let before = menu_items(m);
|
||||
let thrown = m.dropped.len();
|
||||
m.click(env, i32::from(slot), button, input).ok()?;
|
||||
dropped |= m.dropped.len() > thrown;
|
||||
let changed = changed_slots(&before, m);
|
||||
predicted.push((slot, button, input, changed, m.carried.clone()));
|
||||
}
|
||||
Some((predicted, dropped))
|
||||
}
|
||||
|
||||
fn menu_items(m: &horizon_client::menu::Menu) -> Vec<Option<ItemStack>> {
|
||||
(0..m.slot_count()).map(|i| m.slot(i).cloned()).collect()
|
||||
}
|
||||
@@ -6377,6 +6660,13 @@ fn hand_id(hand: Hand) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `Item.use` of `info` ends `Minecraft.startUseItem`'s click, so the
|
||||
/// off hand gets no turn. `FireworkRocketItem.use` answers SUCCESS while the
|
||||
/// player glides (the rocket boosts them) and PASS otherwise.
|
||||
fn use_ends_click(info: &horizon_client::items::ItemInfo, fall_flying: bool) -> bool {
|
||||
item_has_use(info) || (fall_flying && &*info.id == "minecraft:firework_rocket")
|
||||
}
|
||||
|
||||
/// Whether `Item.use` usually acts on this item (eats, drinks, equips, raises,
|
||||
/// throws, shoots, fills or places), which ends `Minecraft.startUseItem` at
|
||||
/// its hand. Tools and materials pass, and the off hand gets its turn. The
|
||||
@@ -6520,6 +6810,66 @@ impl Remote {
|
||||
mod tests {
|
||||
use super::Game;
|
||||
|
||||
/// Q over a slot of the inventory on a server (#3357): the predicted THROW
|
||||
/// drops the stack, so the player's hand swings, as `LivingEntity.drop`
|
||||
/// does for `Prediction.PREDICTED`; a plain pick up drops nothing.
|
||||
#[test]
|
||||
fn a_predicted_throw_from_a_slot_swings_the_hand() {
|
||||
use horizon_client::{
|
||||
banner::Banners,
|
||||
brewing::Brewing,
|
||||
crafting::Recipes,
|
||||
creative::Names,
|
||||
enchant::Enchantments,
|
||||
items::{ItemStack, Items},
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
stations::StationRecipes,
|
||||
};
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let items = Items::catalog(&root).unwrap();
|
||||
let recipes = Recipes::load(&root, &items).unwrap();
|
||||
let brewing = Brewing::load(&root, &items);
|
||||
let banners = Banners::load(&root);
|
||||
let station_recipes = StationRecipes::load(&root, &items);
|
||||
let enchantments = Enchantments::default();
|
||||
let names = Names::default();
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
station_recipes: &station_recipes,
|
||||
enchantments: &enchantments,
|
||||
names: &names,
|
||||
trade: None,
|
||||
maps: None,
|
||||
};
|
||||
let mut menu = Menu::new(Kind::Inventory);
|
||||
let cobblestone = ItemStack {
|
||||
item: "minecraft:cobblestone".into(),
|
||||
count: 16,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
// The first hotbar slot of InventoryMenu.
|
||||
menu.set_slot(&env, 36, Some(cobblestone));
|
||||
let (sent, threw) =
|
||||
super::predict_clicks(&mut menu, &env, &[(36, 0, Input::Throw)]).unwrap();
|
||||
assert!(threw, "Q over a slot drops one and swings");
|
||||
assert_eq!(menu.slot(36).map(|s| s.count), Some(15));
|
||||
assert_eq!(sent.len(), 1);
|
||||
let (_, threw) = super::predict_clicks(&mut menu, &env, &[(36, 0, Input::Pickup)]).unwrap();
|
||||
assert!(!threw, "picking the stack up drops nothing");
|
||||
let (_, threw) =
|
||||
super::predict_clicks(&mut menu, &env, &[(-999, 1, Input::Pickup)]).unwrap();
|
||||
assert!(threw, "one of the carried stack outside the window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placement_preview_checks_obstruction_without_changing_the_level() {
|
||||
use super::DryLevel;
|
||||
@@ -6624,6 +6974,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_creative_sync_covers_inventory_menu_slots_5_to_45_once_each() {
|
||||
// CreativeInventoryListener sends every InventoryMenu slot but the crafting ones: the
|
||||
// armour (5..=8), the inventory (9..=44) and the off hand (45).
|
||||
let stack = |item: &str| Some(horizon_client::items::ItemStack::new(item, 1));
|
||||
let inventory: Vec<_> = (0..36)
|
||||
.map(|i| stack(&format!("minecraft:item_{i}")))
|
||||
.collect();
|
||||
let offhand = stack("minecraft:shield");
|
||||
let armor = [
|
||||
stack("minecraft:diamond_helmet"),
|
||||
stack("minecraft:diamond_chestplate"),
|
||||
stack("minecraft:diamond_leggings"),
|
||||
stack("minecraft:diamond_boots"),
|
||||
];
|
||||
let stacks = super::creative_sync_stacks(&inventory, &offhand, &armor);
|
||||
assert_eq!(stacks.len(), super::CREATIVE_SYNCED);
|
||||
let mut slots: Vec<i16> = (0..stacks.len()).map(super::creative_sync_slot).collect();
|
||||
// Each entry goes to the menu slot that holds that stack.
|
||||
for (i, &slot) in slots.iter().enumerate() {
|
||||
let game_slot = Game::menu_slot("inventory", i32::from(slot)).unwrap();
|
||||
let expected = match game_slot {
|
||||
0..36 => &inventory[game_slot],
|
||||
504 => &offhand,
|
||||
500..504 => &armor[game_slot - 500],
|
||||
_ => panic!("entry {i} went to game slot {game_slot}"),
|
||||
};
|
||||
assert_eq!(&stacks[i], expected, "entry {i}, menu slot {slot}");
|
||||
}
|
||||
slots.sort();
|
||||
assert_eq!(slots, (5..=45).collect::<Vec<i16>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_slot_update_leaves_an_unsent_creative_click_to_be_sent() {
|
||||
let stack = |item: &str| Some(horizon_client::items::ItemStack::new(item, 1));
|
||||
let none = || vec![None; super::CREATIVE_SYNCED];
|
||||
// Synced: nothing. A click puts a helmet on (entry 37, not sent yet), then the server
|
||||
// sets hotbar slot 0 (a pickup): only that entry counts as synced.
|
||||
let mut synced = none();
|
||||
let mut before = none();
|
||||
before[37] = stack("minecraft:diamond_helmet");
|
||||
let mut now = before.clone();
|
||||
now[0] = stack("minecraft:stick");
|
||||
super::mark_synced(&mut synced, &before, now.clone());
|
||||
assert_eq!(synced[0], now[0]);
|
||||
assert_eq!(
|
||||
synced[37], None,
|
||||
"the helmet still differs, so the next tick sends it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_inventory_menu_slot_is_a_game_slot_and_45_is_the_off_hand() {
|
||||
// InventoryMenu: result, 2x2 grid, armour, 27 + 9 inventory slots, then the off hand.
|
||||
@@ -6659,4 +7061,31 @@ mod tests {
|
||||
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 504), None);
|
||||
assert_eq!(Game::menu_slot_index("unknown", 0), None);
|
||||
}
|
||||
|
||||
/// `FireworkRocketItem.use` is SUCCESS while gliding: the main hand's
|
||||
/// rocket ends the click and the off hand's shield is not raised (#3424).
|
||||
/// On foot it passes, so the off hand still gets its turn.
|
||||
#[test]
|
||||
fn a_rocket_used_while_gliding_ends_the_click() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
eprintln!("skipping: no prepared resources in {}", root.display());
|
||||
return;
|
||||
}
|
||||
let registry = horizon_world::level::data::load_registry(&root).unwrap();
|
||||
let facts = horizon_client::blocks::BlockFacts::load(&root, ®istry).unwrap();
|
||||
let items = horizon_client::items::Items::load(&root, ®istry, &facts).unwrap();
|
||||
let rocket = items.get("minecraft:firework_rocket").unwrap();
|
||||
assert!(super::use_ends_click(rocket, true), "gliding");
|
||||
assert!(!super::use_ends_click(rocket, false), "on foot");
|
||||
// Other items answer the same whether or not the player glides.
|
||||
let shield = items.get("minecraft:shield").unwrap();
|
||||
let stick = items.get("minecraft:stick").unwrap();
|
||||
for gliding in [false, true] {
|
||||
assert!(super::use_ends_click(shield, gliding));
|
||||
assert!(!super::use_ends_click(stick, gliding));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,15 @@ impl Game<'_> {
|
||||
.play("minecraft:block.respawn_anchor.charge", Some(at), 1.0, 1.0);
|
||||
Ok(())
|
||||
}
|
||||
/// `RespawnAnchorBlock.useItemOn`'s test: `held` is glowstone
|
||||
/// (`isRespawnFuel`) and the block at `pos` is an anchor that takes another
|
||||
/// charge (`canBeCharged`).
|
||||
pub(super) fn anchor_takes(&self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
held.is_some_and(|s| &*s.item == "minecraft:glowstone")
|
||||
&& self.is_anchor(state)
|
||||
&& self.anchor_charges(state) < 4
|
||||
}
|
||||
/// `RespawnAnchorBlock.useItemOn` with `held` in the hand that acts.
|
||||
pub(super) fn use_respawn_anchor(
|
||||
&mut self,
|
||||
|
||||
@@ -366,6 +366,9 @@ impl Game<'_> {
|
||||
}
|
||||
// Autosaves keep a login position; death must resolve the respawn point instead.
|
||||
self.level.position = None;
|
||||
// The dead player's Entity.setRemoved stopped its riding: the
|
||||
// new one is not put back into the vehicle.
|
||||
self.riding = None;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.ui.death_text = None;
|
||||
|
||||
@@ -1997,6 +1997,12 @@ impl Game<'_> {
|
||||
{
|
||||
push(&mut lines, text, rgba(85, 85, 255, 255));
|
||||
}
|
||||
// OminousBottleAmplifier.addToTooltip.
|
||||
if let Some((text, c)) =
|
||||
horizon_client::item_tooltip::ominous_bottle_line(stack, &display, &translate)
|
||||
{
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
// BlockItemStateProperties.addToTooltip.
|
||||
if let Some(text) = horizon_client::item_tooltip::honey_line(stack, &display, &translate) {
|
||||
push(&mut lines, text, gray);
|
||||
|
||||
@@ -2,13 +2,62 @@
|
||||
//! kept in the dropped item's `container` component) and ender chests (the
|
||||
//! player's `PlayerEnderChestContainer`, saved as `EnderItems`).
|
||||
use super::Game;
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, text::Text};
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, ChestHalf, is_chest_block},
|
||||
items::ItemStack,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
pub(super) fn is_shulker_box(name: &str) -> bool {
|
||||
name.ends_with("shulker_box")
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ServerPlayerGameMode.useItemOn` for a spectator: the block's menu
|
||||
/// (`BlockState.getMenuProvider`) opens, and nothing else happens. The
|
||||
/// lid, its sound and the `container_open` game event come from
|
||||
/// `ContainerOpenersCounter`, which spectators never join
|
||||
/// (`ChestBlockEntity.startOpen`); the stat, the piglins' anger and the
|
||||
/// shulker box's room check are `useWithoutItem`'s, which is not called.
|
||||
/// Only the block containers whose clicks the server takes back from a
|
||||
/// spectator (`handleContainerClick`) open here: storage, the furnaces,
|
||||
/// the brewing stand and the crafter.
|
||||
pub(super) fn spectator_open_menu(&mut self, pos: [i32; 3]) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let name = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.name
|
||||
.clone();
|
||||
let container = is_shulker_box(&name)
|
||||
|| matches!(
|
||||
name.trim_start_matches("minecraft:"),
|
||||
"chest"
|
||||
| "trapped_chest"
|
||||
| "barrel"
|
||||
| "hopper"
|
||||
| "dispenser"
|
||||
| "dropper"
|
||||
| "furnace"
|
||||
| "blast_furnace"
|
||||
| "smoker"
|
||||
| "brewing_stand"
|
||||
| "crafter"
|
||||
);
|
||||
// ChestBlock.getMenuProvider: none while either half is blocked.
|
||||
if !container || self.chest_menu_blocked(key, &name) {
|
||||
return;
|
||||
}
|
||||
let Some(entity) = BlockEntity::for_block(&name) else {
|
||||
return;
|
||||
};
|
||||
self.world.block_entities.get_or_insert_with(key, || entity);
|
||||
self.container = Some(key);
|
||||
self.screen = super::Screen::Inventory;
|
||||
self.capture(false);
|
||||
self.right = false;
|
||||
}
|
||||
/// The custom name of the open container, or its default title
|
||||
/// (a double chest is named by whichever half has a name).
|
||||
pub(super) fn container_title(&self, default: String) -> String {
|
||||
@@ -72,7 +121,7 @@ impl Game<'_> {
|
||||
/// with either half blocked, opens no menu.
|
||||
pub(super) fn chest_menu_blocked(&self, key: (i32, i32, i32), name: &str) -> bool {
|
||||
match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" => {
|
||||
n if is_chest_block(n) => {
|
||||
self.chest_blocked_at(key)
|
||||
|| self
|
||||
.chest_partner(key)
|
||||
@@ -96,22 +145,13 @@ impl Game<'_> {
|
||||
|
||||
// ---- chest menus: double chests, titles and shift-click ----
|
||||
|
||||
/// `Direction.getClockWise` for horizontal facings.
|
||||
fn clockwise(facing: &str) -> &'static str {
|
||||
match facing {
|
||||
"north" => "east",
|
||||
"east" => "south",
|
||||
"south" => "west",
|
||||
_ => "north",
|
||||
}
|
||||
}
|
||||
fn counter_clockwise(facing: &str) -> &'static str {
|
||||
match clockwise(facing) {
|
||||
"east" => "west",
|
||||
"south" => "north",
|
||||
"west" => "east",
|
||||
_ => "south",
|
||||
}
|
||||
/// A chest block state's half: its block, `type` and `facing`.
|
||||
fn chest_half(state: &horizon_world::chunk::BlockState) -> Option<ChestHalf<'_>> {
|
||||
Some(ChestHalf {
|
||||
block: &state.name,
|
||||
kind: state.properties.get("type")?,
|
||||
facing: state.properties.get("facing")?,
|
||||
})
|
||||
}
|
||||
fn facing_step(facing: &str) -> (i32, i32) {
|
||||
match facing {
|
||||
@@ -128,34 +168,19 @@ impl Game<'_> {
|
||||
pub(super) fn chest_partner(&self, key: (i32, i32, i32)) -> Option<((i32, i32, i32), bool)> {
|
||||
let registry = self.pack.assets.registry;
|
||||
let state = registry.state(self.world.block(key.0, key.1, key.2))?;
|
||||
if !matches!(
|
||||
state.name.as_str(),
|
||||
"minecraft:chest" | "minecraft:trapped_chest"
|
||||
) {
|
||||
if !is_chest_block(&state.name) {
|
||||
return None;
|
||||
}
|
||||
let kind = state.properties.get("type")?.as_str();
|
||||
if kind == "single" {
|
||||
return None;
|
||||
}
|
||||
let facing = state.properties.get("facing")?.as_str();
|
||||
let dir = if kind == "left" {
|
||||
clockwise(facing)
|
||||
} else {
|
||||
counter_clockwise(facing)
|
||||
};
|
||||
let (dx, dz) = facing_step(dir);
|
||||
let own = chest_half(state)?;
|
||||
let (dx, dz) = own.connected_step()?;
|
||||
let other = (key.0 + dx, key.1, key.2 + dz);
|
||||
let other_state = registry.state(self.world.block(other.0, other.1, other.2))?;
|
||||
let other_kind = other_state.properties.get("type")?.as_str();
|
||||
let paired = other_state.name == state.name
|
||||
&& other_kind != "single"
|
||||
&& other_kind != kind
|
||||
let paired = own.pairs_with(&chest_half(other_state)?)
|
||||
&& matches!(
|
||||
self.world.block_entities.get(&other),
|
||||
Some(BlockEntity::Chest(items)) if items.len() == 27
|
||||
);
|
||||
paired.then_some((other, kind == "right"))
|
||||
paired.then_some((other, own.kind == "right"))
|
||||
}
|
||||
/// The open or close sound of a container block (`ChestBlockEntity.playSound`,
|
||||
/// `BarrelBlockEntity.playSound`, `ShulkerBoxBlockEntity.startOpen`): volume
|
||||
@@ -345,12 +370,8 @@ impl Game<'_> {
|
||||
return None;
|
||||
}
|
||||
let generic = match self.container_block().as_deref() {
|
||||
Some(
|
||||
"minecraft:chest"
|
||||
| "minecraft:trapped_chest"
|
||||
| "minecraft:barrel"
|
||||
| "minecraft:ender_chest",
|
||||
) => true,
|
||||
Some("minecraft:barrel" | "minecraft:ender_chest") => true,
|
||||
Some(n) if is_chest_block(n) => true,
|
||||
_ => self.open_cart_kind() == Some(super::minecarts::CartKind::Chest),
|
||||
};
|
||||
generic.then(|| self.container_size() / 9)
|
||||
@@ -394,8 +415,15 @@ impl Game<'_> {
|
||||
256.0,
|
||||
256.0,
|
||||
);
|
||||
let title = self.translate("container.shulkerBox", "Shulker Box");
|
||||
batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
|
||||
// ShulkerBoxScreen draws the menu's title: the box's custom name
|
||||
// (BaseContainerBlockEntity.getDisplayName) or its default name.
|
||||
let title = self.remote_menu_title().unwrap_or_else(|| {
|
||||
Text::literal(
|
||||
self.container_title(self.translate("container.shulkerBox", "Shulker Box")),
|
||||
)
|
||||
.resolve_legacy()
|
||||
});
|
||||
batch.text_styled(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 73.0, grey, false);
|
||||
return;
|
||||
@@ -452,11 +480,38 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn double_chest_halves_connect_around_the_facing() {
|
||||
let half = |kind, facing| ChestHalf {
|
||||
block: "minecraft:chest",
|
||||
kind,
|
||||
facing,
|
||||
};
|
||||
// A left half facing north connects east, a right half west.
|
||||
assert_eq!(facing_step(clockwise("north")), (1, 0));
|
||||
assert_eq!(facing_step(counter_clockwise("north")), (-1, 0));
|
||||
assert_eq!(facing_step(clockwise("south")), (-1, 0));
|
||||
assert_eq!(facing_step(counter_clockwise("east")), (0, -1));
|
||||
assert_eq!(half("left", "north").connected_step(), Some((1, 0)));
|
||||
assert_eq!(half("right", "north").connected_step(), Some((-1, 0)));
|
||||
assert_eq!(half("left", "south").connected_step(), Some((-1, 0)));
|
||||
assert_eq!(half("right", "east").connected_step(), Some((0, -1)));
|
||||
assert_eq!(half("single", "east").connected_step(), None);
|
||||
}
|
||||
#[test]
|
||||
fn copper_chests_join_like_chests() {
|
||||
let half = |block, kind| ChestHalf {
|
||||
block,
|
||||
kind,
|
||||
facing: "north",
|
||||
};
|
||||
for block in [
|
||||
"minecraft:chest",
|
||||
"minecraft:trapped_chest",
|
||||
"minecraft:copper_chest",
|
||||
"minecraft:waxed_weathered_copper_chest",
|
||||
] {
|
||||
assert!(is_chest_block(block));
|
||||
assert!(half(block, "left").pairs_with(&half(block, "right")));
|
||||
}
|
||||
// ChestBlock.combine needs the same block on both halves.
|
||||
let copper = half("minecraft:copper_chest", "left");
|
||||
assert!(!copper.pairs_with(&half("minecraft:exposed_copper_chest", "right")));
|
||||
assert!(!is_chest_block("minecraft:ender_chest"));
|
||||
}
|
||||
#[test]
|
||||
fn shulker_boxes_are_recognised_by_name() {
|
||||
|
||||
@@ -465,6 +465,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// An ominous bottle's `ominous_bottle_amplifier` reaches the client
|
||||
/// through the packet's bytes, so its tooltip names the Bad Omen level.
|
||||
#[test]
|
||||
fn an_ominous_bottle_keeps_its_amplifier_across_the_wire() {
|
||||
use horizon_protocol::buf::{Codec, Reader, Writer};
|
||||
use horizon_protocol::item::Slot;
|
||||
let mut components = vec![String::new(); 80];
|
||||
components[component::OMINOUS_BOTTLE_AMPLIFIER as usize] =
|
||||
"minecraft:ominous_bottle_amplifier".into();
|
||||
let items = ["minecraft:air", "minecraft:ominous_bottle"];
|
||||
let builtin = Builtin {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:item".to_owned(),
|
||||
Ids::from_names(items.iter().map(|s| s.to_string()).collect()),
|
||||
),
|
||||
(
|
||||
"minecraft:data_component_type".to_owned(),
|
||||
Ids::from_names(components),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let dynamic = Dynamic {
|
||||
registries: Default::default(),
|
||||
};
|
||||
for amplifier in [None, Some(0), Some(3)] {
|
||||
let mut bottle = ItemStack::new("minecraft:ominous_bottle", 1);
|
||||
if let Some(a) = amplifier {
|
||||
bottle.data_mut().ominous_amplifier = Some(a);
|
||||
}
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| items.iter().position(|i| *i == name).unwrap_or(0) as i32,
|
||||
|_, _| None,
|
||||
&Some(bottle),
|
||||
);
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&wire, &mut w);
|
||||
let mut r = Reader::new(&w.data);
|
||||
let read = Slot::read(&mut r).unwrap().unwrap();
|
||||
assert_eq!(r.remaining(), 0);
|
||||
let back = from_network(&read, &builtin, &dynamic);
|
||||
assert_eq!(
|
||||
back.data().and_then(|d| d.ominous_amplifier),
|
||||
amplifier,
|
||||
"{amplifier:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a creative client sends (`stack_to_network`) comes back whole on
|
||||
/// the server: damage, both enchantment lists, repair cost, name, trim.
|
||||
#[test]
|
||||
|
||||
@@ -424,6 +424,44 @@ pub fn unbreakable_line(
|
||||
.then(|| translate("item.unbreakable").unwrap_or_else(|| "item.unbreakable".into()))
|
||||
}
|
||||
|
||||
/// `OminousBottleAmplifier.addToTooltip`: the effect the bottle gives,
|
||||
/// `MobEffectInstance(BAD_OMEN, 120000, amplifier)`, listed as
|
||||
/// `PotionContents.addPotionTooltip` lists it, in the colour of Bad Omen's
|
||||
/// category (NEUTRAL, blue). An ominous bottle without the component has the
|
||||
/// item's default amplifier, 0.
|
||||
pub fn ominous_bottle_line(
|
||||
stack: &ItemStack,
|
||||
display: &TooltipDisplay,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<(String, u32)> {
|
||||
const BAD_OMEN_TICKS: i32 = 120_000;
|
||||
let amplifier = stack
|
||||
.data()
|
||||
.and_then(|d| d.ominous_amplifier)
|
||||
.or_else(|| (&*stack.item == "minecraft:ominous_bottle").then_some(0))?;
|
||||
if !display.shows("minecraft:ominous_bottle_amplifier") {
|
||||
return None;
|
||||
}
|
||||
use crate::text::{Text, format_translation};
|
||||
let text = |key: &str, fallback: &str| translate(key).unwrap_or_else(|| fallback.to_owned());
|
||||
// PotionContents.getPotionDescription.
|
||||
let mut line = Text::literal(text(
|
||||
"effect.minecraft.bad_omen",
|
||||
"effect.minecraft.bad_omen",
|
||||
));
|
||||
if amplifier > 0 {
|
||||
let potency = translate(&format!("potion.potency.{amplifier}"))
|
||||
.unwrap_or_else(|| crate::enchant::level_name(u32::from(amplifier) + 1));
|
||||
line = format_translation(
|
||||
&text("potion.withAmplifier", "%s %s"),
|
||||
&[line, Text::literal(potency)],
|
||||
);
|
||||
}
|
||||
let duration = Text::literal(crate::brewing::format_duration(BAD_OMEN_TICKS));
|
||||
let line = format_translation(&text("potion.withDuration", "%s (%s)"), &[line, duration]);
|
||||
Some((line.plain(), rgba(85, 85, 255, 255)))
|
||||
}
|
||||
|
||||
/// `PotionContents.addPotionTooltip`'s attribute block: an empty line, "When
|
||||
/// Applied:" and the modifiers of the potion's effects (blue for a gain, red
|
||||
/// for a loss); nothing for a potion whose effects add none.
|
||||
@@ -586,6 +624,7 @@ pub fn detail_lines(
|
||||
.map(|(line, _)| line),
|
||||
);
|
||||
}
|
||||
lines.extend(ominous_bottle_line(stack, &display, translate).map(|l| l.0));
|
||||
lines
|
||||
}
|
||||
|
||||
@@ -820,6 +859,62 @@ mod tests {
|
||||
assert!(!display.shows("minecraft:attribute_modifiers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ominous_bottle_lists_bad_omen_at_its_amplifier_for_100_minutes() {
|
||||
let lang = |key: &str| {
|
||||
Some(
|
||||
match key {
|
||||
"effect.minecraft.bad_omen" => "Bad Omen",
|
||||
"potion.withAmplifier" => "%s %s",
|
||||
"potion.withDuration" => "%s (%s)",
|
||||
"potion.potency.3" => "IV",
|
||||
_ => return None,
|
||||
}
|
||||
.to_owned(),
|
||||
)
|
||||
};
|
||||
let duration = crate::brewing::format_duration(120_000);
|
||||
let blue = rgba(85, 85, 255, 255);
|
||||
// The item's default amplifier, 0: no potency.
|
||||
let mut bottle = ItemStack::new("minecraft:ominous_bottle", 1);
|
||||
let line = |s: &ItemStack| ominous_bottle_line(s, &TooltipDisplay::for_stack(s), &lang);
|
||||
assert_eq!(
|
||||
line(&bottle),
|
||||
Some((format!("Bad Omen ({duration})"), blue))
|
||||
);
|
||||
bottle.data_mut().ominous_amplifier = Some(3);
|
||||
assert_eq!(
|
||||
line(&bottle),
|
||||
Some((format!("Bad Omen IV ({duration})"), blue))
|
||||
);
|
||||
assert!(
|
||||
lines(&bottle, None, &lang).contains(&format!("Bad Omen IV ({duration})")),
|
||||
"{:?}",
|
||||
lines(&bottle, None, &lang)
|
||||
);
|
||||
// A numbered template, as some languages write it.
|
||||
let numbered = |key: &str| match key {
|
||||
"potion.withDuration" => Some("%2$s: %1$s".to_owned()),
|
||||
other => lang(other),
|
||||
};
|
||||
assert_eq!(
|
||||
ominous_bottle_line(&bottle, &TooltipDisplay::for_stack(&bottle), &numbered)
|
||||
.map(|l| l.0),
|
||||
Some(format!("{duration}: Bad Omen IV"))
|
||||
);
|
||||
bottle.data_mut().set_other(
|
||||
"minecraft:tooltip_display",
|
||||
Some(Tag::compound([(
|
||||
"hidden_components",
|
||||
Tag::List(vec![Tag::String(
|
||||
"minecraft:ominous_bottle_amplifier".into(),
|
||||
)]),
|
||||
)])),
|
||||
);
|
||||
assert_eq!(line(&bottle), None);
|
||||
assert_eq!(line(&ItemStack::new("minecraft:glass_bottle", 1)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ominous_banner_keeps_its_default_hidden_pattern_tooltip() {
|
||||
let mut banner = crate::mobs::raider::ominous_banner();
|
||||
|
||||
@@ -311,6 +311,24 @@ impl Hub {
|
||||
g.send(&packet);
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.sendAllDataToRemote` for the guest's open
|
||||
/// block container `window`: every slot and the carried stack again.
|
||||
pub fn resend_menu(&mut self, conn: u32, window: i32, world: &World) {
|
||||
let Some(i) = self.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let g = &self.guests[i];
|
||||
let Some(items) = g
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|m| window != 0 && m.window == window)
|
||||
.and_then(|m| m.slots.read(world, g))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.send_menu_content(i, items);
|
||||
}
|
||||
|
||||
fn close_container_at(&mut self, i: usize) {
|
||||
let g = &mut self.guests[i];
|
||||
if let Some(menu) = g.menu.take() {
|
||||
@@ -378,7 +396,13 @@ impl Hub {
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.window == p.container);
|
||||
if !window_ok || self.guests[i].spectator() {
|
||||
if !window_ok {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.guests[i].spectator() {
|
||||
// A spectator's click changes nothing, and sendAllDataToRemote
|
||||
// takes back what its client predicted.
|
||||
self.resend_menu(conn, p.container, world);
|
||||
return Vec::new();
|
||||
}
|
||||
// A menu that is no longer valid takes no clicks.
|
||||
|
||||
@@ -378,6 +378,22 @@ impl Guest {
|
||||
pub fn send(&self, packet: &play::Clientbound) {
|
||||
self.sender.send(packet.encode());
|
||||
}
|
||||
/// `Entity.stopRiding`: out of the vehicle it rode.
|
||||
pub fn stop_riding(&mut self) {
|
||||
(self.vehicle, self.vehicle_good) = (None, None);
|
||||
}
|
||||
/// `LivingEntity.tickDeath` for a dead player: true once its body is
|
||||
/// removed, 20 ticks after it died (`remove(KILLED)`, whose
|
||||
/// `Entity.setRemoved` stops its riding). The body sits in its vehicle
|
||||
/// until then.
|
||||
pub fn tick_death(&mut self) -> bool {
|
||||
self.death_time += 1;
|
||||
let removed = self.death_time >= 20;
|
||||
if removed {
|
||||
self.stop_riding();
|
||||
}
|
||||
removed
|
||||
}
|
||||
pub fn send_all(&self, packets: &[play::Clientbound]) {
|
||||
for p in packets {
|
||||
self.send(p);
|
||||
@@ -667,6 +683,12 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Int(i32::from(mode)),
|
||||
));
|
||||
}
|
||||
if let Some(amplifier) = d.ominous_amplifier {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::OMINOUS_BOTTLE_AMPLIFIER,
|
||||
horizon_protocol::item::Component::Int(i32::from(amplifier)),
|
||||
));
|
||||
}
|
||||
stack.added.extend(firework_components(d));
|
||||
// The compass's lodestone target.
|
||||
if let Some(t) = d.other("minecraft:lodestone_tracker") {
|
||||
@@ -846,6 +868,23 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_player_leaves_its_vehicle_when_its_body_is_removed() {
|
||||
let mut g = guest_at([0.5, -120.0, 0.5]);
|
||||
g.vehicle = Some(7);
|
||||
g.vehicle_good = Some([0.5, -120.0, 0.5]);
|
||||
g.dead = true;
|
||||
// LivingEntity.tickDeath: the body sits in the boat for 19 ticks...
|
||||
for tick in 1..20 {
|
||||
assert!(!g.tick_death(), "tick {tick}");
|
||||
assert_eq!(g.vehicle, Some(7), "tick {tick}");
|
||||
}
|
||||
// ...then remove(KILLED)'s setRemoved stops its riding, so the
|
||||
// respawned player is not put back into the boat.
|
||||
assert!(g.tick_death());
|
||||
assert_eq!((g.vehicle, g.vehicle_good), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_action_must_be_within_reach() {
|
||||
let mut g = guest_at([0.5, 64.0, 0.5]);
|
||||
|
||||
@@ -2552,6 +2552,10 @@ impl Hub {
|
||||
let g = &mut self.guests[i];
|
||||
// PlayerList.respawn: into the level in place (the respawn level).
|
||||
g.dimension = self.config.dimension.clone();
|
||||
// ServerLevel.removePlayerImmediately: the dead player's
|
||||
// Entity.setRemoved (KILLED) stops its riding; the new player rides
|
||||
// nothing.
|
||||
g.stop_riding();
|
||||
// ServerLevel.removePlayerImmediately: every entity paired with the dead player is
|
||||
// removed (ServerEntity.removePairing, one packet each). Its chunks are dropped
|
||||
// without ForgetLevelChunk (PlayerChunkSender.dropChunk skips dead players): the
|
||||
|
||||
@@ -771,6 +771,8 @@ pub struct Extra {
|
||||
pub main_hand_left: bool,
|
||||
pub elytra: ElytraAnimation,
|
||||
pub swim: SwimAnimation,
|
||||
/// `ClientAvatarState` of a player: the cloak, bob and walked distance its cape swings with.
|
||||
pub avatar: crate::camera::AvatarBob,
|
||||
/// The arm the current swing moves (`currentSwing.hand().asArm`): `Some(true)`
|
||||
/// for the right arm.
|
||||
pub swing_right_arm: Option<bool>,
|
||||
@@ -965,6 +967,9 @@ pub struct Mob {
|
||||
pub skin: Option<String>,
|
||||
/// `PlayerSkin.model` is `SLIM`: the player model with 3-pixel arms.
|
||||
pub slim: bool,
|
||||
/// `PlayerSkin.cape`: a player's cape texture, once every download of
|
||||
/// its skin is ready.
|
||||
pub cape: Option<String>,
|
||||
/// A mannequin's `DATA_PROFILE` (ResolvableProfile): the profile's
|
||||
/// `textures` property resolves to `skin` like a player's.
|
||||
pub profile: Option<horizon_protocol::entity_data::Profile>,
|
||||
@@ -1963,6 +1968,7 @@ impl Mob {
|
||||
outbox: Vec::new(),
|
||||
skin: None,
|
||||
slim: false,
|
||||
cape: None,
|
||||
profile: None,
|
||||
attrs: Attrs {
|
||||
speed: kind.speed,
|
||||
@@ -2734,6 +2740,8 @@ impl Mob {
|
||||
/// player from its synched pose and flags: the glide count, the wings
|
||||
/// (`elytraAnimationState`) and the swim amount (`updateSwimAmount`).
|
||||
pub fn tick_avatar_pose(&mut self, in_water: bool) {
|
||||
let (p, v) = (self.previous, self.velocity);
|
||||
let on_ground_alive = self.on_ground && self.alive();
|
||||
let e = &mut self.extra;
|
||||
e.fall_fly_ticks = if e.fall_flying {
|
||||
e.fall_fly_ticks + 1
|
||||
@@ -2745,6 +2753,17 @@ impl Mob {
|
||||
let visually = e.synced_pose == 3 || (!e.fall_flying && e.synced_pose == 1);
|
||||
e.elytra.tick(e.fall_flying, e.crouching, self.velocity);
|
||||
e.swim.tick(visually, in_water);
|
||||
// AbstractClientPlayer.tick's ClientAvatarState.tick (from where the
|
||||
// player was before this tick's move), RemotePlayer.aiStep's
|
||||
// updateBob and rideTick's resetBob.
|
||||
e.avatar.tick([p.x, p.y, p.z]);
|
||||
if e.passenger {
|
||||
e.avatar.reset_bob();
|
||||
} else {
|
||||
let swimming = e.synced_pose == 3;
|
||||
e.avatar
|
||||
.update_bob(on_ground_alive && !swimming, [v.x, v.y, v.z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -952,9 +952,10 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
|
||||
m.ai.xxa *= 0.98;
|
||||
m.ai.zza *= 0.98;
|
||||
// LivingEntity.isImmobile: no goals or input (a biting, stunned or
|
||||
// roaring ravager). A mannequin is an Avatar, not a Mob: LivingEntity's
|
||||
// roaring ravager, an eating or rearing horse). A mannequin is an Avatar, not a Mob: LivingEntity's
|
||||
// serverAiStep has no goals, so it only falls and is pushed.
|
||||
let immobile = (m.species() == Species::Ravager && super::ravager::immobile(m))
|
||||
|| super::equine::immobile(m)
|
||||
|| m.species() == Species::Mannequin;
|
||||
if m.species() == Species::Piglin && m.alive() {
|
||||
super::piglin::celebrate(m, w);
|
||||
|
||||
@@ -70,13 +70,22 @@ pub fn max_temper(s: Species) -> i32 {
|
||||
100
|
||||
}
|
||||
}
|
||||
/// `canPerformRearing`: not donkeys or mules.
|
||||
/// `canPerformRearing`: every `AbstractHorse` but llamas (and camels).
|
||||
fn can_rear(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Horse | Species::SkeletonHorse | Species::ZombieHorse
|
||||
Species::Horse
|
||||
| Species::Donkey
|
||||
| Species::Mule
|
||||
| Species::SkeletonHorse
|
||||
| Species::ZombieHorse
|
||||
)
|
||||
}
|
||||
/// `AbstractHorse.isImmobile`: no goals while it eats or rears. A mount its
|
||||
/// rider steers still takes their input (`travelRidden`).
|
||||
pub fn immobile(m: &Mob) -> bool {
|
||||
can_rear(m.species()) && (m.extra.flag || m.extra.counter > 0) && !controlled(m)
|
||||
}
|
||||
pub fn register(m: &mut Mob) {
|
||||
if super::camel::is_camel(m.species()) {
|
||||
return super::camel::register(m);
|
||||
@@ -283,7 +292,7 @@ impl Goal for RunAroundLikeCrazy {
|
||||
make_mad(m, cx.events);
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.makeMad`: rear with the angry sound.
|
||||
/// `AbstractHorse.makeMad`: rear with the angry sound (`makeSound`).
|
||||
pub fn make_mad(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
if m.extra.counter > 0 {
|
||||
return;
|
||||
@@ -304,6 +313,57 @@ pub fn make_mad(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
};
|
||||
m.voice(sound, events);
|
||||
}
|
||||
/// What `Horse`, `AbstractChestedHorse` (donkeys, mules, llamas) and
|
||||
/// `ZombieHorse.mobInteract` do with a held item before
|
||||
/// `AbstractHorse.mobInteract`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HeldItem {
|
||||
/// `isFood`: `fedFood`, whether it eats or not.
|
||||
Feed,
|
||||
/// Untamed: `makeMad` instead of taking the rider.
|
||||
Refused,
|
||||
}
|
||||
/// That branch for a click with `item` (empty for none): no rider, not
|
||||
/// opening a tamed adult's inventory (`sneaking`), not a baby while the
|
||||
/// player holds a golden dandelion (`dandelion`; zombie horses skip that
|
||||
/// check). An untamed mount is made mad here.
|
||||
pub fn held_item(
|
||||
m: &mut Mob,
|
||||
item: &str,
|
||||
sneaking: bool,
|
||||
dandelion: bool,
|
||||
events: &mut Vec<MobEvent>,
|
||||
) -> Option<HeldItem> {
|
||||
let species = m.species();
|
||||
let checks_dandelion = match species {
|
||||
Species::Horse | Species::Donkey | Species::Mule => true,
|
||||
Species::Llama | Species::TraderLlama => true,
|
||||
Species::ZombieHorse => false,
|
||||
_ => return None,
|
||||
};
|
||||
let open_inventory = !m.is_baby() && m.extra.tame && sneaking;
|
||||
if m.rider.is_some()
|
||||
|| open_inventory
|
||||
|| (checks_dandelion && m.is_baby() && dandelion)
|
||||
|| item.is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// ZombieHorse.isFood: the zombie_horse_food tag.
|
||||
let food = if species == Species::ZombieHorse {
|
||||
item == "minecraft:red_mushroom"
|
||||
} else {
|
||||
m.kind.food.iter().any(|f| f == item)
|
||||
};
|
||||
if food {
|
||||
return Some(HeldItem::Feed);
|
||||
}
|
||||
if m.extra.tame {
|
||||
return None;
|
||||
}
|
||||
make_mad(m, events);
|
||||
Some(HeldItem::Refused)
|
||||
}
|
||||
/// `RandomStandGoal`.
|
||||
#[derive(Clone, Debug)]
|
||||
struct RandomStand {
|
||||
@@ -318,7 +378,7 @@ impl Goal for RandomStand {
|
||||
self.next += 1;
|
||||
if self.next > 0 && m.random.next_int_bound(1000).unwrap_or(1000) < self.next {
|
||||
self.next = -m.kind.ambient_interval;
|
||||
return m.random.next_int_bound(10).unwrap_or(1) == 0;
|
||||
return !immobile(m) && m.random.next_int_bound(10).unwrap_or(1) == 0;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1229,6 +1229,48 @@ pub fn render_with(
|
||||
out,
|
||||
);
|
||||
}
|
||||
/// `CapeLayer.submit` with `PlayerCapeModel`: the player model's body posed
|
||||
/// as the player's, its cape turned by `ModelPart.rotateBy` with the cape's
|
||||
/// flap and leans. `base` is the player's root transform (moved out over a
|
||||
/// chestplate); `uv` the cape texture.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn cape(
|
||||
m: &Mob,
|
||||
alpha: f32,
|
||||
base: glam::Mat4,
|
||||
uv: [f32; 4],
|
||||
light: u32,
|
||||
data: &MobData,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Some(model) = m.kind.layer("player#cape") else {
|
||||
return;
|
||||
};
|
||||
let s = anim::state(m, alpha);
|
||||
let mut poses = Poses::new(model);
|
||||
anim::setup(m, &s, &mut poses, &data.animations);
|
||||
let a = f64::from(alpha);
|
||||
let at = [
|
||||
m.previous.x + (m.position.x - m.previous.x) * a,
|
||||
m.previous.y + (m.position.y - m.previous.y) * a,
|
||||
m.previous.z + (m.position.z - m.previous.z) * a,
|
||||
];
|
||||
let body = m.previous_body_yaw + wrap(m.body_yaw - m.previous_body_yaw) * alpha;
|
||||
// LivingEntityRenderState.fallFlyingScale.
|
||||
let ticks = m.extra.fall_fly_ticks as f32 + alpha;
|
||||
let gliding = (ticks * ticks / 100.0).clamp(0.0, 1.0);
|
||||
let [flap, lean, lean2] = m.extra.avatar.cape(alpha, at, body, gliding);
|
||||
let pose = poses.get("cape");
|
||||
let by = glam::Quat::from_rotation_y(-PI)
|
||||
* glam::Quat::from_rotation_x((6.0 + lean / 2.0 + flap).to_radians())
|
||||
* glam::Quat::from_rotation_z((lean2 / 2.0).to_radians())
|
||||
* glam::Quat::from_rotation_y((180.0 - lean2 / 2.0).to_radians());
|
||||
let now = glam::Quat::from_euler(glam::EulerRot::ZYX, pose[5], pose[4], pose[3]);
|
||||
let (z, y, x) = (now * by).to_euler(glam::EulerRot::ZYX);
|
||||
(pose[3], pose[4], pose[5]) = (x, y, z);
|
||||
// RenderTypes.entitySolid with OverlayTexture.NO_OVERLAY.
|
||||
draw(model, base, &poses, uv, 0xFFFF_FFFF, light, 0, out);
|
||||
}
|
||||
/// A mob drawn under `origin` instead of at its position (the spinning mob
|
||||
/// of `SpawnerRenderer.submitEntityInSpawner`).
|
||||
pub fn render_posed(
|
||||
|
||||
@@ -241,12 +241,20 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
/// `WitherBoss.customServerAiStep`: charging, then the side heads' targets
|
||||
/// and volleys, block breaking and healing.
|
||||
pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.extra.point = m
|
||||
.target_info(w)
|
||||
.map(|t| [t.position.x, t.position.y, t.position.z]);
|
||||
if m.extra.counter > 0 {
|
||||
let left = m.extra.counter - 1;
|
||||
if left <= 0 {
|
||||
// `level.explode(this, ...)`: the wither is the blast's source, so
|
||||
// its entity query (`getEntities(source, box)`) leaves it out.
|
||||
let name = match &m.custom_name {
|
||||
Some(name) => crate::text::Text::literal(name.clone()),
|
||||
None => crate::text::Text::translate("entity.minecraft.wither", &[]),
|
||||
};
|
||||
events.push(MobEvent::ExplosionSource {
|
||||
mob: m.id,
|
||||
charged: false,
|
||||
name,
|
||||
});
|
||||
events.push(MobEvent::Explode(
|
||||
[m.position.x, m.eye_y(), m.position.z],
|
||||
7.0,
|
||||
@@ -268,6 +276,11 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEven
|
||||
}
|
||||
return;
|
||||
}
|
||||
// `setAlternativeTarget(0, ...)` once charged: `aiStep` flies toward this
|
||||
// target only after the charge, never during it.
|
||||
m.extra.point = m
|
||||
.target_info(w)
|
||||
.map(|t| [t.position.x, t.position.y, t.position.z]);
|
||||
// The side heads pick targets and fire on their own.
|
||||
let age = m.age as i32;
|
||||
for i in 1..3 {
|
||||
|
||||
@@ -3566,6 +3566,40 @@ mod tests {
|
||||
w.player.count_down_server_spin();
|
||||
assert_eq!(w.player.auto_spin_attack_ticks, 0);
|
||||
}
|
||||
/// #3308: a swimmer who boards a boat stops swimming
|
||||
/// (`Entity.updateSwimming` needs `!isPassenger()`) and `updatePlayerPose`
|
||||
/// sits them up, eye at 1.62 instead of the swim's 0.4.
|
||||
#[test]
|
||||
fn a_passenger_stops_swimming_and_stands() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 10.0, 20.5)).unwrap();
|
||||
player.sprinting = true;
|
||||
player.swimming = true;
|
||||
player.set_pose(crate::pose::Pose::Swimming).unwrap();
|
||||
assert_eq!(player.dimensions.eye_height, 0.4);
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
w.passenger_pose_tick().unwrap();
|
||||
assert!(!w.player.swimming);
|
||||
assert_eq!(w.player.pose, crate::pose::Pose::Standing);
|
||||
assert_eq!(w.player.dimensions.eye_height, 1.62);
|
||||
}
|
||||
#[test]
|
||||
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -167,4 +167,12 @@ impl MotionWorld<'_, '_, '_> {
|
||||
};
|
||||
self.player.set_pose(pose)
|
||||
}
|
||||
/// The pose part of `Player.tick` for a passenger (`Entity.rideTick` runs
|
||||
/// `tick`, not the travel): `Entity.updateSwimming` stops a swim, since it
|
||||
/// needs `!isPassenger()`, and `updatePlayerPose` follows. A player who
|
||||
/// boards a boat while swimming sits up instead of lying under it (#3308).
|
||||
pub fn passenger_pose_tick(&mut self) -> Result<()> {
|
||||
self.player.swimming = false;
|
||||
self.update_player_pose()
|
||||
}
|
||||
}
|
||||
@@ -709,3 +709,97 @@ fn guests_see_each_other_and_the_world_entities() {
|
||||
first.join().unwrap().unwrap();
|
||||
drop(second);
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleContainerClick`: a spectator looks
|
||||
/// into a chest but its clicks change nothing, and `sendAllDataToRemote`
|
||||
/// takes back what its client predicted.
|
||||
#[test]
|
||||
fn a_spectators_container_click_is_taken_back() {
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, lan::menu::Slots};
|
||||
struct NoRules;
|
||||
impl horizon_client::lan::menu::Rules for NoRules {
|
||||
fn items(&self) -> &horizon_client::items::Items {
|
||||
unreachable!("a spectator's click never reaches the menu")
|
||||
}
|
||||
fn max_stack(&self, _: &str) -> u32 {
|
||||
64
|
||||
}
|
||||
fn armor_index(&self, _: &str) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let Some(resources) = resources() else {
|
||||
return;
|
||||
};
|
||||
let mut s = server(&resources);
|
||||
let port = s.hub.lan.port;
|
||||
let mut bot = run(&mut s, move || connect(port, "Watcher", true));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while s.hub.guests.is_empty() {
|
||||
s.tick(&[]);
|
||||
assert!(Instant::now() < deadline, "guest did not join");
|
||||
thread::yield_now();
|
||||
}
|
||||
let conn = s.hub.guests[0].conn;
|
||||
s.hub.set_game_mode(conn, 3);
|
||||
let at = s.hub.guests[0].position;
|
||||
let key = (
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
at[2].floor() as i32 + 2,
|
||||
);
|
||||
s.world.set(key.0, key.1, key.2, BlockStateId(2)).unwrap();
|
||||
let mut items = vec![None; 27];
|
||||
items[0] = Some(ItemStack::new("minecraft:diamond", 5));
|
||||
s.world
|
||||
.block_entities
|
||||
.insert(key, BlockEntity::Chest(items.clone()));
|
||||
s.hub.open_container(
|
||||
conn,
|
||||
"minecraft:generic_9x3",
|
||||
horizon_protocol::text::translatable("container.chest", &[]),
|
||||
key,
|
||||
Slots::Block(key),
|
||||
items.clone(),
|
||||
);
|
||||
let window = s.hub.guests[0].menu.as_ref().unwrap().window;
|
||||
// The menu's own contents, as the client first sees them.
|
||||
bot.c
|
||||
.reader
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let content = move |bot: &mut Bot| -> Result<(), String> {
|
||||
loop {
|
||||
if let C::ContainerSetContent(p) = bot.recv_play()?
|
||||
&& p.container == window
|
||||
{
|
||||
assert!(p.items[0].is_some(), "the diamonds are still in slot 0");
|
||||
assert!(p.carried.is_none(), "nothing is carried");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut bot = run(&mut s, move || content(&mut bot).map(|()| bot));
|
||||
// The client predicted picking up the diamonds.
|
||||
let click = play::ContainerClick {
|
||||
container: window,
|
||||
state: 1,
|
||||
slot: 0,
|
||||
button: 0,
|
||||
input: 0,
|
||||
changed: vec![(0, None)],
|
||||
carried: None,
|
||||
};
|
||||
let drops = s.hub.container_click(conn, &click, &mut s.world, &NoRules);
|
||||
assert!(drops.is_empty());
|
||||
assert_eq!(
|
||||
s.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.map(|b| b.slots()[0].clone()),
|
||||
Some(items[0].clone()),
|
||||
"the chest keeps its diamonds"
|
||||
);
|
||||
assert!(s.hub.guests[0].carried.is_none());
|
||||
run(&mut s, move || content(&mut bot));
|
||||
}
|
||||
@@ -47,6 +47,7 @@ pub mod component {
|
||||
pub const FIREWORK_EXPLOSION: i32 = 70;
|
||||
pub const FIREWORKS: i32 = 71;
|
||||
pub const BLOCK_ENTITY_DATA: i32 = 62;
|
||||
pub const OMINOUS_BOTTLE_AMPLIFIER: i32 = 65;
|
||||
pub const CONTAINER: i32 = 77;
|
||||
}
|
||||
|
||||
@@ -183,8 +184,14 @@ impl Component {
|
||||
pub(crate) fn read(kind: i32, r: &mut Reader) -> Result<Self> {
|
||||
use component::*;
|
||||
Ok(match kind {
|
||||
MAX_STACK_SIZE | MAX_DAMAGE | DAMAGE | RARITY | REPAIR_COST | MAP_ID
|
||||
| MAP_POST_PROCESSING => Self::Int(r.varint()?),
|
||||
MAX_STACK_SIZE
|
||||
| MAX_DAMAGE
|
||||
| DAMAGE
|
||||
| RARITY
|
||||
| REPAIR_COST
|
||||
| MAP_ID
|
||||
| MAP_POST_PROCESSING
|
||||
| OMINOUS_BOTTLE_AMPLIFIER => Self::Int(r.varint()?),
|
||||
WRITABLE_BOOK_CONTENT => {
|
||||
let n = r.count(100)?;
|
||||
Self::WritableBook(
|
||||
@@ -862,6 +869,15 @@ mod tests {
|
||||
book.added
|
||||
.push((component::MAP_POST_PROCESSING, Component::Int(1)));
|
||||
round::<Option<Stack>, Slot>(Some(book));
|
||||
// OminousBottleAmplifier.STREAM_CODEC: a VarInt.
|
||||
let mut bottle = Stack::new(1200, 1);
|
||||
bottle
|
||||
.added
|
||||
.push((component::OMINOUS_BOTTLE_AMPLIFIER, Component::Int(3)));
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&Some(bottle.clone()), &mut w);
|
||||
assert_eq!(w.data, [1, 0xb0, 0x09, 1, 0, 65, 3]);
|
||||
round::<Option<Stack>, Slot>(Some(bottle));
|
||||
let mut quill = Stack::new(1099, 1);
|
||||
quill.added.push((
|
||||
component::WRITABLE_BOOK_CONTENT,
|
||||
|
||||
@@ -697,6 +697,26 @@ impl Behaviours {
|
||||
if self.data(state).chain.contains(&Class::CopperGolemStatue) {
|
||||
return self.turn_statue(level, ticks, pos, state, item);
|
||||
}
|
||||
// RespawnAnchorBlock.useItemOn: glowstone (`isRespawnFuel`) charges an
|
||||
// anchor below 4 charges (`canBeCharged`) and the click ends there.
|
||||
if item == Some("minecraft:glowstone")
|
||||
&& self.data(state).chain.contains(&Class::RespawnAnchor)
|
||||
&& self.table.int(state, self.props.charges) < 4
|
||||
{
|
||||
// RespawnAnchorBlock.charge.
|
||||
let charges = self.table.int(state, self.props.charges);
|
||||
let next = self.table.set_int(state, self.props.charges, charges + 1);
|
||||
let mut sim = self.sim(level, ticks);
|
||||
sim.set_block_and_update(pos, next);
|
||||
sim.game_event_state(pos, "minecraft:block_change", next);
|
||||
sim.sound(
|
||||
pos,
|
||||
&"minecraft:block.respawn_anchor.charge".into(),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
return InteractionResult::Success;
|
||||
}
|
||||
// NoteBlock.useItemOn: a mob head on the top face is placed, not tuned
|
||||
// (`#minecraft:noteblock_top_instruments`).
|
||||
if face == Direction::Up
|
||||
|
||||
@@ -639,6 +639,55 @@ impl World<'_> {
|
||||
player,
|
||||
)
|
||||
}
|
||||
/// Right-click the north face of `pos` holding `item` in the main hand.
|
||||
fn use_item(&mut self, pos: BlockPos, item: &str) -> InteractionResult {
|
||||
let player = PlayerContext {
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
may_build: true,
|
||||
can_eat: true,
|
||||
game_master: false,
|
||||
};
|
||||
let hit = horizon_core::geometry::Vec3::new(
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 0.5,
|
||||
f64::from(pos.z),
|
||||
);
|
||||
self.f.behaviours.use_item_on(
|
||||
&mut self.level,
|
||||
&mut self.ticks,
|
||||
pos,
|
||||
Direction::North,
|
||||
hit,
|
||||
player,
|
||||
Some(item),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glowstone_charges_a_respawn_anchor_up_to_four() {
|
||||
// RespawnAnchorBlock.useItemOn: glowstone on an anchor below 4 charges
|
||||
// charges it and the click ends (SUCCESS), so no glowstone is placed (#3034).
|
||||
let mut w = world!();
|
||||
let pos = BlockPos::new(0, 1, 0);
|
||||
let empty = w.id("respawn_anchor", &[("charges", "0")]);
|
||||
w.put(pos, empty);
|
||||
for charges in 1..=4 {
|
||||
assert_eq!(
|
||||
w.use_item(pos, "minecraft:glowstone"),
|
||||
InteractionResult::Success
|
||||
);
|
||||
assert_eq!(w.prop(pos, "charges"), charges.to_string());
|
||||
}
|
||||
// A full anchor can't be charged: TRY_WITH_EMPTY_HAND, then useWithoutItem
|
||||
// (the respawn point), which leaves the charges alone.
|
||||
w.use_item(pos, "minecraft:glowstone");
|
||||
assert_eq!(w.prop(pos, "charges"), "4");
|
||||
// Anything else on an empty anchor passes, so the item is used instead.
|
||||
w.put(pos, empty);
|
||||
assert_eq!(w.use_item(pos, "minecraft:stone"), InteractionResult::Pass);
|
||||
assert_eq!(w.prop(pos, "charges"), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+7
-3
@@ -21,11 +21,11 @@ Every row has a one-line description and its cost in frame time (none, low, medi
|
||||
| Quality | smooth lighting, mipmap levels, biome blend, clouds, cloud distance, weather radius, particles, entity shadows, cutout leaves | `options.txt` |
|
||||
| | sky, fog, weather, animated textures | `horizon-graphics.txt` |
|
||||
| Display | FOV, max framerate, VSync, fullscreen, GUI scale | `options.txt` |
|
||||
| | resolution scale, anti-aliasing (FXAA), HDR, paper white, peak brightness, present mode, frame pacing | `horizon-graphics.txt` |
|
||||
| | resolution scale, anti-aliasing (FXAA or TAA), TAA upscaling, HDR, paper white, peak brightness, present mode, frame pacing | `horizon-graphics.txt` |
|
||||
| Performance | battery mode, worker threads, upload budget per frame | `horizon-graphics.txt` |
|
||||
| Overlay | performance overlay: FPS, frame/CPU/GPU times, frame-time graph | `horizon-graphics.txt` |
|
||||
|
||||
`options.txt` keeps exactly vanilla's keys and values, so the options harness still compares it with vanilla; Horizon's settings live in `horizon-graphics.txt` beside it. Their defaults render as vanilla does: cave culling on, no upload budgets, sky, fog and weather on, 100% resolution, no FXAA, HDR off, the present mode following VSync. VSync on always waits for the display (Fifo), in a window and in fullscreen, as in vanilla; the present mode setting chooses how frames are shown only while VSync is off. Frame pacing defaults to Efficient: the limiter sleeps through the last half millisecond where vanilla spins, which changes timing, not pixels.
|
||||
`options.txt` keeps exactly vanilla's keys and values, so the options harness still compares it with vanilla; Horizon's settings live in `horizon-graphics.txt` beside it. Their defaults render as vanilla does: cave culling on, no upload budgets, sky, fog and weather on, 100% resolution, no anti-aliasing, HDR off, the present mode following VSync. VSync on always waits for the display (Fifo), in a window and in fullscreen, as in vanilla; the present mode setting chooses how frames are shown only while VSync is off. Frame pacing defaults to Efficient: the limiter sleeps through the last half millisecond where vanilla spins, which changes timing, not pixels.
|
||||
|
||||
**Battery mode** (Performance, off by default) is for laptops and handhelds: at most 60 frames per second in a world, 30 in menus without a world or while another window has focus, and half the automatic worker threads (chunks load slower). It changes only when frames are drawn: what they show and the game's 20 ticks per second stay as they are.
|
||||
|
||||
@@ -49,7 +49,11 @@ Ordinary Overworld atmospheric fog extends with the selected LOD distance. Fog O
|
||||
|
||||
The setting is stored as `lodDistance` in `horizon-graphics.txt`; `0` disables it. The official Far View mod (`mods/official/far-view`, [mods.md](mods.md)) turns the same distant terrain on from the mod browser, with its own distance and detail; the setting, when on, wins. Existing `renderDistance` in `options.txt` keeps its normal meaning for full chunks.
|
||||
|
||||
Not implemented: anisotropic filtering (wgpu needs linear magnification for it, which blurs close blocks), MSAA (FXAA instead) and exclusive fullscreen (fullscreen is borderless).
|
||||
Anti-aliasing cycles Off, FXAA and TAA (`fxaa` and `taa` in `horizon-graphics.txt`). TAA jitters the world's projection by a sub-pixel offset each frame (Halton 2, 3 over 8 frames) and blends the frame into a history reprojected through the depth buffer, clamped to the frame's 3×3 neighbourhood. Its motion vectors come from the camera alone, so mobs and other moving things rely on the clamp and can trail slightly. It runs at the resolution scale, before the hand and HUD, and is off under a shader pack, which does its own.
|
||||
|
||||
Upscaling (`upscaling` in `horizon-graphics.txt`) renders the world smaller than the resolution scale's size and lets TAA rebuild it at that size: Native 1×, Quality 1.5×, Balanced 1.7×, Performance 2× and Ultra Performance 3× fewer pixels along each axis (FSR's and DLSS's ratios). The jitter then runs over 8 frames per output pixel, and each frame counts in an output pixel by how near its sample lands. The button shows only while Anti-aliasing is TAA; leaving TAA goes back to Native. Textures keep the mipmap level of the smaller frame, so far blocks are softer than at Native.
|
||||
|
||||
Not implemented: anisotropic filtering (wgpu needs linear magnification for it, which blurs close blocks), MSAA (FXAA or TAA instead) and exclusive fullscreen (fullscreen is borderless).
|
||||
|
||||
## Benchmark
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.PrintWriter;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.commands.arguments.blocks.BlockStateParser;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.world.level.LevelAccessor;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.ChestBlock;
|
||||
import net.minecraft.world.level.block.DoubleBlockCombiner;
|
||||
import net.minecraft.world.level.block.EntityBlock;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/**
|
||||
* Original oracle: which chests join into a double chest. Each case line is
|
||||
* JSON: a chest's block state ("state") at the origin and another block state
|
||||
* ("other") at the horizontal offset "at" ([dx, dz]); each holds the block
|
||||
* entity its block makes (EntityBlock.newBlockEntity). Runs
|
||||
* DoubleBlockCombiner.combineWithNeigbour as ChestBlock.combine(state, level,
|
||||
* pos, true) calls it (the block's blockEntityType, ChestBlock.getBlockType,
|
||||
* ChestBlock.getConnectedDirection, FACING; nothing blocked) on a stand-in
|
||||
* level, and prints "double" with the first (right) half's position and the
|
||||
* second's, or "single".
|
||||
*/
|
||||
public final class VanillaChestPairOracle {
|
||||
static BlockState parse(String s) throws Exception {
|
||||
return BlockStateParser.parseForBlock(BuiltInRegistries.BLOCK, s, false).blockState();
|
||||
}
|
||||
|
||||
static String xz(BlockPos p) {
|
||||
return p.getX() + "," + p.getZ();
|
||||
}
|
||||
|
||||
static String pair(JsonObject c) throws Exception {
|
||||
BlockState state = parse(c.get("state").getAsString());
|
||||
BlockState other = parse(c.get("other").getAsString());
|
||||
var at = c.getAsJsonArray("at");
|
||||
BlockPos pos = BlockPos.ZERO;
|
||||
BlockPos otherPos = pos.offset(at.get(0).getAsInt(), 0, at.get(1).getAsInt());
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
Map<BlockPos, BlockEntity> entities = new HashMap<>();
|
||||
blocks.put(pos, state);
|
||||
blocks.put(otherPos, other);
|
||||
for (var e : blocks.entrySet()) {
|
||||
if (e.getValue().getBlock() instanceof EntityBlock block) {
|
||||
BlockEntity entity = block.newBlockEntity(e.getKey(), e.getValue());
|
||||
if (entity != null) entities.put(e.getKey(), entity);
|
||||
}
|
||||
}
|
||||
LevelAccessor level = (LevelAccessor) Proxy.newProxyInstance(
|
||||
LevelAccessor.class.getClassLoader(), new Class<?>[] {LevelAccessor.class}, (proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "getBlockState": return blocks.getOrDefault(((BlockPos) args[0]).immutable(), Blocks.AIR.defaultBlockState());
|
||||
case "getBlockEntity":
|
||||
if (args.length == 1) return entities.get(((BlockPos) args[0]).immutable());
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
throw new UnsupportedOperationException(method.toString());
|
||||
});
|
||||
ChestBlock chest = (ChestBlock) state.getBlock();
|
||||
var result = DoubleBlockCombiner.combineWithNeigbour(chest.blockEntityType(), ChestBlock::getBlockType,
|
||||
ChestBlock::getConnectedDirection, ChestBlock.FACING, state, level, pos, (l, p) -> false);
|
||||
return result.apply(new DoubleBlockCombiner.Combiner<ChestBlockEntity, String>() {
|
||||
@Override public String acceptDouble(ChestBlockEntity first, ChestBlockEntity second) {
|
||||
return "double " + xz(first.getBlockPos()) + " " + xz(second.getBlockPos());
|
||||
}
|
||||
@Override public String acceptSingle(ChestBlockEntity single) { return "single"; }
|
||||
@Override public String acceptNone() { return "none"; }
|
||||
});
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
|
||||
var lines = Files.lines(Path.of(args[0]))) {
|
||||
for (String line : (Iterable<String>) lines::iterator) {
|
||||
try {
|
||||
out.println(pair(JsonParser.parseString(line).getAsJsonObject()));
|
||||
} catch (Exception e) {
|
||||
System.err.println(line + ": " + e);
|
||||
out.println("error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.PrintWriter;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.component.DataComponentPatch;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.nbt.TagParser;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.ComponentSerialization;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.MenuProvider;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.ChestBlock;
|
||||
import net.minecraft.world.level.block.DoubleBlockCombiner;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/**
|
||||
* Original oracle: the title a container block's menu opens with
|
||||
* (ClientboundOpenScreenPacket.title). Each case line is JSON: a block and the
|
||||
* saved NBT of its block entity ("nbt"), with an optional item component patch
|
||||
* ("patch", SNBT) the block was placed from (BlockEntity.applyComponentsFromItemStack,
|
||||
* as BlockItem.updateBlockEntityComponents runs it); or, for a double chest, the
|
||||
* saved NBT of its first (right) and second (left) halves ("first", "second").
|
||||
* Prints the menu provider's getDisplayName as canonical NBT: a single block
|
||||
* entity's own (BaseContainerBlockEntity.getName), or ChestBlock's
|
||||
* MENU_PROVIDER_COMBINER's for the two halves.
|
||||
*/
|
||||
public final class VanillaContainerTitleOracle {
|
||||
static final BlockPos POS = new BlockPos(0, 64, 0);
|
||||
|
||||
static BlockEntity load(BlockState state, BlockPos pos, String snbt) throws Exception {
|
||||
BlockEntity entity = BlockEntity.loadStatic(pos, state, TagParser.parseCompoundFully(snbt), ParityData.access);
|
||||
if (entity == null) throw new IllegalStateException("no block entity: " + snbt);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static Component doubleChest(ChestBlockEntity first, ChestBlockEntity second) throws Exception {
|
||||
Field field = ChestBlock.class.getDeclaredField("MENU_PROVIDER_COMBINER");
|
||||
field.setAccessible(true);
|
||||
var combiner = (DoubleBlockCombiner.Combiner<ChestBlockEntity, Optional<MenuProvider>>) field.get(null);
|
||||
return combiner.acceptDouble(first, second).orElseThrow().getDisplayName();
|
||||
}
|
||||
|
||||
static Component title(JsonObject c) throws Exception {
|
||||
BlockState state = BuiltInRegistries.BLOCK.getValue(Identifier.parse(c.get("block").getAsString())).defaultBlockState();
|
||||
if (c.has("first")) {
|
||||
var first = (ChestBlockEntity) load(state, POS, c.get("first").getAsString());
|
||||
var second = (ChestBlockEntity) load(state, POS.west(), c.get("second").getAsString());
|
||||
return doubleChest(first, second);
|
||||
}
|
||||
BlockEntity entity = load(state, POS, c.get("nbt").getAsString());
|
||||
if (c.has("patch")) {
|
||||
var patch = DataComponentPatch.CODEC.parse(ParityData.ops, TagParser.parseCompoundFully(c.get("patch").getAsString())).getOrThrow();
|
||||
ItemStack item = new ItemStack(state.getBlock().asItem());
|
||||
item.applyComponents(patch);
|
||||
entity.applyComponentsFromItemStack(item);
|
||||
}
|
||||
return ((MenuProvider) entity).getDisplayName();
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
|
||||
var lines = Files.lines(Path.of(args[0]))) {
|
||||
for (String line : (Iterable<String>) lines::iterator) {
|
||||
try {
|
||||
Component title = title(JsonParser.parseString(line).getAsJsonObject());
|
||||
out.println(ParityData.canon(ComponentSerialization.CODEC.encodeStart(ParityData.ops, title).getOrThrow()));
|
||||
} catch (Exception e) {
|
||||
System.err.println(line + ": " + e);
|
||||
out.println("error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,8 +186,8 @@ public final class VanillaMobAiOracle {
|
||||
boolean spawning;
|
||||
/** The player hits a mob for 1 at the start of a tick: {tick, mob index}. */
|
||||
List<int[]> hurts = new ArrayList<>();
|
||||
/** The player feeds an animal at the start of a tick (Animal.mobInteract with the item in
|
||||
* its main hand): {tick, mob index, item id}. */
|
||||
/** The player feeds an animal, or uses another item on it, at the start of a tick
|
||||
* (Animal.mobInteract with the item in its main hand): {tick, mob index, item id}. */
|
||||
List<Object[]> feeds = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ public final class VanillaMobAiOracle {
|
||||
}
|
||||
case "heart" -> s.hearts.add(f);
|
||||
case "hurt" -> s.hurts.add(new int[] {Integer.parseInt(f[1]), Integer.parseInt(f[2])});
|
||||
case "feed" -> s.feeds.add(new Object[] {Integer.parseInt(f[1]), Integer.parseInt(f[2]), f[3]});
|
||||
case "feed", "interact" -> s.feeds.add(new Object[] {Integer.parseInt(f[1]), Integer.parseInt(f[2]), f[3]});
|
||||
case "player" -> {
|
||||
s.player = new double[] {Double.parseDouble(f[1]), Double.parseDouble(f[2]), Double.parseDouble(f[3])};
|
||||
for (int i = 4; i < f.length; i++) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.level.block.EntityBlock;
|
||||
import net.minecraft.world.level.block.entity.BaseContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/**
|
||||
* Original oracle: the menu a storage block's block entity opens. Each input
|
||||
* line is a block id; its default state's block entity
|
||||
* (EntityBlock.newBlockEntity, marked removed so that opening counts no
|
||||
* opener and needs no level) builds its menu with
|
||||
* BaseContainerBlockEntity.createMenu(id, inventory), the menu a server sends
|
||||
* in ClientboundOpenScreenPacket. Prints the menu's MenuType id and whether
|
||||
* its first slot accepts a shulker box (Slot.mayPlace).
|
||||
*/
|
||||
public final class VanillaStorageMenuOracle {
|
||||
static String menu(String id, Method createMenu) throws Exception {
|
||||
BlockState state = BuiltInRegistries.BLOCK.getValue(Identifier.parse(id)).defaultBlockState();
|
||||
BlockEntity entity = ((EntityBlock) state.getBlock()).newBlockEntity(BlockPos.ZERO, state);
|
||||
entity.setRemoved();
|
||||
Inventory inventory = new Inventory(null, null);
|
||||
var menu = (AbstractContainerMenu) createMenu.invoke((BaseContainerBlockEntity) entity, 0, inventory);
|
||||
boolean accepts = menu.getSlot(0).mayPlace(new ItemStack(Items.SHULKER_BOX));
|
||||
return BuiltInRegistries.MENU.getKey(menu.getType()) + (accepts ? " accepts" : " refuses") + " shulker_box";
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
Method createMenu = BaseContainerBlockEntity.class.getDeclaredMethod("createMenu", int.class, Inventory.class);
|
||||
createMenu.setAccessible(true);
|
||||
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
|
||||
var lines = Files.lines(Path.of(args[0]))) {
|
||||
for (String line : (Iterable<String>) lines::iterator) {
|
||||
try {
|
||||
out.println(menu(line.strip(), createMenu));
|
||||
} catch (Exception e) {
|
||||
System.err.println(line + ": " + e);
|
||||
out.println("error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,12 @@ small JSON-RPC client, and saves a screenshot of each run. The ui scenario drive
|
||||
widget id instead: the title screen, the options (a slider set by id) and the inventory's slots;
|
||||
the options scenario reads and sets options.txt values by key; the events scenario waits for
|
||||
states (wait-for) and reads the event log; the consume scenario eats and drinks consumables (a
|
||||
golden apple, milk, honey, chorus fruit) and checks their effects, remainders and teleport; the
|
||||
pick scenario middle-clicks slots (AbstractContainerScreen's cloning): a full stack in creative, from
|
||||
the hotbar and from an item tab, and nothing in survival; the suggestions scenario checks
|
||||
slash-command Tab completion on single player's connected client after a fresh launch; the no-ai
|
||||
scenario turns No AI on in a scratch game home and checks that horizon-ctl, its MCP server and the
|
||||
game's control socket all refuse.
|
||||
golden apple, milk, honey, chorus fruit) and checks their effects, remainders and teleport, then
|
||||
holds an eye of ender in use; the pick scenario middle-clicks slots (AbstractContainerScreen's
|
||||
cloning): a full stack in creative, from the hotbar and from an item tab, and nothing in survival;
|
||||
the suggestions scenario checks slash-command Tab completion on single player's connected client
|
||||
after a fresh launch; the no-ai scenario turns No AI on in a scratch game home and checks that
|
||||
horizon-ctl, its MCP server and the game's control socket all refuse.
|
||||
|
||||
python3 tools/agent_control_e2e.py # both modes
|
||||
python3 tools/agent_control_e2e.py --mode mcp # one of them
|
||||
@@ -57,7 +57,7 @@ POSITIONAL = {
|
||||
'wait_for': ['condition', 'value'], 'open': ['world', 'seed', 'mode'], 'select': ['slot'],
|
||||
'summon': ['kind', 'x', 'y', 'z'], 'interact_entity': ['id'], 'effect': ['id', 'seconds', 'amplifier'],
|
||||
'xp': ['levels'], 'options': ['page'], 'lan': ['action'], 'world_backup': ['folder'],
|
||||
'world_rename': ['folder', 'name'], 'world_delete': ['folder'],
|
||||
'world_rename': ['folder', 'name'], 'world_delete': ['folder'], 'mouse': ['button', 'action'],
|
||||
}
|
||||
# Booleans the game takes as bare words.
|
||||
BARE = {'click_slot': ['right', 'shift'], 'click': ['right', 'middle', 'shift'], 'summon': ['baby']}
|
||||
@@ -476,7 +476,21 @@ def consume_scenario(d, out):
|
||||
assert held(state) is None, state['held']
|
||||
assert moved != [0, 0, 0] and abs(moved[0]) <= 8 and abs(moved[2]) <= 8, moved
|
||||
d('screenshot', path=str(out / f'consume-{d.name}.png'))
|
||||
return {'teleported': [round(m, 2) for m in moved]}
|
||||
# EnderEyeItem.use: startUsingItem (getUseDuration 0) whether or not a stronghold is found, so a held
|
||||
# button keeps the eye in use, slowing the player, and throws at most one eye until it lets go.
|
||||
d('command', command='clear @s')
|
||||
d('give', item='ender_eye', count=3)
|
||||
d('command', command='tp @s ~ ~ ~ ~ -90')
|
||||
d('mouse', button='right', action='down')
|
||||
d('wait', ticks=12)
|
||||
eye = d('state')
|
||||
d('mouse', button='right', action='up')
|
||||
d('wait', ticks=3)
|
||||
released = d('state')
|
||||
assert eye['using'] is not None, eye['using']
|
||||
assert held(eye) == 'minecraft:ender_eye' and eye['held']['count'] >= 2, eye['held']
|
||||
assert released['using'] is None, released['using']
|
||||
return {'teleported': [round(m, 2) for m in moved], 'eye_use_ticks': eye['using']['ticks']}
|
||||
|
||||
|
||||
def pick_scenario(d, out):
|
||||
@@ -622,9 +636,32 @@ def manage_scenario(d, out):
|
||||
return {'worlds': len(worlds)}
|
||||
|
||||
|
||||
def pause_save_scenario(d, out):
|
||||
# IntegratedServer.tickServer: the tick single player pauses saves everything ("Saving and pausing
|
||||
# game...", #3280), so the world is on disk while the pause menu is open.
|
||||
d('open', world='Paused', seed=7)
|
||||
d('wait_for', condition='loaded', timeout=400)
|
||||
d('wait_ready', seconds=400)
|
||||
level = d.cwd / 'saves/Paused/level.dat'
|
||||
d('wait', ticks=40)
|
||||
playing = level.stat().st_mtime_ns
|
||||
d('wait', ticks=40)
|
||||
assert level.stat().st_mtime_ns == playing, 'level.dat was saved without a pause'
|
||||
d('pause')
|
||||
deadline = time.time() + 15
|
||||
while level.stat().st_mtime_ns == playing and time.time() < deadline:
|
||||
time.sleep(0.25)
|
||||
saved = level.stat().st_mtime_ns != playing
|
||||
shot = d('screenshot', path=str(out / f'{d.name}-pause-save.png'))
|
||||
if '_image' in shot:
|
||||
(out / f'{d.name}-pause-save.png').write_bytes(shot['_image'])
|
||||
assert saved, 'pausing single player did not save level.dat'
|
||||
return {'saved_on_pause': saved}
|
||||
|
||||
|
||||
SCENARIOS = {'survival': scenario, 'ui': ui_scenario, 'options': options_scenario, 'events': events_scenario,
|
||||
'consume': consume_scenario, 'pick': pick_scenario, 'suggestions': suggestions_scenario,
|
||||
'manage': manage_scenario}
|
||||
'manage': manage_scenario, 'pause-save': pause_save_scenario}
|
||||
|
||||
|
||||
REFUSAL = "AI features are off: turn off No AI in the launcher's settings"
|
||||
|
||||
@@ -349,6 +349,27 @@ def entities():
|
||||
]
|
||||
|
||||
|
||||
def wither_spawn():
|
||||
"""A wither charging up (`Invul`) neither moves toward its target nor is hurt by
|
||||
the blast that ends the charge (#3322): `WitherBoss.customServerAiStep` sets no
|
||||
alternative target while invulnerable, and `ServerLevel.explode`'s entity query
|
||||
skips the source."""
|
||||
wither = '@e[type=minecraft:wither,limit=1]'
|
||||
return [
|
||||
*setup('/fill 0 209 4 9 209 20 minecraft:stone',
|
||||
'/summon minecraft:pig 4.5 210 18.5 {NoAI:1b,Tags:["wither_prey"]}',
|
||||
'/summon minecraft:wither 4.5 210 8.5 {Invul:20}'),
|
||||
{'op': 'tick', 'n': 10},
|
||||
cmd(f'/data get entity {wither} Pos'),
|
||||
cmd(f'/data get entity {wither} Invul'),
|
||||
{'op': 'tick', 'n': 12},
|
||||
cmd(f'/data get entity {wither} Invul'),
|
||||
cmd(f'/data get entity {wither} Health'),
|
||||
*setup('/kill @e[type=minecraft:wither]', '/kill @e[tag=wither_prey]', '/fill 0 200 0 15 228 20 air'),
|
||||
{'op': 'tick', 'n': 5},
|
||||
]
|
||||
|
||||
|
||||
def riding_teleport():
|
||||
"""Player teleports remove the vehicle, leave it behind and keep the destination (#1473)."""
|
||||
horse = '@e[tag=tp_horse,limit=1]'
|
||||
@@ -1122,6 +1143,7 @@ def groups():
|
||||
('player state', player_state()),
|
||||
('blocks and border', world_blocks()),
|
||||
('entities', entities()),
|
||||
('wither spawn', wither_spawn()),
|
||||
('riding teleport', riding_teleport()),
|
||||
('mob riding', mob_riding()),
|
||||
('scores, teams and bossbars', scores()),
|
||||
|
||||
+98
-2
@@ -41,6 +41,8 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
server's damage event) (#848);
|
||||
- containers: the guest's ender chest holds the guest's items, not the host's, and a double chest
|
||||
opens as 9x6 with the right half first;
|
||||
- names: a renamed shulker box, copper chest and chest the guest places open on the guest's screen
|
||||
with their custom names as the title (BaseContainerBlockEntity.getDisplayName, #3245);
|
||||
- machines: a hopper, a dispenser and a dropper open on the guest's screen with their 5 and 9
|
||||
slots, and keep what the guest shift-clicks into them (#848);
|
||||
- brewing: a brewing stand opens on the guest's screen; blaze powder, nether wart and a bottle the
|
||||
@@ -307,7 +309,7 @@ GUEST_NAME = 'Guest'
|
||||
# Match normal single player; --in-frame is the comparison path.
|
||||
SERVER_THREAD = True
|
||||
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'spawned', 'sign', 'damage', 'mobs', 'hazards', 'pvp',
|
||||
'arrows', 'kills', 'leave', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'styles',
|
||||
'arrows', 'kills', 'leave', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'names', 'feedback', 'titles', 'chat', 'styles',
|
||||
'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing',
|
||||
'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'sleep-villager', 'bed-occupied', 'sleep-reload', 'predict', 'push',
|
||||
'piston-push', 'piston-slime', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles',
|
||||
@@ -1532,6 +1534,32 @@ def check_containers(host, guest, out):
|
||||
return {'ender': ender, 'double': double, 'taken': taken}
|
||||
|
||||
|
||||
def check_names(host, guest, out):
|
||||
# BlockItem.updateBlockEntityComponents gives the item's custom_name to the block entity, and its
|
||||
# menu opens with it as the title (ClientboundOpenScreenPacket), shulker boxes and copper chests too.
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
titles = {}
|
||||
for i, item in enumerate(('red_shulker_box', 'copper_chest', 'chest')):
|
||||
spot = (x - 2 + 2 * i, y, z + 2)
|
||||
guest('command', command=f'setblock {spot[0]} {spot[1] - 1} {spot[2]} minecraft:stone')
|
||||
guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
|
||||
guest('command', command=f'give @s minecraft:{item}[minecraft:custom_name="Named {i}"]')
|
||||
guest.wait(10)
|
||||
guest.raw('place', *spot, '--item', f'minecraft:{item}')
|
||||
guest.wait(10)
|
||||
guest.raw('interact', *spot)
|
||||
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
||||
title = guest.until(lambda: guest('screen').get('title'), bool, ticks=40)
|
||||
titles[item] = ''.join(span['text'] for span in title)
|
||||
guest.shot(out, f'names-{item}')
|
||||
guest.raw('key', 'esc', 'tap')
|
||||
guest.wait(5)
|
||||
guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
|
||||
want = {'red_shulker_box': 'Named 0', 'copper_chest': 'Named 1', 'chest': 'Named 2'}
|
||||
assert titles == want, f'the titles of the renamed containers: {titles}'
|
||||
return titles
|
||||
|
||||
|
||||
def check_machines(host, guest, out):
|
||||
# After `recipes`: the stacks given here unlock recipes, which would move the book's first one.
|
||||
guest('command', command='gamemode survival')
|
||||
@@ -5167,7 +5195,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
|
||||
'spawned': check_spawned,
|
||||
'sign': check_sign, 'damage': check_damage, 'mobs': check_mobs,
|
||||
'hazards': check_hazards, 'pvp': check_pvp, 'respawn': check_respawn,
|
||||
'containers': check_containers, 'feedback': check_feedback, 'titles': check_titles,
|
||||
'containers': check_containers, 'names': check_names, 'feedback': check_feedback, 'titles': check_titles,
|
||||
'chat': check_chat, 'styles': check_styles, 'machines': check_machines, 'brewing': check_brewing,
|
||||
'loom': check_loom, 'stonecutter': check_stonecutter, 'grindstone': check_grindstone,
|
||||
'anvil': check_anvil, 'smithing': check_smithing, 'enchanting': check_enchanting,
|
||||
@@ -8189,5 +8217,73 @@ CHECKS += ['dig_sounds']
|
||||
CHECK_FNS['dig_sounds'] = check_dig_sounds
|
||||
|
||||
|
||||
def check_shulker_menu(host, guest, out):
|
||||
"""A shulker box opens a ShulkerBoxMenu (ShulkerBoxBlockEntity.createMenu), whose ShulkerBoxSlots
|
||||
refuse shulker boxes. The server opened it as a 9x3 chest menu, which took them."""
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
box = (x, y, z + 2)
|
||||
try:
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='clear @s')
|
||||
guest('command', command='give @s minecraft:cobblestone 1')
|
||||
guest('command', command='give @s minecraft:red_shulker_box 1')
|
||||
guest('command', command=f'setblock {box[0]} {box[1]} {box[2]} minecraft:shulker_box')
|
||||
guest.wait(10)
|
||||
guest.raw('interact', *box)
|
||||
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
||||
guest.wait(10)
|
||||
# The cobblestone goes in; the red shulker box stays on the cursor.
|
||||
mouse(guest, hotbar_slot(guest, 'minecraft:cobblestone'))
|
||||
mouse(guest, 301)
|
||||
mouse(guest, hotbar_slot(guest, 'minecraft:red_shulker_box'))
|
||||
mouse(guest, 300)
|
||||
guest.wait(10)
|
||||
guest.shot(out, 'shulker-menu')
|
||||
guest.raw('key', 'esc', 'tap')
|
||||
guest.wait(10)
|
||||
inside, size = open_container(guest, box, out, 'shulker-menu-after')
|
||||
counts = guest('inventory')['counts']
|
||||
stored = host('command', command=f'data get block {box[0]} {box[1]} {box[2]} Items')
|
||||
finally:
|
||||
guest('command', command=f'setblock {box[0]} {box[1]} {box[2]} minecraft:air')
|
||||
guest('command', command='clear @s')
|
||||
assert size == 27 and inside == {1: 'minecraft:cobblestone'}, f'the shulker box holds {inside} ({size})'
|
||||
assert 'red_shulker_box' not in json.dumps(stored), f'the server stored {stored}'
|
||||
assert counts.get('minecraft:red_shulker_box') == 1, f'inventory after: {counts}'
|
||||
return {'inside': inside, 'inventory': counts}
|
||||
|
||||
|
||||
CHECKS += ['shulker_menu']
|
||||
CHECK_FNS['shulker_menu'] = check_shulker_menu
|
||||
|
||||
|
||||
def check_keys_released(host, guest, out):
|
||||
"""Gui.setScreen -> KeyMapping.releaseAll: the screen shown after leaving a server releases every held
|
||||
key, so W and Space held through the disconnect don't walk and jump on in the next world (#3246)."""
|
||||
port = host.raw('lan', 'status')['port']
|
||||
guest.raw('key', 'w', 'down')
|
||||
guest.raw('key', 'space', 'down')
|
||||
held = sorted(guest('status')['keys'])
|
||||
guest.raw('disconnect')
|
||||
left = sorted(guest('status')['keys'])
|
||||
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
|
||||
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
||||
guest.wait(20)
|
||||
rejoined = sorted(guest('status')['keys'])
|
||||
start = pos_of(guest)
|
||||
guest.wait(20)
|
||||
end = pos_of(guest)
|
||||
walked = round(math.hypot(end[0] - start[0], end[2] - start[2]), 3)
|
||||
seen = {'held': held, 'left': left, 'rejoined': rejoined, 'walked': walked}
|
||||
assert held == ['KeyW', 'Space'], f'the keys were not pressed: {seen}'
|
||||
assert left == [] and rejoined == [], f'keys stayed down after leaving: {seen}'
|
||||
assert walked < 0.1, f'the guest walked on its own after rejoining: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
CHECKS += ['keys_released']
|
||||
CHECK_FNS['keys_released'] = check_keys_released
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare which chests join into a double chest against vanilla 26.3.
|
||||
|
||||
The Java oracle (VanillaChestPairOracle) puts a chest and another block next
|
||||
to it, each with its block entity, and runs DoubleBlockCombiner's
|
||||
combineWithNeigbour as ChestBlock.combine does: the double chest's halves
|
||||
(the right one first) or a single chest. That pairing decides a chest's
|
||||
menu (9x6 or 9x3), what a hopper fills and the lid both halves share. The
|
||||
Rust example (chest_pair_fixture) answers with horizon_client::containers'
|
||||
ChestHalf, which the game's menus and hoppers use. Compared for chests,
|
||||
trapped chests and copper chests in every weathering state, waxed or not,
|
||||
next to each of them and a barrel, with every chest type, two facings and
|
||||
the block on either side.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
try:
|
||||
from . import interaction_lab as lib
|
||||
except ImportError:
|
||||
import interaction_lab as lib
|
||||
|
||||
CHESTS = ['chest', 'trapped_chest', 'copper_chest', 'exposed_copper_chest', 'weathered_copper_chest',
|
||||
'oxidized_copper_chest', 'waxed_copper_chest', 'waxed_exposed_copper_chest',
|
||||
'waxed_weathered_copper_chest', 'waxed_oxidized_copper_chest']
|
||||
TYPES = ['single', 'left', 'right']
|
||||
# A chest facing north connects east (left) or west (right): ChestBlock.getConnectedDirection.
|
||||
SIDES = [(1, 0), (-1, 0)]
|
||||
|
||||
|
||||
def state(block, facing, kind):
|
||||
return f'minecraft:{block}[facing={facing},type={kind}]'
|
||||
|
||||
|
||||
def cases():
|
||||
out = []
|
||||
for block in CHESTS:
|
||||
for kind in TYPES:
|
||||
own = state(block, 'north', kind)
|
||||
for at in SIDES:
|
||||
for other in CHESTS:
|
||||
for facing in ('north', 'south'):
|
||||
for other_kind in TYPES:
|
||||
out.append({'state': own, 'other': state(other, facing, other_kind), 'at': at})
|
||||
out.append({'state': own, 'other': 'minecraft:barrel[facing=north]', 'at': at})
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--limit', type=int, default=4)
|
||||
args = parser.parse_args()
|
||||
work = lib.work_dir('chest-pair-parity')
|
||||
vanilla = lib.Vanilla(work)
|
||||
vanilla.compile('ParityData', 'VanillaChestPairOracle')
|
||||
all_cases = cases()
|
||||
(work / 'cases.jsonl').write_text(''.join(json.dumps(c) + '\n' for c in all_cases), encoding='utf-8')
|
||||
vanilla.run('VanillaChestPairOracle', work / 'cases.jsonl', work / 'native.txt')
|
||||
lib.cargo_example('horizon-client', 'chest_pair_fixture', work / 'cases.jsonl', work / 'rust.txt')
|
||||
errors = sum(line == 'error' for line in (work / 'native.txt').read_text(encoding='utf-8').split('\n'))
|
||||
if errors:
|
||||
print(f'{errors} cases fail in vanilla')
|
||||
total = lib.compare(all_cases, work / 'native.txt', work / 'rust.txt',
|
||||
lambda case, line: case['state'].split('[')[0] + ' next to ' + case['other'].split('[')[0],
|
||||
json.dumps, args.limit)
|
||||
raise SystemExit(1 if total or errors else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare the titles container menus open with against vanilla 26.3 (#3245).
|
||||
|
||||
The Java oracle (VanillaContainerTitleOracle) loads each container block
|
||||
entity with BlockEntity.loadStatic, applies the component patch of the item it
|
||||
was placed from (BlockEntity.applyComponentsFromItemStack: a renamed shulker
|
||||
box or copper chest gives its custom_name to CustomName), and prints the menu
|
||||
provider's getDisplayName: BaseContainerBlockEntity.getName, the custom name
|
||||
or getDefaultName, and for a double chest ChestBlock's MENU_PROVIDER_COMBINER
|
||||
(the right half's name, else the left's, else container.chestDouble). That is
|
||||
the title of the ClientboundOpenScreenPacket a server sends, which the
|
||||
client's screen draws. The Rust example (container_title_fixture) places the
|
||||
item with horizon_client::block_entity_item as the game does and names the
|
||||
menu with horizon_client::containers, which the server's
|
||||
`lan_open_container` sends. Compared for every container block: chests,
|
||||
trapped and copper chests (every weathering state, waxed too), barrels,
|
||||
shulker boxes of any colour, dispensers, droppers, hoppers, the three
|
||||
furnaces, brewing stands and crafters, unnamed, named in their saved data,
|
||||
placed from a renamed item, and double chests named on either half.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
try:
|
||||
from . import interaction_lab as lib
|
||||
except ImportError:
|
||||
import interaction_lab as lib
|
||||
|
||||
POS = 'x:0,y:64,z:0'
|
||||
|
||||
# (block, its block entity type).
|
||||
BLOCKS = [
|
||||
('chest', 'chest'), ('trapped_chest', 'trapped_chest'),
|
||||
('copper_chest', 'chest'), ('exposed_copper_chest', 'chest'), ('weathered_copper_chest', 'chest'),
|
||||
('oxidized_copper_chest', 'chest'), ('waxed_copper_chest', 'chest'), ('waxed_oxidized_copper_chest', 'chest'),
|
||||
('barrel', 'barrel'), ('shulker_box', 'shulker_box'), ('red_shulker_box', 'shulker_box'),
|
||||
('black_shulker_box', 'shulker_box'), ('dispenser', 'dispenser'), ('dropper', 'dropper'), ('hopper', 'hopper'),
|
||||
('furnace', 'furnace'), ('smoker', 'smoker'), ('blast_furnace', 'blast_furnace'),
|
||||
('brewing_stand', 'brewing_stand'), ('crafter', 'crafter'),
|
||||
]
|
||||
NAME = '"minecraft:custom_name"'
|
||||
# (saved NBT body, the placed item's component patch or None).
|
||||
VARIANTS = [
|
||||
('', None),
|
||||
('CustomName:"Loot"', None),
|
||||
('', '{%s:"Loot"}' % NAME),
|
||||
('', '{%s:"Box of §cred"}' % NAME),
|
||||
('CustomName:"Old"', '{%s:"New"}' % NAME),
|
||||
('CustomName:"Old"', '{"!minecraft:custom_name":{}}'),
|
||||
('', '{"minecraft:lore":["a"]}'),
|
||||
]
|
||||
# Double chests: (block, block entity type), then the halves' names.
|
||||
DOUBLES = [('chest', 'chest'), ('trapped_chest', 'trapped_chest')]
|
||||
HALVES = [(None, None), ('Right', None), (None, 'Left'), ('Right', 'Left')]
|
||||
|
||||
|
||||
def be(type_id, body=''):
|
||||
return '{id:"minecraft:%s",%s%s}' % (type_id, POS, ',' + body if body else '')
|
||||
|
||||
|
||||
def named(type_id, name):
|
||||
return be(type_id, 'CustomName:"%s"' % name if name else '')
|
||||
|
||||
|
||||
def cases():
|
||||
out = []
|
||||
for block, type_id in BLOCKS:
|
||||
for body, patch in VARIANTS:
|
||||
case = {'block': 'minecraft:' + block, 'nbt': be(type_id, body)}
|
||||
if patch is not None:
|
||||
case['patch'] = patch
|
||||
out.append(case)
|
||||
for block, type_id in DOUBLES:
|
||||
for first, second in HALVES:
|
||||
out.append({'block': 'minecraft:' + block, 'first': named(type_id, first), 'second': named(type_id, second)})
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--limit', type=int, default=4)
|
||||
args = parser.parse_args()
|
||||
work = lib.work_dir('container-title-parity')
|
||||
vanilla = lib.Vanilla(work)
|
||||
vanilla.compile('ParityData', 'VanillaContainerTitleOracle')
|
||||
all_cases = cases()
|
||||
(work / 'cases.jsonl').write_text(''.join(json.dumps(c) + '\n' for c in all_cases), encoding='utf-8')
|
||||
vanilla.run('VanillaContainerTitleOracle', work / 'cases.jsonl', work / 'native.txt')
|
||||
lib.cargo_example('horizon-client', 'container_title_fixture', work / 'cases.jsonl', work / 'rust.txt')
|
||||
errors = sum(line == 'error' for line in (work / 'native.txt').read_text(encoding='utf-8').split('\n'))
|
||||
if errors:
|
||||
print(f'{errors} cases fail in vanilla')
|
||||
total = lib.compare(all_cases, work / 'native.txt', work / 'rust.txt',
|
||||
lambda case, line: case['block'] + (' double' if 'first' in case else ''),
|
||||
lambda case: json.dumps(case, ensure_ascii=False), args.limit)
|
||||
raise SystemExit(1 if total or errors else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -233,6 +233,22 @@ def scenarios():
|
||||
f'mob minecraft:{species} 0.5 -60 0.5 0 {seed} persistent{baby}',
|
||||
f'feed 20 0 minecraft:{food}', f'feed 40 0 minecraft:{food}',
|
||||
])
|
||||
# The player uses a sword on untamed horses (#2697): Horse, AbstractChestedHorse and
|
||||
# ZombieHorse.mobInteract make them mad (makeMad: rear, angry sound) instead of taking the
|
||||
# rider, not while one still stands; an untamed skeleton horse ignores it (SkeletonHorse).
|
||||
# The zombie horse goes at night (it burns by day), the skeleton horse stops before its
|
||||
# first step sound (Horizon plays the block's).
|
||||
for species, seed, babies, ticks, opts in [
|
||||
('horse', 180, True, 80, ''), ('donkey', 181, True, 80, ''), ('mule', 182, False, 80, ''),
|
||||
('zombie_horse', 183, False, 80, 'time=18000'), ('llama', 184, True, 80, ''),
|
||||
('skeleton_horse', 185, False, 45, '')]:
|
||||
for baby in ('', ' baby') if babies else ('',):
|
||||
add(f'sword_{species}{baby.replace(" ", "_")}', ticks, [
|
||||
'player 3.5 -60 0.5',
|
||||
f'mob minecraft:{species} 0.5 -60 0.5 0 {seed} persistent{baby}',
|
||||
'interact 20 0 minecraft:iron_sword', 'interact 30 0 minecraft:iron_sword',
|
||||
'interact 40 0 minecraft:iron_sword',
|
||||
], opts)
|
||||
# Striders: standing on a lava pool (floatStrider), walking from cold grass to it
|
||||
# (StriderGoToLavaGoal, suffocating), and drawn by a player holding warped fungus.
|
||||
LAVA = 'fill -5 -63 -5 5 -61 5 minecraft:lava'
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare the menu a storage block opens against vanilla 26.3.
|
||||
|
||||
The Java oracle (VanillaStorageMenuOracle) builds each block's block entity
|
||||
and its menu with BaseContainerBlockEntity.createMenu, the menu a server
|
||||
sends in ClientboundOpenScreenPacket, and prints its MenuType and whether its
|
||||
first slot takes a shulker box. A shulker box opens a ShulkerBoxMenu
|
||||
(minecraft:shulker_box), whose ShulkerBoxSlots refuse shulker boxes; chests
|
||||
and barrels open a 9x3 ChestMenu. The Rust example (storage_menu_fixture)
|
||||
answers with horizon_client::containers::block_menu_type, the menu type the
|
||||
server's lan_open_container sends; the server refuses shulker boxes in the
|
||||
menus of that type (lan::containers, ShulkerBoxSlot). Compared for chests,
|
||||
trapped and copper chests, barrels, shulker boxes of every colour,
|
||||
dispensers, droppers and hoppers.
|
||||
"""
|
||||
import argparse
|
||||
try:
|
||||
from . import interaction_lab as lib
|
||||
except ImportError:
|
||||
import interaction_lab as lib
|
||||
|
||||
COLOURS = ['white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', 'pink', 'gray', 'light_gray', 'cyan',
|
||||
'purple', 'blue', 'brown', 'green', 'red', 'black']
|
||||
BLOCKS = (['chest', 'trapped_chest', 'barrel', 'dispenser', 'dropper', 'hopper', 'shulker_box']
|
||||
+ [f'{c}_shulker_box' for c in COLOURS]
|
||||
+ [f'{w}copper_chest' for w in ('', 'exposed_', 'weathered_', 'oxidized_')]
|
||||
+ [f'waxed_{w}copper_chest' for w in ('', 'exposed_', 'weathered_', 'oxidized_')])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--limit', type=int, default=4)
|
||||
args = parser.parse_args()
|
||||
work = lib.work_dir('storage-menu-parity')
|
||||
vanilla = lib.Vanilla(work)
|
||||
vanilla.compile('ParityData', 'VanillaStorageMenuOracle')
|
||||
cases = ['minecraft:' + b for b in BLOCKS]
|
||||
(work / 'cases.txt').write_text(''.join(c + '\n' for c in cases), encoding='utf-8')
|
||||
vanilla.run('VanillaStorageMenuOracle', work / 'cases.txt', work / 'native.txt')
|
||||
lib.cargo_example('horizon-client', 'storage_menu_fixture', work / 'cases.txt', work / 'rust.txt')
|
||||
errors = sum(line == 'error' for line in (work / 'native.txt').read_text(encoding='utf-8').split('\n'))
|
||||
if errors:
|
||||
print(f'{errors} cases fail in vanilla')
|
||||
total = lib.compare(cases, work / 'native.txt', work / 'rust.txt', lambda case, line: case, limit=args.limit)
|
||||
raise SystemExit(1 if total or errors else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user