diff --git a/crates/window-core/src/codegen/definitions.rs b/crates/window-core/src/codegen/definitions.rs index 58f2e78f..32afbd5d 100644 --- a/crates/window-core/src/codegen/definitions.rs +++ b/crates/window-core/src/codegen/definitions.rs @@ -217,6 +217,7 @@ pub(super) fn generate_fullscreen_entries(manifest: &Manifest, package_name: &st (true, "FullscreenEntry"), (true, "FullscreenMode"), (true, "FullscreenSurfaceEntry"), + (canvas.horse.is_some(), "HorseEntry"), (layers, "LayerEntry"), (layers, "LayerKind"), (any(&|f| !f.slots.is_empty()), "SlotEntry"), diff --git a/crates/window-core/src/codegen/entries.rs b/crates/window-core/src/codegen/entries.rs index 413ceec7..e5d49a56 100644 --- a/crates/window-core/src/codegen/entries.rs +++ b/crates/window-core/src/codegen/entries.rs @@ -67,7 +67,7 @@ pub(super) fn fullscreen_canvas_entry_expr(canvas: &FullscreenCanvasEntry, level .arg("width", canvas.cursor.width) .arg("height", canvas.cursor.height) .finish(); - Call::new("FullscreenCanvasEntry", level) + let call = Call::new("FullscreenCanvasEntry", level) .arg("width", canvas.width) .arg("height", canvas.height) .arg("degreesPerPixel", format!("{:?}", canvas.degrees_per_pixel)) @@ -83,8 +83,16 @@ pub(super) fn fullscreen_canvas_entry_expr(canvas: &FullscreenCanvasEntry, level .arg("hudHiddenWorldAge", format!("{}L", canvas.hud_hidden_world_age)) .arg("hudStripHeight", canvas.hud_strip_height) .arg("hudStripHalfWidth", canvas.hud_strip_half_width) - .arg("hudMaxHealth", canvas.hud_max_health) - .finish() + .arg("hudMaxHealth", canvas.hud_max_health); + let Some(horse) = &canvas.horse else { + return call.finish(); + }; + let offset = horse.offset.iter().map(|value| format!("{value:?}")).collect::>().join(", "); + let horse = Call::new("HorseEntry", level + 1) + .arg("offset", format!("listOf({offset})")) + .arg("armor", kt_string(&horse.armor)) + .finish(); + call.arg("horse", horse).finish() } pub(super) fn optional_hud_shader_entry_expr(shader: Option<&HudShaderEntry>, level: usize) -> String { diff --git a/crates/window-core/src/fullscreen.rs b/crates/window-core/src/fullscreen.rs index 8dc38189..0d1df0e7 100644 --- a/crates/window-core/src/fullscreen.rs +++ b/crates/window-core/src/fullscreen.rs @@ -37,6 +37,8 @@ use crate::compose::Texture; use crate::geometry::{Point, Rect, Size}; use crate::ir::Rgb; +pub mod horse; + /// The virtual canvas size in canvas pixels, which full-screen windows are laid out on. pub const CANVAS: Size = Size::new(480, 270); diff --git a/crates/window-core/src/fullscreen/horse.rs b/crates/window-core/src/fullscreen/horse.rs new file mode 100644 index 00000000..5d2fda2b --- /dev/null +++ b/crates/window-core/src/fullscreen/horse.rs @@ -0,0 +1,66 @@ +//! Horse mode: a full-screen window with the view locked. +//! +//! The client's camera spectates an invisible client-only bat at the player's eye, so the view never turns. The +//! player rides an invisible, saddled, client-only horse at [`OFFSET`] from the camera. Every client tick, the client +//! turns the horse it steers to the player's yaw and half their pitch (`AbstractHorse.tickRidden`). The horse wears +//! armour whose texture starts with [`ARMOR_SIGNATURE`]. The generated `core/entity` vertex shader recognises that +//! armour, recovers the yaw and pitch from the corners of the head's top face, and draws that face as the cursor +//! with the include's mapping. It hides every other vertex of the armour. The server maps the player's own rotation +//! the same way, so the drawn cursor and the hit-test agree. + +use crate::compose::Texture; + +/// Where the horse stands relative to the camera, in blocks, with the camera facing yaw and pitch 0 (+Z). It stays in +/// view at every FOV, so it is not frustum-culled, and below the camera's line of sight, so clicks reach the +/// interaction entity around the eye instead of the horse. The shader reads a corner's position as rotation, so the +/// horse must never leave it: the runtime seats it on an item display, which the client keeps it on through fluids. +pub const OFFSET: [f64; 3] = [0.0, -1.75, 3.0]; + +/// Texel (0, 0) of the armour texture, as RGBA. The entity shader treats every armour texture that starts with it as +/// the cursor armour, so it is reserved pack-wide. +pub const ARMOR_SIGNATURE: [u8; 4] = [253, 1, 253, 255]; + +/// The armour's equipment asset, under the pack namespace. +pub const ARMOR_ASSET: &str = "fullscreen/horse_cursor"; + +/// The column of the armour texture where the cursor starts, right of the signature texel. +pub const ARMOR_CURSOR_X: u32 = 8; + +/// The armour texture: [`ARMOR_SIGNATURE`] at (0, 0) and `cursor` from ([`ARMOR_CURSOR_X`], 0). +pub(crate) fn armor_texture(cursor: &Texture) -> Texture { + let mut texture = Texture::transparent(ARMOR_CURSOR_X + cursor.width, cursor.height.max(1)); + texture.rgba[..4].copy_from_slice(&ARMOR_SIGNATURE); + let row = cursor.width as usize * 4; + for y in 0..cursor.height as usize { + let at = (y * texture.width as usize + ARMOR_CURSOR_X as usize) * 4; + texture.rgba[at..at + row].copy_from_slice(&cursor.rgba[y * row..(y + 1) * row]); + } + texture +} + +/// The equipment asset's path relative to the pack root. +pub(crate) fn equipment_path(namespace: &str) -> String { + format!("assets/{namespace}/equipment/{ARMOR_ASSET}.json") +} + +/// The armour texture's path relative to the pack root, where the equipment asset's `horse_body` layer finds it. +pub(crate) fn texture_path(namespace: &str) -> String { + format!("assets/{namespace}/textures/entity/equipment/horse_body/{ARMOR_ASSET}.png") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fullscreen::cursor_texture; + + #[test] + fn armor_texture_starts_with_the_signature_then_holds_the_cursor() { + let cursor = cursor_texture(); + let texture = armor_texture(&cursor); + assert_eq!((texture.width, texture.height), (ARMOR_CURSOR_X + cursor.width, cursor.height)); + assert_eq!(texture.get(0, 0), ARMOR_SIGNATURE); + for (x, y) in [(0, 0), (1, 1), (7, 7), (4, 11)] { + assert_eq!(texture.get(ARMOR_CURSOR_X + x, y), cursor.get(x, y), "({x}, {y})"); + } + } +} diff --git a/crates/window-core/src/hud/mod.rs b/crates/window-core/src/hud/mod.rs index 185c6ba0..c65363b0 100644 --- a/crates/window-core/src/hud/mod.rs +++ b/crates/window-core/src/hud/mod.rs @@ -102,6 +102,12 @@ pub fn emit( }) } +/// The `core/entity` vertex shader that draws the cursor of horse-mode full-screen windows. It belongs only in packs +/// that [`emit`] accepted full-screen windows for, which targets the pack_format 85-88 profile it is written for. +pub fn horse_entity_shader() -> ShaderFile { + shader_file("entity.vsh", renderer::render_horse_entity_vertex()) +} + fn shader_files(profile: ShaderProfile, rules: &[HudShaderRule], hover: bool, fullscreen: bool) -> Vec { profile.sources(rules, hover, fullscreen).into_iter().map(|(name, contents)| shader_file(name, contents)).collect() } diff --git a/crates/window-core/src/hud/renderer/entity.rs b/crates/window-core/src/hud/renderer/entity.rs new file mode 100644 index 00000000..a5885404 --- /dev/null +++ b/crates/window-core/src/hud/renderer/entity.rs @@ -0,0 +1,310 @@ +//! The pack_format 85-88 `core/entity` vertex shader of horse-mode full-screen windows: vanilla's, plus a branch that +//! draws the cursor from the horse's armour. See [`crate::fullscreen::horse`]. + +use crate::fullscreen::LAYER_DEPTH_STEP; +use crate::fullscreen::horse::{ARMOR_CURSOR_X, ARMOR_SIGNATURE, OFFSET}; + +use super::fullscreen::CANVAS_PLACEMENT; + +/// Where the armour model's head parts pivot, in the horse's unturned space (facing -Z, Y up), in blocks: the part's +/// pose offset of (0, 4, -12) texels, scaled by 1.1 about the feet. +const HEAD_PIVOT: [f64; 3] = [0.0, 1.3761, -0.825]; + +/// The head cube's top face in the head parts' texels, before the model's Y flip: its box top of -11, inflated by 0.1. +const HEAD_TOP: f64 = -11.1; + +const ENTITY_VERTEX: &str = r#"#version 330 + +#if defined(PER_FACE_LIGHTING) || !defined(NO_CARDINAL_LIGHTING) +#moj_import +#endif +#moj_import +#moj_import +#moj_import +#moj_import +#if defined(NO_OVERLAY) && defined(PER_FACE_LIGHTING) +#moj_import +#moj_import +#endif + +in vec3 Position; +in vec4 Color; +in vec2 UV0; +in ivec2 UV1; +in ivec2 UV2; +in vec3 Normal; + +#ifndef NO_OVERLAY +uniform sampler2D Sampler1; +#endif + +#ifndef EMISSIVE +uniform sampler2D Sampler2; +#endif + +out float sphericalVertexDistance; +out float cylindricalVertexDistance; + +#ifdef PER_FACE_LIGHTING +out vec4 vertexPerFaceColorBack; +out vec4 vertexPerFaceColorFront; +#else +out vec4 vertexColor; +#endif + +#ifndef EMISSIVE +out vec4 lightMapColor; +#endif + +#ifndef NO_OVERLAY +out vec4 overlayColor; +#endif + +out vec2 texCoord0; + +#if defined(NO_OVERLAY) && defined(PER_FACE_LIGHTING) +// Equipment layers, which draw the horse's cursor armour. +uniform sampler2D Sampler0; + +// Texel (0, 0) of the cursor armour's texture. +const ivec4 WINDOW_FS_ARMOR_SIGNATURE = ivec4(@SIGNATURE@); +// Where the cursor starts in the armour's texture, in texels. +const vec2 WINDOW_FS_ARMOR_CURSOR = vec2(@CURSOR_X@.0, 0.0); +// Where the horse stands relative to the camera, in blocks. +const vec3 WINDOW_FS_HORSE_OFFSET = vec3(@OFFSET@); +// The depth distance between adjacent layers. +const float WINDOW_FS_LAYER_DEPTH_STEP = @DEPTH_STEP@; +// Below every fog start, so the cursor is never fogged. +const float WINDOW_FS_NO_FOG_DISTANCE = -1.0e30; +// Outside the clip volume: a triangle with every corner here has no area. +const vec4 WINDOW_FS_HIDDEN = vec4(2.0, 2.0, 2.0, 1.0); + +// The vanilla horse armour model: the equine body mesh inflated by 0.1, drawn at the adult scale of 1.1. In the +// horse's unturned space (facing -Z, Y up, in blocks), the head parts pivot at WINDOW_FS_HEAD_PIVOT and pitch about X +// by 30 degrees plus the horse's pitch. WINDOW_FS_HEAD_TOP is the head cube's top face in the head parts' pixels, +// before the model's Y flip. +const vec3 WINDOW_FS_HEAD_PIVOT = vec3(@HEAD_PIVOT@); +const float WINDOW_FS_HORSE_SCALE = 1.1 / 16.0; +const float WINDOW_FS_HEAD_REST_PITCH = 30.0; +const float WINDOW_FS_HEAD_TOP = @HEAD_TOP@; + +@PLACEMENT@ + +bool window_fs_is_cursor_armor() { + return ivec4(round(texelFetch(Sampler0, ivec2(0), 0) * 255.0)) == WINDOW_FS_ARMOR_SIGNATURE; +} + +// Draws the head cube's top face as the cursor and hides every other vertex of the cursor armour. Each corner recovers +// the horse's yaw and pitch from its own position relative to the camera: the horse stands at WINDOW_FS_HORSE_OFFSET +// and only turns about its origin, and the client sets its yaw to the rider's and its pitch to half the rider's. The +// session keeps the horse at that offset, since a corner's position alone cannot tell the horse moving from it turning. +void window_fs_horse_cursor() { + gl_Position = WINDOW_FS_HIDDEN; + sphericalVertexDistance = WINDOW_FS_NO_FOG_DISTANCE; + cylindricalVertexDistance = WINDOW_FS_NO_FOG_DISTANCE; + vertexPerFaceColorBack = vec4(1.0); + vertexPerFaceColorFront = vec4(1.0); +#ifndef EMISSIVE + lightMapColor = vec4(1.0); +#endif + texCoord0 = UV0; + + // Texels of the model's 64x64 texture space. The top face's corners share UVs with corners of the side faces and, + // at u = 13, the bottom face; the normal tells them apart below. + vec2 uv = round(UV0 * 64.0); + if (!((uv.x == 7.0 || uv.x == 13.0) && (uv.y == 13.0 || uv.y == 20.0)) || Normal.y <= 0.1) { + return; + } + vec3 head = vec3(uv.x == 13.0 ? 3.1 : -3.1, WINDOW_FS_HEAD_TOP, uv.y == 13.0 ? 5.1 : -2.1); + vec3 local = Position - WINDOW_FS_HORSE_OFFSET; + + // Turning keeps the corner's height and horizontal distance, which fix its unturned x and z. + float x = -WINDOW_FS_HORSE_SCALE * head.x; + float z = -sqrt(max(dot(local.xz, local.xz) - x * x, 0.0)); + vec2 pitched = vec2(WINDOW_FS_HEAD_PIVOT.y - local.y, z - WINDOW_FS_HEAD_PIVOT.z) / WINDOW_FS_HORSE_SCALE; + float headPitch = degrees(atan(pitched.y, pitched.x) - atan(head.z, head.y)); + headPitch -= 360.0 * floor((headPitch + 180.0) / 360.0); + // The renderer turns the model by 180 - yaw about Y. + float yaw = 180.0 - degrees(atan(local.x, local.z)) + degrees(atan(x, z)); + + float turn = radians(180.0 - yaw); + float tilt = radians(headPitch); + vec3 topNormal = vec3(-sin(tilt) * sin(turn), cos(tilt), -sin(tilt) * cos(turn)); + if (dot(Normal, topNormal) <= 0.9) { + return; + } + + vec2 cursor = window_fs_cursor_at(yaw, 2.0 * (headPitch - WINDOW_FS_HEAD_REST_PITCH)); + vec2 corner = vec2(uv.x == 13.0 ? 1.0 : 0.0, uv.y == 20.0 ? 1.0 : 0.0) * WINDOW_FS_CURSOR_SIZE; + gl_Position = vec4(window_fs_ndc(cursor + corner), window_fs_depth(WINDOW_FS_CURSOR_LAYER), 1.0); + texCoord0 = (WINDOW_FS_ARMOR_CURSOR + corner) / vec2(textureSize(Sampler0, 0)); +} +#endif + +void main() { +#if defined(NO_OVERLAY) && defined(PER_FACE_LIGHTING) + if (window_fs_is_cursor_armor()) { + window_fs_horse_cursor(); + return; + } +#endif + gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0); + + sphericalVertexDistance = fog_spherical_distance(Position); + cylindricalVertexDistance = fog_cylindrical_distance(Position); + +#ifdef PER_FACE_LIGHTING + vec2 light = minecraft_compute_light(Light0_Direction, Light1_Direction, Normal); + vertexPerFaceColorBack = minecraft_mix_light_separate(-light, Color); + vertexPerFaceColorFront = minecraft_mix_light_separate(light, Color); +#elif defined(NO_CARDINAL_LIGHTING) + vertexColor = Color; +#else + vertexColor = minecraft_mix_light(Light0_Direction, Light1_Direction, Normal, Color); +#endif + +#ifndef EMISSIVE + lightMapColor = sample_lightmap(Sampler2, UV2); +#endif + +#ifndef NO_OVERLAY + overlayColor = texelFetch(Sampler1, UV1, 0); +#endif + + texCoord0 = UV0; + +#ifdef APPLY_TEXTURE_MATRIX + texCoord0 = (TextureMat * vec4(UV0, 0.0, 1.0)).xy; +#endif +} +"#; + +/// Renders `core/entity.vsh`. Equipment layers (`NO_OVERLAY` with `PER_FACE_LIGHTING`) whose texture starts with +/// the armour signature draw the cursor; everything else takes vanilla's path. +pub(in crate::hud) fn render_horse_entity_vertex() -> String { + let join = |values: &mut dyn Iterator| values.collect::>().join(", "); + ENTITY_VERTEX + .replace("@PLACEMENT@", CANVAS_PLACEMENT) + .replace("@SIGNATURE@", &join(&mut ARMOR_SIGNATURE.iter().map(u8::to_string))) + .replace("@CURSOR_X@", &ARMOR_CURSOR_X.to_string()) + .replace("@HEAD_PIVOT@", &join(&mut HEAD_PIVOT.iter().map(|value| format!("{value:?}")))) + .replace("@HEAD_TOP@", &format!("{HEAD_TOP:?}")) + .replace("@OFFSET@", &join(&mut OFFSET.iter().map(|value| format!("{value:?}")))) + .replace("@DEPTH_STEP@", &format!("{LAYER_DEPTH_STEP:?}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fullscreen::{DEGREES_PER_PIXEL, cursor}; + + const SCALE: f32 = 1.1 / 16.0; + const REST_PITCH: f32 = 30.0; + + /// The top face's corners in the head parts' texels: (x, z) of each, with the face at `HEAD_TOP`. + const CORNERS: [[f32; 2]; 4] = [[3.1, 5.1], [-3.1, 5.1], [3.1, -2.1], [-3.1, -2.1]]; + + /// A corner's position relative to the camera for a horse at `OFFSET` turned to `yaw` with its head pitched to + /// `head_pitch`, as the client's model transforms place it. It is the inverse of `recover`. + fn place(yaw: f64, head_pitch: f64, [x, z]: [f32; 2]) -> [f32; 3] { + let (x, z) = (f64::from(x), f64::from(z)); + let scale = f64::from(SCALE); + let tilt = head_pitch.to_radians(); + let down = HEAD_TOP * tilt.cos() - z * tilt.sin(); + let forward = HEAD_TOP * tilt.sin() + z * tilt.cos(); + let (local_x, local_y, local_z) = (-scale * x, HEAD_PIVOT[1] - scale * down, HEAD_PIVOT[2] + scale * forward); + let turn = (180.0 - yaw).to_radians(); + let turned_x = local_x * turn.cos() + local_z * turn.sin(); + let turned_z = -local_x * turn.sin() + local_z * turn.cos(); + [(turned_x + OFFSET[0]) as f32, (local_y + OFFSET[1]) as f32, (turned_z + OFFSET[2]) as f32] + } + + /// The shader's recovery in single precision: a corner's yaw and head pitch from its position alone. + fn recover(position: [f32; 3], [x, z]: [f32; 2]) -> (f32, f32) { + let local = [position[0] - OFFSET[0] as f32, position[1] - OFFSET[1] as f32, position[2] - OFFSET[2] as f32]; + let unturned_x = -SCALE * x; + let unturned_z = -(local[0] * local[0] + local[2] * local[2] - unturned_x * unturned_x).max(0.0).sqrt(); + let down = (HEAD_PIVOT[1] as f32 - local[1]) / SCALE; + let forward = (unturned_z - HEAD_PIVOT[2] as f32) / SCALE; + let head_pitch = (forward.atan2(down) - z.atan2(HEAD_TOP as f32)).to_degrees(); + let head_pitch = head_pitch - 360.0 * ((head_pitch + 180.0) / 360.0).floor(); + let yaw = 180.0 - local[0].atan2(local[2]).to_degrees() + unturned_x.atan2(unturned_z).to_degrees(); + (yaw - 360.0 * ((yaw + 180.0) / 360.0).floor(), head_pitch) + } + + /// The cursor the shader draws for a corner, from `recover` and the include's mapping. + fn drawn(position: [f32; 3], corner: [f32; 2]) -> (f64, f64) { + let (yaw, head_pitch) = recover(position, corner); + (f64::from(yaw), 2.0 * f64::from(head_pitch - REST_PITCH)) + } + + #[test] + fn recovers_the_rotation_from_the_positions_vanilla_gives_the_head_corners() { + // Each pose's corners were taken from the client's own armour model and render transforms, as + // (yaw, rider pitch) and the camera-relative positions of the (+x, +z), (-x, +z), (+x, -z), (-x, -z) corners. + let poses = [ + ( + (-33.66, 17.9), + [ + [0.749422, 0.439996, 3.740893], + [0.394637, 0.439996, 3.977148], + [0.962791, 0.128818, 4.061310], + [0.608005, 0.128818, 4.297565], + ], + ), + ( + (45.0, 45.0), + [ + [-0.709833, 0.368831, 4.011238], + [-1.011238, 0.368831, 3.709833], + [-0.922911, -0.023879, 4.224315], + [-1.224315, -0.023879, 3.922910], + ], + ), + ( + (-120.0, 80.0), + [ + [1.125084, 0.216584, 2.104337], + [1.338209, 0.216584, 2.473481], + [1.271702, -0.248564, 2.019687], + [1.484827, -0.248564, 2.388831], + ], + ), + ]; + for ((yaw, rider_pitch), corners) in poses { + for (position, corner) in corners.into_iter().zip(CORNERS) { + let (got_yaw, head_pitch) = recover(position.map(|value| value as f32), corner); + assert!((got_yaw - yaw).abs() < 0.002, "yaw {yaw}: {got_yaw}"); + let want = REST_PITCH + rider_pitch / 2.0; + assert!((head_pitch - want).abs() < 0.002, "pitch {rider_pitch}: {}", head_pitch * 2.0 - 60.0); + } + } + } + + #[test] + fn the_drawn_cursor_is_the_servers_across_the_whole_canvas() { + // Each pixel's center, from the edges in: well clear of the boundaries the snap epsilon decides. + let (width, height) = (480, 270); + for y in (0..height).step_by(13).chain([height - 1]) { + for x in (0..width).step_by(17).chain([width - 1]) { + let yaw = (f64::from(x) + 0.5 - f64::from(width) / 2.0) * DEGREES_PER_PIXEL; + let pitch = (f64::from(y) + 0.5 - f64::from(height) / 2.0) * DEGREES_PER_PIXEL; + let want = cursor(yaw, pitch); + for corner in CORNERS { + let (got_yaw, got_pitch) = drawn(place(yaw, REST_PITCH as f64 + pitch / 2.0, corner), corner); + assert_eq!(cursor(got_yaw, got_pitch), want, "{x}, {y} via {corner:?}"); + } + } + } + } + + #[test] + fn a_horse_off_its_offset_reads_as_turned() { + // Why the session pins the horse: a rise of 0.04 blocks alone moves the cursor several pixels. + let corner = CORNERS[1]; + let mut position = place(0.0, REST_PITCH as f64, corner); + position[1] += 0.04; + let (_, pitch) = drawn(position, corner); + assert!(pitch.abs() > 2.0 * DEGREES_PER_PIXEL, "{pitch}"); + } +} diff --git a/crates/window-core/src/hud/renderer/fullscreen.rs b/crates/window-core/src/hud/renderer/fullscreen.rs index ad182687..62bec363 100644 --- a/crates/window-core/src/hud/renderer/fullscreen.rs +++ b/crates/window-core/src/hud/renderer/fullscreen.rs @@ -22,6 +22,25 @@ pub(super) const FULLSCREEN_VERTEX_APPLY: &str = " } #endif"; +/// Places a canvas point on the screen and a layer at its depth: `window_fs_ndc` and `window_fs_depth`. Needs +/// `ScreenSize`, the include, and `WINDOW_FS_LAYER_DEPTH_STEP`. +pub(super) const CANVAS_PLACEMENT: &str = r#"// A canvas point in normalized device coordinates, with the canvas letterboxed or pillarboxed to the screen. +vec2 window_fs_ndc(vec2 canvas) { + float screenAspect = ScreenSize.x / ScreenSize.y; + float canvasAspect = WINDOW_FS_CANVAS.x / WINDOW_FS_CANVAS.y; + vec2 fit = screenAspect > canvasAspect ? vec2(canvasAspect / screenAspect, 1.0) : vec2(1.0, screenAspect / canvasAspect); + vec2 unit = canvas / WINDOW_FS_CANVAS * 2.0 - 1.0; + return vec2(unit.x, -unit.y) * fit; +} + +// The depth of a layer in normalized device coordinates, nearer for higher layers. The level projection is reversed, +// with the near plane at 1.0, and the layers fill the 256 steps just below it, so world geometry covers one only +// within a negligible distance of the near plane. ProjMat is not consulted, since it carries +// the hurt tilt, view bobbing, and nausea, which can move a projected point past the near plane and clip it. +float window_fs_depth(int layer) { + return 1.0 - float(WINDOW_FS_CURSOR_LAYER - layer + 1) * WINDOW_FS_LAYER_DEPTH_STEP; +}"#; + const VERTEX_HELPERS: &str = r#" #if !defined(IS_GUI) && !defined(IS_SEE_THROUGH) // The top-left of the text display's single zero-width line in its text space, where a shifted font's row 0 draws. @@ -39,22 +58,7 @@ vec2 window_fs_camera_rotation() { return vec2(degrees(atan(-right.z, -right.x)), degrees(asin(clamp(-lookY, -1.0, 1.0)))); } -// A canvas point in normalized device coordinates, with the canvas letterboxed or pillarboxed to the screen. -vec2 window_fs_ndc(vec2 canvas) { - float screenAspect = ScreenSize.x / ScreenSize.y; - float canvasAspect = WINDOW_FS_CANVAS.x / WINDOW_FS_CANVAS.y; - vec2 fit = screenAspect > canvasAspect ? vec2(canvasAspect / screenAspect, 1.0) : vec2(1.0, screenAspect / canvasAspect); - vec2 unit = canvas / WINDOW_FS_CANVAS * 2.0 - 1.0; - return vec2(unit.x, -unit.y) * fit; -} - -// The depth of a layer in normalized device coordinates, nearer for higher layers. The level projection is reversed, -// with the near plane at 1.0, and the layers fill the 256 steps just below it, so world geometry covers one only -// within a negligible distance of the near plane. ProjMat is not consulted, since it carries -// the hurt tilt, view bobbing, and nausea, which can move a projected point past the near plane and clip it. -float window_fs_depth(int layer) { - return 1.0 - float(WINDOW_FS_CURSOR_LAYER - layer + 1) * WINDOW_FS_LAYER_DEPTH_STEP; -} +@PLACEMENT@ void window_fs_apply() { int payload = window_fs_payload(Color); @@ -85,7 +89,7 @@ void window_fs_apply() { "#; pub(super) fn render_fullscreen_vertex_helpers() -> String { - VERTEX_HELPERS.replace("@DEPTH_STEP@", &format!("{LAYER_DEPTH_STEP:?}")) + VERTEX_HELPERS.replace("@PLACEMENT@", CANVAS_PLACEMENT).replace("@DEPTH_STEP@", &format!("{LAYER_DEPTH_STEP:?}")) } /// Renders `core/position_tex_color.vsh`, vanilla's GUI sprite shader plus the HUD strip inputs. Like vanilla, it diff --git a/crates/window-core/src/hud/renderer/mod.rs b/crates/window-core/src/hud/renderer/mod.rs index 1b8708ee..8033ade2 100644 --- a/crates/window-core/src/hud/renderer/mod.rs +++ b/crates/window-core/src/hud/renderer/mod.rs @@ -1,4 +1,5 @@ mod background; +mod entity; mod fragment; mod fullscreen; mod glsl; @@ -11,6 +12,7 @@ pub(super) use background::{ render_v150_dynamic_text_background_shader, render_v150_text_background_shader, render_v330_define_variant_text_background_shader, render_v330_text_background_shader, }; +pub(super) use entity::render_horse_entity_vertex; pub(super) use fragment::{ render_dynamic_text_intensity_fragment, render_v150_text_intensity_fragment, render_v330_define_variant_text_fragment, diff --git a/crates/window-core/src/manifest.rs b/crates/window-core/src/manifest.rs index 0276c016..e09cf59e 100644 --- a/crates/window-core/src/manifest.rs +++ b/crates/window-core/src/manifest.rs @@ -15,7 +15,9 @@ use crate::{Error, Result}; mod fullscreen; mod hud; -pub use fullscreen::{ControlEntry, CursorEntry, FullscreenCanvasEntry, FullscreenEntry, FullscreenSurfaceEntry}; +pub use fullscreen::{ + ControlEntry, CursorEntry, FullscreenCanvasEntry, FullscreenEntry, FullscreenSurfaceEntry, HorseEntry, +}; pub use hud::{HudEntry, HudShaderEntry, HudSurfaceEntry}; /// Current compiled definition schema version. diff --git a/crates/window-core/src/manifest/fullscreen.rs b/crates/window-core/src/manifest/fullscreen.rs index 927f3dcd..10b5896a 100644 --- a/crates/window-core/src/manifest/fullscreen.rs +++ b/crates/window-core/src/manifest/fullscreen.rs @@ -122,6 +122,18 @@ pub struct FullscreenCanvasEntry { pub hud_strip_half_width: u32, /// The most max health the client is told while the HUD is hidden. pub hud_max_health: u32, + /// The horse of horse-mode windows, present when the pack has one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub horse: Option, +} + +/// The horse that horse-mode full-screen windows read the cursor from; see [`crate::fullscreen::horse`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct HorseEntry { + /// Where the horse stands relative to the camera `[x, y, z]`, in blocks, with the camera facing yaw and pitch 0. + pub offset: [f64; 3], + /// The equipment asset id of the cursor armour the horse wears in its body slot. + pub armor: String, } /// The built-in cursor glyph. diff --git a/crates/window-core/src/pipeline/fullscreen.rs b/crates/window-core/src/pipeline/fullscreen.rs index dac857a7..c50e1ea3 100644 --- a/crates/window-core/src/pipeline/fullscreen.rs +++ b/crates/window-core/src/pipeline/fullscreen.rs @@ -9,7 +9,7 @@ use crate::fullscreen::{ self, BACKDROP_LAYER, CURSOR_LAYER, MAX_COLORS, MAX_CONTENT_LAYER, Marker, MarkerKind, OVERLAY_LAYER, STATIC_LAYER, }; use crate::geometry::{Point, Rect, Size}; -use crate::ir::{LaidOutFullscreen, Layer, RegionIr, Rgb, tint_palette, tint_palettes}; +use crate::ir::{FullscreenMode, LaidOutFullscreen, Layer, RegionIr, Rgb, tint_palette, tint_palettes}; use crate::manifest::{ ControlEntry, CursorEntry, FullscreenCanvasEntry, FullscreenEntry, FullscreenSurfaceEntry, SlotEntry, }; @@ -22,6 +22,8 @@ use super::metrics::bitmap_metrics; use super::window::slot_entry; use super::{CompileContext, OutputFile}; +mod horse; + const WHITE: Rgb = Rgb::new(0xff, 0xff, 0xff); /// The item model, under the pack namespace, that draws nothing: the runtime fills the hotbar with it while a @@ -32,11 +34,13 @@ const BLANK_ITEM: &str = "fullscreen/blank"; pub(super) struct Contract { colors: Vec, controls: Vec, + /// Whether a horse-mode window was compiled. + horse: bool, } impl Default for Contract { fn default() -> Self { - Self { colors: vec![WHITE], controls: Vec::new() } + Self { colors: vec![WHITE], controls: Vec::new(), horse: false } } } @@ -66,6 +70,7 @@ pub(super) fn compile_fullscreen( contract: &mut Contract, ) -> Result { ctx.warnings.extend(f.warnings.iter().cloned()); + contract.horse |= f.mode == FullscreenMode::Horse; let pane = Pane { kind: PaneKind::Fullscreen, name: &f.name }; let origin = Point::new(0, 0); let file_base = format!("fullscreen/window/{}", f.name); @@ -175,7 +180,8 @@ pub(super) fn compile_fullscreen( }) } -/// Emits the built-in cursor and backdrop glyphs and the include, and returns the canvas entry. +/// Emits the built-in cursor and backdrop glyphs, the include, and with a horse-mode window the horse's files, and +/// returns the canvas entry. pub(super) fn emit_contract(ctx: &mut CompileContext<'_>, mut contract: Contract) -> Result { let cursor_texture = fullscreen::cursor_texture(); let cursor_size = Size::new(cursor_texture.width, cursor_texture.height); @@ -187,6 +193,7 @@ pub(super) fn emit_contract(ctx: &mut CompileContext<'_>, mut contract: Contract ctx.push(Part::Fullscreen, OutputFile::text(fullscreen::INCLUDE_PATH, include)); let blank = to_json(&serde_json::json!({ "model": { "type": "minecraft:empty" } }))?; ctx.push(Part::Fullscreen, OutputFile::text(format!("assets/{}/items/{BLANK_ITEM}.json", ctx.namespace), blank)); + let horse = if contract.horse { Some(horse::emit(ctx, &cursor_texture)?) } else { None }; Ok(FullscreenCanvasEntry { width: fullscreen::CANVAS.width, height: fullscreen::CANVAS.height, @@ -209,6 +216,7 @@ pub(super) fn emit_contract(ctx: &mut CompileContext<'_>, mut contract: Contract hud_strip_height: fullscreen::HUD_STRIP_HEIGHT, hud_strip_half_width: fullscreen::HUD_STRIP_HALF_WIDTH, hud_max_health: fullscreen::HUD_MAX_HEALTH, + horse, }) } diff --git a/crates/window-core/src/pipeline/fullscreen/horse.rs b/crates/window-core/src/pipeline/fullscreen/horse.rs new file mode 100644 index 00000000..647046de --- /dev/null +++ b/crates/window-core/src/pipeline/fullscreen/horse.rs @@ -0,0 +1,22 @@ +//! The pack files of horse-mode full-screen windows; see [`crate::fullscreen::horse`]. + +use crate::Result; +use crate::compose::Texture; +use crate::fullscreen::horse; +use crate::manifest::HorseEntry; +use crate::size::Part; + +use super::super::fonts::to_json; +use super::super::{CompileContext, OutputFile}; + +/// Emits the cursor armour, drawing `cursor`, and the entity shader that recognises it, and returns the horse entry. +pub(super) fn emit(ctx: &mut CompileContext<'_>, cursor: &Texture) -> Result { + let armor = format!("{}:{}", ctx.namespace, horse::ARMOR_ASSET); + let equipment = to_json(&serde_json::json!({ "layers": { "horse_body": [{ "texture": armor }] } }))?; + ctx.push(Part::Fullscreen, OutputFile::text(horse::equipment_path(ctx.namespace), equipment)); + let texture = horse::armor_texture(cursor).encode_png()?; + ctx.push(Part::Fullscreen, OutputFile::binary(horse::texture_path(ctx.namespace), texture)); + let shader = crate::hud::horse_entity_shader(); + ctx.push(Part::Shader, OutputFile::text(shader.path, shader.contents)); + Ok(HorseEntry { offset: horse::OFFSET, armor }) +} diff --git a/crates/window-core/src/pipeline/tests/fullscreens.rs b/crates/window-core/src/pipeline/tests/fullscreens.rs index 38b7dd1f..55d046be 100644 --- a/crates/window-core/src/pipeline/tests/fullscreens.rs +++ b/crates/window-core/src/pipeline/tests/fullscreens.rs @@ -237,3 +237,43 @@ fn outlines_hovered_controls_and_emits_the_blank_item_model() { let blank = file("assets/window/items/fullscreen/blank.json"); assert_eq!(blank.contents.as_bytes(), b"{\"model\":{\"type\":\"minecraft:empty\"}}\n"); } + +#[test] +fn horse_mode_emits_the_cursor_armour_and_entity_shader() { + let shaders = |out: &CompileOutput| -> Vec { + let names = out.files.iter().filter_map(|file| file.path.strip_prefix("assets/minecraft/shaders/core/")); + names.map(str::to_string).collect() + }; + let unlocked = compile(json!({ "name": "menu", "children": [] })).unwrap(); + assert!(unlocked.manifest.fullscreen_canvas.as_ref().unwrap().horse.is_none()); + assert!(!shaders(&unlocked).contains(&"entity.vsh".to_string())); + assert!(unlocked.files.iter().all(|file| !file.path.contains("equipment"))); + + let out = compile(json!({ "name": "lookout", "mode": "horse", "children": [] })).unwrap(); + assert_eq!( + shaders(&out), + ["entity.vsh", "position_tex_color.fsh", "position_tex_color.vsh", "text.fsh", "text.vsh"] + ); + let horse = out.manifest.fullscreen_canvas.as_ref().unwrap().horse.as_ref().expect("the horse entry is present"); + assert_eq!(horse.offset, fullscreen::horse::OFFSET); + assert_eq!(horse.armor, "window:fullscreen/horse_cursor"); + let file = |path: &str| out.files.iter().find(|file| file.path == path).unwrap_or_else(|| panic!("{path}")); + let equipment = file("assets/window/equipment/fullscreen/horse_cursor.json"); + assert_eq!( + equipment.contents.as_bytes(), + b"{\"layers\":{\"horse_body\":[{\"texture\":\"window:fullscreen/horse_cursor\"}]}}\n" + ); + let armor = file("assets/window/textures/entity/equipment/horse_body/fullscreen/horse_cursor.png"); + let armor = Texture::decode_png(armor.contents.as_bytes()).unwrap(); + assert_eq!(armor.get(0, 0), fullscreen::horse::ARMOR_SIGNATURE); + let entity = file("assets/minecraft/shaders/core/entity.vsh"); + let entity = std::str::from_utf8(entity.contents.as_bytes()).unwrap(); + assert!(entity.contains("const ivec4 WINDOW_FS_ARMOR_SIGNATURE = ivec4(253, 1, 253, 255);"), "{entity}"); + assert!(entity.contains("const vec3 WINDOW_FS_HORSE_OFFSET = vec3(0.0, -1.75, 3.0);"), "{entity}"); + assert!(entity.contains("float window_fs_depth(int layer)"), "{entity}"); + + let parsed = Manifest::from_json(&out.manifest.to_json_bytes().unwrap()).unwrap(); + assert_eq!(&parsed, &out.manifest); + let error = compile_at(json!({ "name": "lookout", "mode": "horse", "children": [] }), json!(84)); + assert!(error.unwrap_err().to_string().contains("full-screen windows are drawn by a core text shader")); +} diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt index 54638a8e..9889cd2d 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt @@ -3,6 +3,7 @@ package com.chunkzero.window.minestom.internal import com.chunkzero.window.FullscreenDefinition import com.chunkzero.window.host.FullscreenListener import com.chunkzero.window.host.OpenFullscreen +import com.chunkzero.window.manifest.FullscreenMode import net.kyori.adventure.text.Component import net.minestom.server.component.DataComponents import net.minestom.server.coordinate.Pos @@ -21,10 +22,12 @@ import net.minestom.server.entity.metadata.other.InteractionMeta import net.minestom.server.event.EventFilter import net.minestom.server.event.EventNode import net.minestom.server.event.player.PlayerCustomClickEvent +import net.minestom.server.event.player.PlayerDeathEvent import net.minestom.server.event.player.PlayerDisconnectEvent import net.minestom.server.event.player.PlayerMoveEvent import net.minestom.server.event.player.PlayerPacketEvent import net.minestom.server.event.player.PlayerPacketOutEvent +import net.minestom.server.event.player.PlayerSpawnEvent import net.minestom.server.item.ItemStack import net.minestom.server.item.Material import net.minestom.server.network.packet.client.play.ClientAnimationPacket @@ -84,6 +87,14 @@ import net.minestom.server.world.clock.WorldClock * snapshots, so every packet that changes the world age carries the real clock values, or the hidden age would move * the clocks with it. * + * The window ends, restoring the player where they died, when they die, so the respawn that follows finds a client + * with nothing left to lose. It also ends when they spawn into a different instance than the one it opened in, since + * the client then drops its client-only entities and camera; the player is already at the destination then, so they are + * not teleported back. A window opened by a spawn listener is in the spawn's instance and stays open. + * + * In horse mode, [MinestomFullscreenHorse] also locks the view. The client then draws no hearts, since its camera is + * not a player, so their max health and absorption are left alone. + * * A prompt's callback runs on the player's thread after the state it reports has changed, never under [lock]: a server * container opening over the dialog cancels it on the next tick, and a window ending cancels it once the player is * restored, just before [FullscreenListener.onClose]. @@ -116,6 +127,13 @@ internal class MinestomFullscreen private constructor( private val abilities = PlayerAbilitiesPacket(ABILITY_FLAGS, 0f, WALKING_SPEED) private val lock = Any() private var open = false + private val instance = player.instance + private val horse = + if (definition.surface.mode == FullscreenMode.HORSE) { + MinestomFullscreenHorse(player, checkNotNull(canvas.horse) { "The pack has no horse for horse mode" }) + } else { + null + } /** The held slot the client has selected, which held-slot changes are measured from. Requires [lock]. */ private var clientSlot = heldSlot.toInt() @@ -157,7 +175,7 @@ internal class MinestomFullscreen private constructor( override fun close() { if (!stop()) return try { - restore() + restore(returnToStart = true) } finally { textPrompt.cancel() } @@ -165,7 +183,8 @@ internal class MinestomFullscreen private constructor( private fun start(content: Component) { check(!player.hasTag(ACTIVE)) { "${player.username} already has a full-screen window open" } - checkNotNull(player.instance) { "${player.username} is in no instance" } + checkNotNull(instance) { "${player.username} is in no instance" } + check(!player.isDead) { "${player.username} is dead or respawning" } check(player.setGameMode(GameMode.SPECTATOR)) { "Opening a full-screen window for ${player.username} was cancelled by a game mode listener" } @@ -176,8 +195,14 @@ internal class MinestomFullscreen private constructor( player.setTag(ACTIVE, true) player.flyingSpeed = 0f player.teleport(anchor) - // The game mode change also updated the client's own tab-list entry, so survival is faked after it. - player.scheduler().scheduleNextTick { whileOpen { fakeSurvival() } } + // The game mode change also updated the client's own tab-list entry, so survival is faked after it. The horse + // follows, so a spectator client never draws the invisible horse as a translucent ghost. + player.scheduler().scheduleNextTick { + whileOpen { + fakeSurvival() + horse?.lock(anchor.add(0.0, player.eyeHeight, 0.0)) + } + } spawn(content) if (!containerOpen) blankHotbar() hideHud() @@ -251,7 +276,7 @@ internal class MinestomFullscreen private constructor( private fun hideHud() { if (!containerOpen) hideTime(realTime().clocks()) capMaxHealth() - player.sendPacket(EntityMetaDataPacket(player.entityId, mapOf(ABSORPTION to ZERO))) + if (horse == null) player.sendPacket(EntityMetaDataPacket(player.entityId, mapOf(ABSORPTION to ZERO))) } private fun hideTime(clocks: Map, SetTimePacket.ClockState>) { @@ -264,10 +289,13 @@ internal class MinestomFullscreen private constructor( cappedMaxHealth()?.let { player.sendPacket(EntityAttributesPacket(player.entityId, listOf(it))) } } - /** The modifier-free max health the client is told while the HUD is hidden, or `null` when the real one fits. */ + /** + * The modifier-free max health the client is told while the HUD is hidden, or `null` when the real one fits or the + * hearts are not drawn. + */ private fun cappedMaxHealth(): EntityAttributesPacket.Property? { val cap = canvas.hudMaxHealth.toDouble() - if (player.getAttribute(Attribute.MAX_HEALTH).value <= cap) return null + if (horse != null || player.getAttribute(Attribute.MAX_HEALTH).value <= cap) return null return EntityAttributesPacket.Property(Attribute.MAX_HEALTH, cap, emptyList()) } @@ -280,6 +308,11 @@ internal class MinestomFullscreen private constructor( } node.addListener(PlayerCustomClickEvent::class.java, textPrompt::onClick) node.addListener(PlayerDisconnectEvent::class.java) { end(restore = false) } + node.addListener(PlayerDeathEvent::class.java) { end(restore = true) } + // The client clears its client-only entities and camera when it changes world. + node.addListener(PlayerSpawnEvent::class.java) { event -> + if (event.instance != instance) end(restore = true, moved = true) + } player.eventNode().addChild(node) } @@ -359,7 +392,7 @@ internal class MinestomFullscreen private constructor( } is EntityMetaDataPacket -> { - val absorption = packet.entries()[ABSORPTION]?.value() + val absorption = packet.entries()[ABSORPTION]?.value()?.takeIf { horse == null } if (packet.entityId() == player.entityId && absorption != null && absorption != 0f) { player.sendPacket(EntityMetaDataPacket(player.entityId, mapOf(ABSORPTION to ZERO))) } @@ -463,11 +496,17 @@ internal class MinestomFullscreen private constructor( synchronized(lock) { if (open) action() } } - /** Ends the window after the player left it, restoring them unless they disconnected. */ - private fun end(restore: Boolean) { + /** + * Ends the window after the player left it, restoring them unless they disconnected. A [moved] player was placed + * elsewhere by an instance change, so they are not teleported back. + */ + private fun end( + restore: Boolean, + moved: Boolean = false, + ) { if (!stop()) return try { - if (restore) restore() + if (restore) restore(returnToStart = !moved) } finally { try { textPrompt.cancel() @@ -487,7 +526,8 @@ internal class MinestomFullscreen private constructor( true } - private fun restore() { + private fun restore(returnToStart: Boolean) { + horse?.unlock() textPrompt.clear() player.sendPacket(DestroyEntitiesPacket(listOf(display.entityId, clickTarget.entityId))) // A mode the server set while the window was open replaces the one it began with. @@ -500,7 +540,7 @@ internal class MinestomFullscreen private constructor( player.isInstantBreak = instantBreak } player.flyingSpeed = flyingSpeed - player.teleport(position) + if (returnToStart) player.teleport(position) player.sendPacket(realTime()) val maxHealth = player.getAttribute(Attribute.MAX_HEALTH) val property = EntityAttributesPacket.Property(Attribute.MAX_HEALTH, maxHealth.baseValue, maxHealth.modifiers()) @@ -534,8 +574,9 @@ internal class MinestomFullscreen private constructor( /** * Shows [definition] drawn by [content] to [player], then listens. * - * @throws IllegalStateException if the player already has a full-screen window open, or a game mode listener - * cancelled the switch to spectator mode. + * @throws IllegalStateException if the player already has a full-screen window open, is dead or respawning + * (a respawn listener should open on the next tick), or a game mode listener cancelled the switch to spectator + * mode. */ fun open( player: Player, diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreenHorse.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreenHorse.kt new file mode 100644 index 00000000..9495f6c3 --- /dev/null +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreenHorse.kt @@ -0,0 +1,120 @@ +package com.chunkzero.window.minestom.internal + +import com.chunkzero.window.manifest.HorseEntry +import net.kyori.adventure.text.Component +import net.minestom.server.component.DataComponents +import net.minestom.server.coordinate.Pos +import net.minestom.server.coordinate.Vec +import net.minestom.server.entity.Entity +import net.minestom.server.entity.EntityType +import net.minestom.server.entity.EquipmentSlot +import net.minestom.server.entity.Player +import net.minestom.server.entity.attribute.Attribute +import net.minestom.server.entity.metadata.animal.AbstractHorseMeta +import net.minestom.server.item.ItemStack +import net.minestom.server.item.Material +import net.minestom.server.network.packet.server.play.ActionBarPacket +import net.minestom.server.network.packet.server.play.CameraPacket +import net.minestom.server.network.packet.server.play.DestroyEntitiesPacket +import net.minestom.server.network.packet.server.play.EntityAttributesPacket +import net.minestom.server.network.packet.server.play.EntityEquipmentPacket +import net.minestom.server.network.packet.server.play.SetPassengersPacket +import net.minestom.server.network.packet.server.play.SpawnEntityPacket + +/** + * The locked view of a horse-mode full-screen window, as [horse] describes. + * + * [player]'s camera spectates an invisible client-only bat at their eye, so their view stays still. They ride an + * invisible, saddled, client-only horse wearing the pack's cursor armour. Every client tick, the client turns the horse + * it steers to the player's yaw and half their pitch, and the pack's entity shader draws the cursor from the armour. As + * a passenger, the client still sends the player's rotation every tick, which the server hit-tests as usual. The + * client does not draw its own player, or its hotbar and hearts, while another entity is the camera. + * + * The shader reads the horse's rotation from where its head corners are, so the horse must stay exactly at its offset. + * The client moves a ridden horse itself, and fluids still push and float it however still its attributes are. So the + * horse rides an item display, which nothing moves: the client puts a passenger back on its vehicle after every tick of + * its own. + */ +internal class MinestomFullscreenHorse( + private val player: Player, + private val horse: HorseEntry, +) { + private val camera = + Entity(EntityType.BAT).apply { + isInvisible = true + isSilent = true + setNoGravity(true) + } + private val seat = Entity(EntityType.ITEM_DISPLAY) + private val mount = + Entity(EntityType.HORSE).apply { + isInvisible = true + isSilent = true + setNoGravity(true) + editEntityMeta(AbstractHorseMeta::class.java) { meta -> + // The client only accepts a saddle on a tamed horse. + meta.isTamed = true + meta.isNoAi = true + } + } + + /** + * Spawns the bat at [eye] and the horse and its seat at the horse's offset, all facing yaw and pitch 0, then locks + * the view. + */ + fun lock(eye: Pos) { + val standing = eye.add(horse.offset[0], horse.offset[1], horse.offset[2]) + spawn(camera, eye.sub(0.0, camera.eyeHeight, 0.0)) + spawn(seat, standing) + spawn(mount, standing) + val equipment = mapOf(EquipmentSlot.SADDLE to SADDLE, EquipmentSlot.BODY to armor(horse.armor)) + player.sendPacket(EntityEquipmentPacket(mount.entityId, equipment)) + // The client steers the horse, so it must not move it. + val still = + listOf(Attribute.MOVEMENT_SPEED, Attribute.JUMP_STRENGTH).map { + EntityAttributesPacket.Property(it, 0.0, emptyList()) + } + player.sendPacket(EntityAttributesPacket(mount.entityId, still)) + player.sendPacket(SetPassengersPacket(seat.entityId, listOf(mount.entityId))) + player.sendPacket(SetPassengersPacket(mount.entityId, listOf(player.entityId))) + // Replaces the dismount hint the client shows on mounting. + player.sendPacket(ActionBarPacket(Component.empty())) + player.sendPacket(CameraPacket(camera.entityId)) + } + + /** + * Returns the camera to the player, dismounts them, and removes the bat, horse, and seat. A player who was riding a + * real vehicle is put back on it, since mounting the horse made their client leave it. + */ + fun unlock() { + player.sendPacket(CameraPacket(player.entityId)) + player.sendPacket(SetPassengersPacket(mount.entityId, emptyList())) + player.vehicle?.let { vehicle -> + player.sendPacket(SetPassengersPacket(vehicle.entityId, vehicle.passengers.map { it.entityId })) + } + player.sendPacket(DestroyEntitiesPacket(listOf(camera.entityId, mount.entityId, seat.entityId))) + } + + private fun spawn( + entity: Entity, + at: Pos, + ) { + player.sendPacket(SpawnEntityPacket(entity.entityId, entity.uuid, entity.entityType, at, 0f, 0, Vec.ZERO)) + player.sendPacket(entity.metadataPacket) + } + + private companion object { + /** Without an equipment asset the saddle draws nothing, but the horse still counts as saddled. */ + val SADDLE = equippable(Material.SADDLE, null) + + fun armor(asset: String) = equippable(Material.COPPER_HORSE_ARMOR, asset) + + fun equippable( + material: Material, + asset: String?, + ): ItemStack { + val equippable = requireNotNull(material.prototype().get(DataComponents.EQUIPPABLE)) + return ItemStack.of(material).with(DataComponents.EQUIPPABLE, equippable.withAssetId(asset)) + } + } +} diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index d7b8d750..f66a5445 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -10,9 +10,11 @@ import com.chunkzero.window.manifest.FullscreenCanvasEntry import com.chunkzero.window.manifest.FullscreenEntry import com.chunkzero.window.manifest.FullscreenMode import com.chunkzero.window.manifest.FullscreenSurfaceEntry +import com.chunkzero.window.manifest.HorseEntry import com.chunkzero.window.manifest.WindowManifest import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe import net.kyori.adventure.key.Key @@ -25,7 +27,9 @@ import net.minestom.server.coordinate.Vec import net.minestom.server.dialog.Dialog import net.minestom.server.dialog.DialogAction import net.minestom.server.dialog.DialogInput +import net.minestom.server.entity.Entity import net.minestom.server.entity.EntityType +import net.minestom.server.entity.EquipmentSlot import net.minestom.server.entity.GameMode import net.minestom.server.entity.Metadata import net.minestom.server.entity.MetadataDef @@ -33,10 +37,12 @@ import net.minestom.server.entity.Player import net.minestom.server.entity.PlayerHand import net.minestom.server.entity.attribute.Attribute import net.minestom.server.event.EventDispatcher +import net.minestom.server.event.EventListener import net.minestom.server.event.player.PlayerCustomClickEvent import net.minestom.server.event.player.PlayerDisconnectEvent import net.minestom.server.event.player.PlayerPacketEvent import net.minestom.server.event.player.PlayerPacketOutEvent +import net.minestom.server.event.player.PlayerSpawnEvent import net.minestom.server.inventory.Inventory import net.minestom.server.inventory.InventoryType import net.minestom.server.item.ItemStack @@ -53,15 +59,19 @@ import net.minestom.server.network.packet.server.SendablePacket import net.minestom.server.network.packet.server.ServerPacket import net.minestom.server.network.packet.server.common.ClearDialogPacket import net.minestom.server.network.packet.server.common.ShowDialogPacket +import net.minestom.server.network.packet.server.play.CameraPacket import net.minestom.server.network.packet.server.play.ChangeGameStatePacket import net.minestom.server.network.packet.server.play.CloseWindowPacket import net.minestom.server.network.packet.server.play.DestroyEntitiesPacket import net.minestom.server.network.packet.server.play.EntityAttributesPacket +import net.minestom.server.network.packet.server.play.EntityEquipmentPacket import net.minestom.server.network.packet.server.play.EntityMetaDataPacket import net.minestom.server.network.packet.server.play.HeldItemChangePacket import net.minestom.server.network.packet.server.play.OpenWindowPacket import net.minestom.server.network.packet.server.play.PlayerAbilitiesPacket import net.minestom.server.network.packet.server.play.PlayerInfoUpdatePacket +import net.minestom.server.network.packet.server.play.RespawnPacket +import net.minestom.server.network.packet.server.play.SetPassengersPacket import net.minestom.server.network.packet.server.play.SetPlayerInventorySlotPacket import net.minestom.server.network.packet.server.play.SetSlotPacket import net.minestom.server.network.packet.server.play.SetTimePacket @@ -69,6 +79,8 @@ import net.minestom.server.network.packet.server.play.SpawnEntityPacket import net.minestom.server.network.packet.server.play.WindowItemsPacket import net.minestom.server.network.player.GameProfile import net.minestom.server.network.player.PlayerConnection +import net.minestom.server.registry.RegistryKey +import net.minestom.server.world.DimensionType import java.net.InetSocketAddress import java.net.SocketAddress import java.util.UUID @@ -271,7 +283,7 @@ class MinestomFullscreenTest : EventDispatcher.call(PlayerDisconnectEvent(fixture.player)) menu.prompts shouldContainExactly listOf(null) - menu.closeCount shouldBe 1 + menu.closes shouldBe 1 } "closing with a prompt open restores first, then reports null and onClose, even if the callback fails" { @@ -476,6 +488,160 @@ class MinestomFullscreenTest : fixture.receive(ClientCloseWindowPacket(second)) fixture.sent().single().gameTime() shouldBe HIDDEN_AGE } + + "horse mode locks the view next tick, leaves max health alone, and unlocks it on close" { + val fixture = FullscreenFixture() + val player = fixture.player + val session = Menu(player, FullscreenMode.HORSE).open() + + fixture.sent().map { it.type() } shouldContainExactly + listOf(EntityType.TEXT_DISPLAY, EntityType.INTERACTION) + fixture.sent() shouldBe emptyList() + fixture.clear() + fixture.tick() + + val spawns = fixture.sent() + spawns.map { it.type() } shouldContainExactly + listOf(EntityType.BAT, EntityType.ITEM_DISPLAY, EntityType.HORSE) + val (bat, seat, horse) = spawns + bat.position().y() shouldBe player.position.y() + player.eyeHeight - Entity(EntityType.BAT).eyeHeight + horse.position() shouldBe player.position.add(0.0, player.eyeHeight - 1.75, 3.0) + seat.position() shouldBe horse.position() + val armor = + fixture + .sent() + .single() + .equipments() + .getValue(EquipmentSlot.BODY) + armor.get(DataComponents.EQUIPPABLE)?.assetId() shouldBe "window:fullscreen/horse_cursor" + fixture.sent() shouldContainExactly + listOf( + SetPassengersPacket(seat.entityId(), listOf(horse.entityId())), + SetPassengersPacket(horse.entityId(), listOf(player.entityId)), + ) + fixture.sent().single().cameraId() shouldBe bat.entityId() + + fixture.clear() + fixture.send( + EntityAttributesPacket( + player.entityId, + listOf(EntityAttributesPacket.Property(Attribute.MAX_HEALTH, 40.0, emptyList())), + ), + ) + fixture.sent().size shouldBe 1 + + fixture.clear() + session.close() + fixture.sent().single().cameraId() shouldBe player.entityId + fixture.sent().single().passengersId() shouldBe emptyList() + fixture.sent().flatMap { it.entityIds() } shouldContainAll + listOf(bat.entityId(), horse.entityId(), seat.entityId()) + } + "a player riding a vehicle is put back on it when horse mode closes" { + val fixture = FullscreenFixture() + val player = fixture.player + val cart = Entity(EntityType.MINECART) + cart.setInstance(player.instance, player.position).join() + cart.addPassenger(player) + val session = Menu(player, FullscreenMode.HORSE).open() + fixture.tick() + + fixture.clear() + session.close() + + fixture.sent().last() shouldBe + SetPassengersPacket(cart.entityId, listOf(player.entityId)) + } + + for ((name, dimension) in mapOf("within a dimension" to "overworld", "across dimensions" to "the_nether")) { + "changing instance $name ends the window without teleporting the player back" { + val fixture = FullscreenFixture() + val player = fixture.player + val menu = Menu(player, FullscreenMode.HORSE) + menu.open() + fixture.tick() + + val elsewhere = Pos(100.0, 80.0, 100.0) + val other = + MinecraftServer.getInstanceManager().createInstanceContainer( + RegistryKey.unsafeOf("minecraft:$dimension"), + ) + fixture.clear() + player.setInstance(other, elsewhere).join() + + menu.closes shouldBe 1 + player.gameMode shouldBe GameMode.SURVIVAL + player.position shouldBe elsewhere + fixture.sent().size shouldBe if (dimension == "overworld") 0 else 1 + fixture.sent().single().cameraId() shouldBe player.entityId + fixture.sent().flatMap { it.entityIds() }.size shouldBe 5 + + // The close already happened, so leaving the server right after it does not report another. + EventDispatcher.call(PlayerDisconnectEvent(player)) + menu.closes shouldBe 1 + } + } + + "changing instance in the tick the window opened ends it" { + val fixture = FullscreenFixture() + val player = fixture.player + val menu = Menu(player) + menu.open() + val elsewhere = Pos(100.0, 80.0, 100.0) + + player.setInstance(MinecraftServer.getInstanceManager().createInstanceContainer(), elsewhere).join() + fixture.tick() + + menu.closes shouldBe 1 + player.gameMode shouldBe GameMode.SURVIVAL + player.position shouldBe elsewhere + } + + "death ends the window where the player died, and the respawn that follows leaves it ended" { + val fixture = FullscreenFixture() + val player = fixture.player + val menu = Menu(player) + menu.open() + fixture.tick() + val destination = Pos(100.0, 80.0, 100.0) + player.respawnPoint = destination + + player.kill() + menu.closes shouldBe 1 + player.gameMode shouldBe GameMode.SURVIVAL + player.position shouldBe Pos(0.0, 42.0, 0.0) + shouldThrow { Menu(player).open() } + + player.respawn() + fixture.tick() + menu.closes shouldBe 1 + player.position shouldBe destination + player.gameMode shouldBe GameMode.SURVIVAL + } + + "a window opened by a spawn listener is not ended by the spawn that opened it" { + val fixture = FullscreenFixture() + val player = fixture.player + val menu = Menu(player) + // Minestom reaches a window's listeners from an earlier listener only if the player's node listens already. + player.eventNode().addListener(PlayerSpawnEvent::class.java) { } + val opener = + EventListener.of(PlayerSpawnEvent::class.java) { + menu + .takeIf { _ -> + it.player == player + }?.open() + } + MinecraftServer.getGlobalEventHandler().addListener(opener) + val elsewhere = Pos(100.0, 80.0, 100.0) + + player.setInstance(MinecraftServer.getInstanceManager().createInstanceContainer(), elsewhere).join() + MinecraftServer.getGlobalEventHandler().removeListener(opener) + + menu.closes shouldBe 0 + player.gameMode shouldBe GameMode.SPECTATOR + player.position shouldBe elsewhere.withView(0f, 0f) + } }) { private companion object { const val HIDDEN_AGE = -1_000_000_001L @@ -484,13 +650,14 @@ class MinestomFullscreenTest : private class Menu( player: Player, -) : FullscreenView(FullscreenDefinition(manifest(), "menu"), MinestomFullscreenHost(player)) { + mode: FullscreenMode = FullscreenMode.UNLOCKED, +) : FullscreenView(FullscreenDefinition(manifest(mode), "menu"), MinestomFullscreenHost(player)) { val clicks = mutableListOf() val rightClicks = mutableListOf() val scrolls = mutableListOf() val prompts = mutableListOf() - var closed = false - var closeCount = 0 + var closes = 0 + val closed get() = closes > 0 override fun FullscreenScope.bind() { button("press") { clicks += it } @@ -507,8 +674,7 @@ private class Menu( } override fun onClose() { - closed = true - closeCount++ + closes++ } } @@ -633,7 +799,7 @@ private class PacketLog : PlayerConnection() { override fun getRemoteAddress(): SocketAddress = InetSocketAddress("127.0.0.1", 25565) } -private fun manifest(): WindowManifest { +private fun manifest(mode: FullscreenMode): WindowManifest { val magnitudes = (0..10).map { 1 shl it } val advances = magnitudes.reversed().map { -it } + magnitudes val canvas = @@ -654,6 +820,7 @@ private fun manifest(): WindowManifest { hudStripHeight = 56, hudStripHalfWidth = 96, hudMaxHealth = 20, + horse = HorseEntry(listOf(0.0, -1.75, 3.0), "window:fullscreen/horse_cursor"), ) return WindowManifest( version = WindowManifest.VERSION, @@ -666,7 +833,7 @@ private fun manifest(): WindowManifest { mapOf( "menu" to FullscreenEntry( - surface = FullscreenSurfaceEntry("fullscreen", FullscreenMode.UNLOCKED, listOf(480, 270)), + surface = FullscreenSurfaceEntry("fullscreen", mode, listOf(480, 270)), static = "S", staticMarker = "#a00400", controls = mapOf("press" to ControlEntry(0, 5, 5, 20, 10, action = "press", hover = false)), diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenDefinition.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenDefinition.kt index 113e269d..745b3a36 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenDefinition.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenDefinition.kt @@ -5,6 +5,7 @@ import com.chunkzero.window.internal.baseStyle import com.chunkzero.window.internal.requireColor import com.chunkzero.window.manifest.FullscreenCanvasEntry import com.chunkzero.window.manifest.FullscreenEntry +import com.chunkzero.window.manifest.FullscreenMode import com.chunkzero.window.manifest.FullscreenSurfaceEntry import com.chunkzero.window.manifest.HudEntry import com.chunkzero.window.manifest.HudShaderEntry @@ -61,8 +62,12 @@ public class FullscreenDefinition( /** The backdrop run, or `null` when the window shows the world behind it. */ internal val backdrop: Component? = entry.backdropMarker?.let { run(canvas.backdrop, it) } - /** The cursor run, which the text shader moves to the cursor. */ - internal val cursor: Component = run(canvas.cursor.text, canvas.cursor.marker) + /** + * The cursor run, which the text shader moves to the cursor, or `null` in horse mode, where the pack's entity shader + * draws the cursor instead. + */ + internal val cursor: Component? = + run(canvas.cursor.text, canvas.cursor.marker).takeIf { entry.surface.mode == FullscreenMode.UNLOCKED } /** The hover outline run of each control with one, by control key. */ internal val outlines: Map = diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt index 879d09c3..189c3bdf 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenSession.kt @@ -10,7 +10,6 @@ import com.chunkzero.window.internal.Reactivity import com.chunkzero.window.internal.RenderKey import com.chunkzero.window.internal.RenderScheduler import com.chunkzero.window.internal.RuntimeAction -import com.chunkzero.window.manifest.FullscreenMode import net.kyori.adventure.text.Component /** A live full-screen window opened by [FullscreenView.open]. */ @@ -81,9 +80,6 @@ internal class FullscreenWindowSession( fun open() { try { - check(entry.surface.mode == FullscreenMode.UNLOCKED) { - "Fullscreen '${definition.name}' uses horse mode, which this runtime cannot show" - } view.attach(this, reactivity) view.invokeBind(bindings) bindings.sealed = true diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt index 80936e7c..d67f0d37 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/FullscreenView.kt @@ -30,7 +30,7 @@ public abstract class FullscreenView( * A failed open still consumes the view: [onOpen] and [onClose] never run. * * @throws IllegalStateException if this view was already opened, a dynamic slot, switch, tint, or bar is unbound, an - * action lacks both a handler and a default, the window uses horse mode, or the host could not show it. + * action lacks both a handler and a default, or the host could not show it. * @throws IllegalArgumentException for unknown names referenced in [bind]. */ public fun open(): FullscreenSession { diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt index 604d60b2..af1fde48 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/host/FullscreenHost.kt @@ -11,7 +11,8 @@ public interface FullscreenHost { * * The player is pinned in place facing yaw and pitch 0, which the canvas centre maps to, and their HUD is hidden. * [content] is sent as the line of a client-only text display at their eye, as [FullscreenDefinition.canvas] - * describes. + * describes. In horse mode, the host also locks the player's view and seats them on the horse that + * [com.chunkzero.window.manifest.FullscreenCanvasEntry.horse] describes, which the pack draws the cursor from. * * @throws IllegalStateException if the player cannot show it, for example while another full-screen window is open * for them. Nothing is left behind. diff --git a/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/FullscreenEntries.kt b/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/FullscreenEntries.kt index d74f6afc..a1e15960 100644 --- a/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/FullscreenEntries.kt +++ b/jvm/runtime/src/main/kotlin/com/chunkzero/window/manifest/FullscreenEntries.kt @@ -129,6 +129,20 @@ public data class FullscreenCanvasEntry( @SerialName("hud_strip_half_width") val hudStripHalfWidth: Int, /** The most max health the client is told while the HUD is hidden, so its hearts fit the strip. */ @SerialName("hud_max_health") val hudMaxHealth: Int, + /** The horse of horse-mode windows, present when the pack has one. */ + val horse: HorseEntry? = null, +) + +/** + * The horse that horse-mode full-screen windows read the cursor from. The player rides it while their camera spectates + * a fixed entity at their eye, and the pack's entity shader draws the cursor from its armour. + */ +@Serializable +public data class HorseEntry( + /** Where the horse stands relative to the camera `[x, y, z]`, in blocks, with the camera facing yaw and pitch 0. */ + val offset: List, + /** The equipment asset id of the cursor armour the horse wears in its body slot. */ + val armor: String, ) /** The built-in full-screen cursor glyph. Its top-left texel is the hot spot. */ diff --git a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt index 6ee66707..21afb6d2 100644 --- a/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt +++ b/jvm/runtime/src/test/kotlin/com/chunkzero/window/FullscreenSessionTest.kt @@ -200,10 +200,16 @@ class FullscreenSessionTest : host.pending shouldBe null } - "horse mode is refused" { + "horse mode sends no cursor run, since the entity shader draws the cursor, and hit-tests the same rotation" { val host = FakeFullscreenHost() - shouldThrow { Menu(host, FullscreenMode.HORSE).open() } - host.shown shouldBe emptyList() + val menu = Menu(host, FullscreenMode.HORSE).also { it.open() } + + host.look(110, 110) + host.listener.onClick() + + host.shown.forEach { text(it) shouldNotContain "CURSOR" } + text(host.shown.last()) shouldContain "OUTLINE" + menu.plays shouldBe 1 } }) diff --git a/plugin/test/golden/fullscreen/expected.sha256 b/plugin/test/golden/fullscreen/expected.sha256 index ee2c86ce..1f3238c9 100644 --- a/plugin/test/golden/fullscreen/expected.sha256 +++ b/plugin/test/golden/fullscreen/expected.sha256 @@ -1,9 +1,11 @@ +b902022fd657ad3b7b7cd44391210c7c6f2d5086bebba022fecbbee2a951df3f dist/assets/minecraft/shaders/core/entity.vsh 0ea5dc4a51200a23e36d302db7208f23670ce635ab6a47b8717af27717d16694 dist/assets/minecraft/shaders/core/position_tex_color.fsh 2ba7e616fbdb0334aab7a729aabc0531f4d9daf73ad24aa249971dc6ca2972d8 dist/assets/minecraft/shaders/core/position_tex_color.vsh 950440cf29257b3e036e9aeb2aa7e8e8383e0855820f46b8cda64f77bdb029b6 dist/assets/minecraft/shaders/core/text.fsh 10955aaeef507e673f474143791a4a31ed838d84e6d3ed54231e37f02e425fb6 dist/assets/minecraft/shaders/core/text.vsh 6d0fd0ae349322d6b34846bcdba9fce9fc00c1cb05192db91a6d769dc2da9601 dist/assets/minecraft/shaders/include/window_fullscreen.glsl 8f0a28c175d2c93ff86aa1377f34c9e8fbc76e922ca34ae2f6db19dd557d285e dist/assets/minecraft/textures/gui/sprites/hud/crosshair.png +69beab9c90d9a84fa45fd48c393db16ea354338990a7955935cf8518f6fbcf74 dist/assets/window/equipment/fullscreen/horse_cursor.json ce82cef26d6b723cfe21ff646da7e701caf7e4759b9c8984725a9b50002396b4 dist/assets/window/font/ui.json 8bd2e56c97397bda94a6340e11aa2381582f597ee5df895815ab34ae8e23a616 dist/assets/window/font/y119.json e367eb8a3ebbb32324c3b028964cd84290aeaafe2b9eabd8006ac7c780fb23ee dist/assets/window/font/y141.json @@ -14,12 +16,13 @@ e367eb8a3ebbb32324c3b028964cd84290aeaafe2b9eabd8006ac7c780fb23ee dist/assets/wi c958c1c1e1a10be6b5276aa780f0e6c3cdd86f5326467240765d8059877a17f3 dist/assets/window/items/fullscreen/blank.json 93a0921adf078edd360459b3fb927865d9c73191541118f4b829f328eaccb9df dist/assets/window/items/gui/hitbox.json dc49481287414f39e74f7ef95a92814997f89f1b09b453bc34416f30787dd5b4 dist/assets/window/models/gui/hitbox.json +2976902baae7d6f2839820a34cb11efe68b47aaee46a383565fd116450e5e3ba dist/assets/window/textures/entity/equipment/horse_body/fullscreen/horse_cursor.png 74ebd720cee9a06ebbd6008d1ff58ce16c5dc062cbb69869741e1599a289497f dist/assets/window/textures/font/fullscreen/builtin/backdrop.png 24eb44ad123dcfce9f4dbdc3b5588aea9fd6285b7b0117bc789656c0907e35ce dist/assets/window/textures/font/fullscreen/builtin/cursor.png c685026fc5f903fcd19d771da6be0e55347aafdef87422ad63c16a5c05a45910 dist/assets/window/textures/font/fullscreen/window/menu_0_0.png a962b9aee37baf82e29390badf062f445b50a8e577de3731aa0947fcdbbd9222 dist/assets/window/textures/font/fullscreen/window/menu_hover_0_0_0.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png -9481e534191064dd3f83ebb97a6ba3ca0b967c442808258b0817defd0ef13d61 dist/assets/window/window/debug.json +2b8fb0a7ddbb21f3786a96db2404a919709a8c8ba2a1d7b7a8914d16be76061a dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta 23bf284dd31ac909286bdae4abd75e24f127fe9c720c9bd0cac4872c6f130a46 kotlin-out/LookoutFullscreen.kt 92d2f1d01598c590df7cd43619bd3b83d47048a6955a714733dabe7a25d64fc4 kotlin-out/MenuFullscreen.kt @@ -27,7 +30,7 @@ f30b961fdca503fa68f87b140e31c8a42b48ad830a84c029fc7a7435760bfff9 kotlin-out/Win 2c40c9884ffe5ae1a29707d1195e2a15ad469816e166981e0be0b73b1e1a3696 kotlin-out/WindowEntries.kt 87e25adf61686af7eaad080edd2f923102aab006421bef2c7663dfd8e725e74e kotlin-out/WindowFonts.kt bd87985f4223a45529a54d7453105bf2525188d337c8910cd4935344e0cec3bd kotlin-out/WindowFullscreenDefinitions.kt -c67367fa1a42351cc33700e39c0a22cc2ec93a5d4521f47a9929716f09467bd7 kotlin-out/WindowFullscreenEntries.kt +59d1e77f9b18731f3133f9a2174db48e1c6968ab61dda50aab2d9b3820c0aefb kotlin-out/WindowFullscreenEntries.kt 7e12c0496b9c2f1e69bebd517cf396d3d178322561535b9b4521b9e5e2f4c644 kotlin-out/WindowHudDefinitions.kt f14312feddb64e8e65d2916d8e066c8323d35628c9da4e722e2f12f057d516e3 kotlin-out/WindowHudEntries.kt 9a1b53ef8c7c748afb02db43707298badd154a58dfc0ba7abada76b7a10d212f kotlin-out/WindowPackData.kt