diff --git a/crates/fpsmaster_app/src/container.rs b/crates/fpsmaster_app/src/container.rs index cbbf8ac..44485b6 100644 --- a/crates/fpsmaster_app/src/container.rs +++ b/crates/fpsmaster_app/src/container.rs @@ -295,6 +295,12 @@ impl Container { &self.slots } + /// The window's own inventory (empty for the player window, whose slots all + /// map into the shared player inventory). + pub fn container_items(&self) -> &[Option] { + &self.container_inv + } + /// The villager trade offers (empty until `MC|TrList` arrives). pub fn trades(&self) -> &[MerchantRecipe] { &self.trades diff --git a/crates/fpsmaster_app/src/game.rs b/crates/fpsmaster_app/src/game.rs index a466050..d89f29d 100644 --- a/crates/fpsmaster_app/src/game.rs +++ b/crates/fpsmaster_app/src/game.rs @@ -576,6 +576,90 @@ pub enum MovingSoundCommand { Stop { id: u64 }, } +/// Parse the dashed UUID string a profile compound stores in `Id` into the raw +/// 16 bytes the skin cache is keyed by. Returns `None` for anything that is not +/// 32 hex digits once the dashes are stripped. +fn parse_dashed_uuid(text: &str) -> Option<[u8; 16]> { + let hex: String = text.chars().filter(|c| *c != '-').collect(); + if hex.len() != 32 { + return None; + } + let mut out = [0u8; 16]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok()?; + } + Some(out) +} + +/// The base64 `textures` property of a GameProfile compound +/// (`Properties.textures[0].Value`), which the skin downloader decodes into a +/// skin URL. `None` when the profile carries no skin. +fn skin_texture_property(profile: &fpsmaster_protocol::nbt::NbtCompound) -> Option { + profile + .get("Properties") + .and_then(|t| t.as_compound())? + .get("textures") + .and_then(|t| t.as_list())? + .iter() + .find_map(|entry| { + entry + .as_compound()? + .get("Value") + .and_then(|t| t.as_str()) + .map(str::to_owned) + }) +} + +/// Vanilla item id of a skull stack; its damage value is the `SkullType`. +pub const SKULL_ITEM_ID: i16 = 397; + +/// Edge of a dropped item's model in blocks (vanilla `RenderEntityItem`'s 0.25). +const DROPPED_ITEM_SCALE: f32 = 0.25; + +/// The 0..15 `Rot` a floor-mounted skull takes from the placer's yaw (vanilla +/// `ItemSkull.onItemUse`: `floor(yaw * 16 / 360 + 0.5) & 15`), which is why a +/// placed head ends up looking back at whoever put it down. +fn skull_rotation_from_yaw(yaw: f32) -> u8 { + ((yaw as f64 * 16.0 / 360.0 + 0.5).floor() as i32 & 15) as u8 +} + +/// The head a skull *item* stack draws and the owner UUID of a player head. +/// The item form keeps the profile under `SkullOwner` (the block-entity uses +/// `Owner`) and allows a bare name string, which carries no texture to fetch — +/// such a head falls back to the default skin. +pub fn skull_item_profile( + item: &SlotItem, +) -> (fpsmaster_render::SkullKind, Option<[u8; 16]>, Option) { + let kind = fpsmaster_render::SkullKind::from_type(item.damage.max(0) as i32); + let owner = item + .nbt + .as_ref() + .and_then(|nbt| nbt.get("SkullOwner")) + .and_then(|t| t.as_compound()); + let Some(owner) = owner else { + return (kind, None, None); + }; + let uuid = owner + .get("Id") + .and_then(|t| t.as_str()) + .and_then(parse_dashed_uuid); + (kind, uuid, skin_texture_property(owner)) +} + +/// A skull block-entity's rendered state (vanilla `TileEntitySkull`), decoded +/// from the S35 UpdateBlockEntity NBT. Block 144's metadata only says which way +/// the head is mounted, so everything else — which head, how far a floor skull +/// is turned, and the owner of a player head — has to come from here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkullState { + pub kind: fpsmaster_render::SkullKind, + /// Vanilla `Rot`: sixteenths of a turn, floor-mounted skulls only. + pub rotation: u8, + /// The owner's UUID for a player head, once the profile carried a skin the + /// skin manager could fetch. `None` leaves the head on the default skin. + pub owner: Option<[u8; 16]>, +} + pub struct GameState { pub world: World, pub input: InputState, @@ -644,6 +728,12 @@ pub struct GameState { /// Vanilla `GuiIngame.lastPlayerHealth` (`j`): the health snapshot drawn in the /// highlight frame while the row blinks after a health change. last_player_health: i32, + /// Vanilla `GuiIngame.highlightingItemStack`: the held stack as of the last + /// tick, compared each tick to decide whether the name banner re-triggers. + highlighting_item: Option, + /// Vanilla `GuiIngame.remainingHighlightTicks`: counts down from 40 after the + /// held stack changes; the hotbar name banner draws while it is positive. + remaining_highlight_ticks: i32, /// Experience bar fill (0..1) and level, from SetExperience. xp_bar: f32, xp_level: i32, @@ -727,6 +817,15 @@ pub struct GameState { /// Sign block-entity text, keyed by world block position: the four lines /// flattened from their chat-JSON (S33 UpdateSign). signs: std::collections::HashMap<[i32; 3], [String; 4]>, + /// Skull block-entity state, keyed by world block position (S35 + /// UpdateBlockEntity action 4). The block metadata only carries the facing — + /// which head it is, how a floor skull is turned, and whose skin a player + /// head wears all live here. + skulls: std::collections::HashMap<[i32; 3], SkullState>, + /// Owner UUID → base64 `textures` property for every player skull seen, so + /// the host can download skins for heads whose owner never joined the + /// server (and so is absent from the tab-list roster). + skull_skin_profiles: std::collections::HashMap<[u8; 16], String>, // Smoothed 0..1 view-state amounts, advanced once per physics tick so the // sneak camera dip and sprint FOV widen ease in/out instead of snapping. sneak_amount: f32, @@ -1033,6 +1132,12 @@ impl GameState { for (i, id) in hotbar.into_iter().enumerate() { state.inventory[36 + i] = Some(SlotItem::new(id, 64, 0)); } + // Three skull types in the free slots: heads are the one item that is + // both a block-entity and a builtin item model, so having them to hand + // makes the world / held / icon paths easy to eyeball in the demo. + for (i, skull_type) in [0i16, 3, 4].into_iter().enumerate() { + state.inventory[42 + i] = Some(SlotItem::new(397, 1, skull_type)); + } state } @@ -1202,6 +1307,8 @@ impl GameState { hud_update_counter: 0, health_update_counter: 0, last_player_health: 20, + highlighting_item: None, + remaining_highlight_ticks: 0, xp_bar: 0.0, xp_level: 0, is_dead: false, @@ -1230,6 +1337,8 @@ impl GameState { chest_lid_angles: std::collections::HashMap::new(), chest_open_targets: std::collections::HashMap::new(), signs: std::collections::HashMap::new(), + skulls: std::collections::HashMap::new(), + skull_skin_profiles: std::collections::HashMap::new(), sneak_amount: 0.0, previous_sneak_amount: 0.0, sprint_amount: 0.0, @@ -1772,6 +1881,12 @@ impl GameState { .collect() } + /// Whether any potion effect is active — the cheap check the inventory + /// layout needs (the window shifts right to clear the effect panel). + pub fn has_active_effects(&self) -> bool { + !self.effects.is_empty() + } + pub fn title_overlay(&self, partial_ticks: f32) -> Option> { self.title.overlay(partial_ticks) } @@ -2692,6 +2807,13 @@ impl GameState { // Vanilla GuiIngame.updateTick: a free-running tick counter that drives the // heart-shake RNG and the heart/hunger blink timing. self.hud_update_counter = self.hud_update_counter.wrapping_add(1); + self.update_held_item_highlight(); + self.register_skull_item_owners(); + // Every 5s: cheap next to the per-tick work, and the maps only grow by + // the handful of block-entities a player can walk past in that window. + if self.hud_update_counter % 100 == 0 { + self.prune_block_entity_state(); + } self.title.tick(); // Advance the day/night clock one tick (vanilla ticks world time forward // locally between server updates), unless daylight cycle is off. @@ -3164,6 +3286,19 @@ impl GameState { glint.push_armor_glint(&ids, &enchanted, &anim, feet, body_yaw); } } + // A skull worn in the head slot (vanilla `LayerCustomHead`). + // It is not armor, so `push_armor` above skips it — the two + // never both draw on the same head. + if let Some(head) = slots[4].as_ref().filter(|s| s.id == SKULL_ITEM_ID) { + let (kind, owner, _) = skull_item_profile(head); + mesh.push_worn_skull( + kind, + owner.and_then(|uuid| skin_rows.get(&uuid).copied()), + &anim, + feet, + body_yaw, + ); + } } } // Light the entity by the world lightmap at its body centre (vanilla @@ -3375,6 +3510,7 @@ impl GameState { brightness: f32, tick_alpha: f32, max_dist_sq: f64, + skin_rows: &std::collections::HashMap<[u8; 16], u32>, ) -> Vec { let time = self.world_time(tick_alpha) as f32; let sun_b = fpsmaster_render::sky::sun_brightness( @@ -3387,9 +3523,10 @@ impl GameState { let mut sign_texts = Vec::new(); for (&[wx, wy, wz], block) in self.world.block_entities() { - // Signs (63 standing, 68 wall), enchanting table (116) and end - // portal (119); chests share the index but render in build_chest_models. - if !matches!(block.id, 63 | 68 | 116 | 119) { + // Signs (63 standing, 68 wall), enchanting table (116), end portal + // (119) and skulls (144); chests share the index but render in + // build_chest_models. + if !matches!(block.id, 63 | 68 | 116 | 119 | 144) { continue; } let cx = wx as f64 + 0.5; @@ -3435,6 +3572,18 @@ impl GameState { } 116 => mesh.push_book(cell, time), 119 => mesh.push_end_portal(cell), + // A skull whose S35 payload has not arrived yet still draws — + // as the default skeleton head, facing whichever way its block + // metadata mounts it — rather than staying invisible. + 144 => { + let state = self.skulls.get(&cell); + let kind = state.map_or(fpsmaster_render::SkullKind::Skeleton, |s| s.kind); + let rotation = state.map_or(0, |s| s.rotation); + let skin_row = state + .and_then(|s| s.owner) + .and_then(|uuid| skin_rows.get(&uuid).copied()); + mesh.push_skull(cell, block.meta, rotation, kind, skin_row); + } _ => unreachable!(), } for v in &mut mesh.vertices[start..] { @@ -3446,6 +3595,48 @@ impl GameState { sign_texts } + /// Append the head model for every dropped skull stack to the entity model. + /// Skulls sample the entity atlas, so they cannot ride the block-atlas + /// world-item pass the other dropped items use; they bob and spin on the + /// same vanilla `RenderEntityItem` curves, and are lit by the world lightmap + /// like the other block-entity models in this pass. + pub fn build_dropped_skull_models( + &self, + mesh: &mut ModelMesh, + items: &[crate::item_renderer::DroppedItem], + brightness: f32, + tick_alpha: f32, + skin_rows: &std::collections::HashMap<[u8; 16], u32>, + ) { + let sun_b = fpsmaster_render::sky::sun_brightness( + self.world_time(tick_alpha), + self.weather.rain_strength(tick_alpha), + self.weather.thunder_strength(tick_alpha), + ); + for d in items.iter().filter(|d| d.item.id == SKULL_ITEM_ID) { + let (kind, owner, _) = skull_item_profile(&d.item); + let skin_row = owner.and_then(|uuid| skin_rows.get(&uuid).copied()); + // Vanilla `RenderEntityItem`: hover on a sine, spin about Y, drawn at + // quarter scale — the same curves `build_world_items` uses. + let bob = (d.phase * 0.1).sin() * 0.1 + 0.1; + let base = d.pos + Vec3::new(0.0, 0.25 + bob, 0.0); + let (s, c) = (d.phase * 0.05).sin_cos(); + let start = mesh.vertices.len(); + mesh.push_skull_item(kind, skin_row, fpsmaster_render::model::SKULL_ITEM_ROTATION, &|corner| { + // The head stands on the model origin rather than being centred, + // so only x/z recentre before the spin (see `push_skull_item`). + let p = (corner - Vec3::new(0.5, 0.0, 0.5)) * DROPPED_ITEM_SCALE; + base + Vec3::new(p.x * c - p.z * s, p.y, p.x * s + p.z * c) + }); + let factor = entity_light(&self.world, base, sun_b, brightness); + for v in &mut mesh.vertices[start..] { + v.color[0] *= factor[0]; + v.color[1] *= factor[1]; + v.color[2] *= factor[2]; + } + } + } + /// Ease each tracked chest lid toward its target (1 = open while viewers > 0, /// 0 = closed) at vanilla's 0.1/tick, pruning fully-closed entries. The open /// target comes from the S24 BlockAction viewer count; until that packet is @@ -3874,6 +4065,40 @@ impl GameState { sign_acc ^= s; } mix(sign_acc); + // Skull block-entity state (order-independent): the head type, rotation + // and resolved owner skin, so an S35 arriving after the block — or a + // just-downloaded owner skin — re-triggers the rebuild instead of + // leaving the cached default head on screen. + let mut skull_acc: u64 = 0; + for (&[x, y, z], state) in &self.skulls { + let owner_row = state + .owner + .and_then(|uuid| skin_rows.get(&uuid)) + .map(|r| *r as u64 + 1) + .unwrap_or(0); + skull_acc ^= ((x as u32 as u64) ^ ((z as u32 as u64) << 21) ^ ((y as u64) << 42)) + .wrapping_mul(0x9e37_79b9) + ^ (state.kind as u64) << 4 + ^ (state.rotation as u64) << 8 + ^ owner_row << 12; + } + mix(skull_acc); + // Dropped skull items ride this mesh (they sample the entity atlas, not + // the block atlas the other dropped items use) and bob/spin every frame, + // so their phase has to defeat the cache the way the hand pose does. + // Costs nothing while no head is lying on the ground. + let mut dropped_skull_acc: u64 = 0; + for e in self.world.entities() { + if e.kind != EntityKind::Object(2) { + continue; + } + if self.entity_items.get(&e.id).is_none_or(|i| i.id != SKULL_ITEM_ID) { + continue; + } + let phase = e.age as f32 + tick_alpha + (e.id.0 as f32) * 0.5; + dropped_skull_acc ^= (e.id.0 as u32 as u64) ^ ((qa(phase)) << 20); + } + mix(dropped_skull_acc); // Coarse book-hover timer (world time advances ~20/sec): quantized to a // few steps per second so the floating book animates without forcing a // per-frame rebuild. Idle scenes with a frozen sky still update slowly. @@ -3994,6 +4219,63 @@ impl GameState { } } + /// Vanilla `GuiIngame.updateTick`'s held-item half: when the selected stack + /// differs from last tick the name banner re-arms for 40 ticks, otherwise it + /// counts down; an empty hand clears it outright. Two stacks count as the + /// same when their id, NBT and metadata match — except on damageable items, + /// whose metadata is wear, so a pickaxe losing durability mid-swing must not + /// keep re-triggering the banner. + fn update_held_item_highlight(&mut self) { + let current = self.held_item().cloned(); + match (¤t, &self.highlighting_item) { + (None, _) => self.remaining_highlight_ticks = 0, + (Some(now), Some(before)) + if now.id == before.id + && now.nbt == before.nbt + && (fpsmaster_render::is_damageable(now.id) + || now.damage == before.damage) => + { + self.remaining_highlight_ticks = (self.remaining_highlight_ticks - 1).max(0); + } + _ => self.remaining_highlight_ticks = 40, + } + self.highlighting_item = current; + } + + /// The held-item name banner above the hotbar (vanilla + /// `GuiIngame.renderSelectedItem`): the `§`-coded display name and its 0..1 + /// alpha, or `None` once the 40-tick window has run out. Vanilla's alpha is + /// `ticks * 256 / 10` clamped to 255, so the name holds solid for 30 ticks + /// and fades over the last 10. + pub fn held_item_highlight(&self) -> Option<(String, f32)> { + if self.remaining_highlight_ticks <= 0 { + return None; + } + let item = self.highlighting_item.as_ref()?; + let alpha = (self.remaining_highlight_ticks as f32 * 256.0 / 10.0).min(255.0); + if alpha <= 0.0 { + return None; + } + Some((fpsmaster_render::stack_display_name(item), alpha / 255.0)) + } + + /// The local player's right-arm hold state (vanilla `ModelBiped`'s + /// `heldItemRight`: 0 empty, 1 holding, 3 blocking), so a preview of the + /// local player lowers its arm exactly like the in-world model does. + pub fn local_held_item_arm_state(&self) -> u8 { + held_item_right_state(self.held_item(), self.is_using_item()) + } + + /// The local player's worn armor, in the 1.8 EntityEquipment slot layout the + /// model code expects (0 held — always `None` here, 1 boots, 2 leggings, + /// 3 chestplate, 4 helmet). The player window lays its four armor slots out + /// top-to-bottom as inventory indices 5..8, i.e. helmet first — the reverse + /// of the equipment numbering. + pub fn local_armor(&self) -> [Option<&SlotItem>; 5] { + let slot = |i: usize| self.inventory.get(i).and_then(|s| s.as_ref()); + [None, slot(8), slot(7), slot(6), slot(5)] + } + /// The item in the selected hotbar slot, if any. pub fn held_item(&self) -> Option<&SlotItem> { self.inventory @@ -4536,6 +4818,19 @@ impl GameState { if self.world.set_block_if_chunk_loaded(px, py, pz, state) { self.mark_block_dirty_urgent(px, py, pz); self.relight_after_edit(px, py, pz, old); + // Predict the skull block-entity too, or the head would draw as the + // default skeleton until the server's S35 arrives (and forever in a + // server-less demo world). Vanilla `ItemSkull.onItemUse` only gives + // a floor-mounted skull a rotation, quantized from the placer's yaw. + if item.id == SKULL_ITEM_ID { + let (kind, owner, _) = skull_item_profile(item); + let rotation = if face == 1 { + skull_rotation_from_yaw(self.player.yaw) + } else { + 0 + }; + self.skulls.insert([px, py, pz], SkullState { kind, rotation, owner }); + } // No client-side place sound: vanilla's `ItemBlock.onItemUse` plays it // via `World.playSoundEffect`, but on the client that hits the empty // `RenderGlobal.playSound` (a no-op). The audible place sound comes @@ -5014,6 +5309,9 @@ impl GameState { ClientboundPlayPacket::UpdateSign { x, y, z, lines } => { self.handle_update_sign(x, y, z, lines) } + ClientboundPlayPacket::UpdateBlockEntity { x, y, z, action, nbt } => { + self.handle_update_block_entity(x, y, z, action, nbt) + } ClientboundPlayPacket::ScoreboardObjective { name, mode, @@ -6030,6 +6328,27 @@ impl GameState { false } + /// Drop block-entity state whose block is no longer there — broken, + /// replaced, or carried off with an unloaded chunk. Both maps are keyed by + /// world position and were previously only ever inserted into, so a long + /// session accumulated an entry for every sign and skull it had ever seen. + /// Vanilla ties a TileEntity's lifetime to its block; this applies the same + /// rule on a timer rather than at each edit, which also covers chunk + /// unloads (no block-change packet is sent for those). + /// + /// Safe against a payload arriving before its block only because 1.8.9 + /// always sends the block first — chunk data before its tile entities, + /// S23 BlockChange before S35 — so a live entry is never mid-flight here. + fn prune_block_entity_state(&mut self) { + let world = &self.world; + self.signs + .retain(|&[x, y, z], _| matches!(world.block_at(x, y, z).id, 63 | 68)); + self.skulls + .retain(|&[x, y, z], _| world.block_at(x, y, z).id == 144); + // Owner profiles are keyed by UUID, not position, and are bounded by the + // number of distinct head owners seen; they stay. + } + fn handle_update_sign(&mut self, x: i32, y: i32, z: i32, lines: [String; 4]) -> bool { // Store the four lines flattened from chat-JSON, keyed by block // position; the sign block-entity renderer draws them in world. @@ -6038,6 +6357,83 @@ impl GameState { false } + /// S35 UpdateBlockEntity. Only the skull payload (action 4) is modelled: + /// it carries everything block 144's metadata cannot — which head it is, + /// how a floor-mounted one is turned, and the owning profile of a player + /// head. An absent compound means the server dropped the block-entity, so + /// the stored state goes with it. + fn handle_update_block_entity( + &mut self, + x: i32, + y: i32, + z: i32, + action: u8, + nbt: Option, + ) -> bool { + const SKULL_ACTION: u8 = 4; + if action != SKULL_ACTION { + return false; + } + let cell = [x, y, z]; + let Some(nbt) = nbt else { + self.skulls.remove(&cell); + return false; + }; + let skull_type = nbt.get("SkullType").and_then(|t| t.as_byte()).unwrap_or(0); + let rotation = nbt.get("Rot").and_then(|t| t.as_byte()).unwrap_or(0); + let owner = nbt.get("Owner").and_then(|t| t.as_compound()); + // Queue the owner's skin download; the head falls back to the default + // skin until it lands (and stays there if the profile carried none). + let owner_uuid = owner.and_then(|o| { + let uuid = parse_dashed_uuid(o.get("Id").and_then(|t| t.as_str())?)?; + if let Some(property) = skin_texture_property(o) { + self.skull_skin_profiles.entry(uuid).or_insert(property); + } + Some(uuid) + }); + self.skulls.insert( + cell, + SkullState { + kind: fpsmaster_render::SkullKind::from_type(skull_type as i32), + rotation: (rotation & 15) as u8, + owner: owner_uuid, + }, + ); + false + } + + /// Queue skin downloads for the owners of any player heads carried in the + /// inventory or the open window, or worn on another player's head, so they + /// show the right face instead of the default skin. Run once per tick over + /// the ~45 player slots, the open window and the tracked equipment — cheap, + /// and it covers every route a stack can arrive by (SetSlot, WindowItems, + /// EntityEquipment, a predicted click) without hooking each one. + fn register_skull_item_owners(&mut self) { + let profiles: Vec<([u8; 16], String)> = self + .inventory + .iter() + .chain(self.open_container.iter().flat_map(|c| c.container_items())) + .chain(self.entity_equipment.values().map(|slots| &slots[4])) + .flatten() + .filter(|item| item.id == SKULL_ITEM_ID) + .filter_map(|item| match skull_item_profile(item) { + (_, Some(uuid), Some(property)) => Some((uuid, property)), + _ => None, + }) + .collect(); + for (uuid, property) in profiles { + self.skull_skin_profiles.entry(uuid).or_insert(property); + } + } + + /// Owner profiles seen on player skulls that carry a skin texture, for the + /// host to hand to the skin downloader (the same route the tab-list roster + /// takes). Skulls can name players who were never in the player list, so + /// they need their own source of profiles. + pub fn skull_skin_profiles(&self) -> impl Iterator { + self.skull_skin_profiles.iter() + } + fn handle_player_list_header_footer(&mut self, header: String, footer: String) -> bool { self.player_list.set_header_footer( chat::flatten_chat_json(&header), @@ -6842,6 +7238,12 @@ fn is_interactable(block: BlockState) -> bool { /// waits for the server rather than risk a wrong phantom. fn placement_block_state(item: &SlotItem, face: u8, yaw: f32, cursor_y: u8) -> Option { let raw = item.id; + // Skulls are the one item outside the block-id range that places a block: + // vanilla `ItemSkull.onItemUse` sets block 144 with the clicked face as its + // metadata, and refuses the underside (a skull has nothing to hang from). + if raw == SKULL_ITEM_ID { + return (face != 0).then(|| BlockState::new(144, face)); + } if !(1..=255).contains(&raw) { return None; } @@ -8373,6 +8775,72 @@ mod interaction_tests { ); } + #[test] + fn switching_the_held_item_re_arms_the_name_banner() { + let mut gs = GameState::empty_for_server(1.0); + gs.selected_slot = 0; + gs.inventory[36] = Some(SlotItem::new(1, 64, 0)); // stone + gs.inventory[37] = Some(SlotItem::new(2, 64, 0)); // grass + + gs.update_held_item_highlight(); + assert_eq!(gs.remaining_highlight_ticks, 40); + let (name, alpha) = gs.held_item_highlight().expect("banner armed"); + assert!(name.contains("Stone"), "{name}"); + assert_eq!(alpha, 1.0, "solid while above 10 ticks left"); + + // Holding the same stack counts down; the last 10 ticks fade. + for _ in 0..30 { + gs.update_held_item_highlight(); + } + assert_eq!(gs.remaining_highlight_ticks, 10); + assert_eq!(gs.held_item_highlight().map(|(_, a)| a), Some(1.0)); + gs.update_held_item_highlight(); + let alpha = gs.held_item_highlight().expect("still visible").1; + assert!(alpha > 0.0 && alpha < 1.0, "fading, got {alpha}"); + + // ...and runs out. + for _ in 0..9 { + gs.update_held_item_highlight(); + } + assert_eq!(gs.remaining_highlight_ticks, 0); + assert!(gs.held_item_highlight().is_none()); + + // Selecting a different slot re-arms it with the new item's name. + gs.selected_slot = 1; + gs.update_held_item_highlight(); + assert_eq!(gs.remaining_highlight_ticks, 40); + assert!(gs.held_item_highlight().unwrap().0.contains("Grass")); + + // An empty hand clears it outright rather than showing a stale name. + gs.selected_slot = 2; + gs.update_held_item_highlight(); + assert_eq!(gs.remaining_highlight_ticks, 0); + assert!(gs.held_item_highlight().is_none()); + } + + #[test] + fn a_tool_losing_durability_does_not_re_trigger_the_name_banner() { + // Vanilla ignores metadata on damageable items, so mining (which bumps + // the pickaxe's damage every block) must not keep re-showing its name — + // while a plain metadata variant swap (wool colour) still does. + let mut gs = GameState::empty_for_server(1.0); + gs.selected_slot = 0; + gs.inventory[36] = Some(SlotItem::new(257, 1, 0)); // iron pickaxe + gs.update_held_item_highlight(); + assert_eq!(gs.remaining_highlight_ticks, 40); + + gs.inventory[36] = Some(SlotItem::new(257, 1, 1)); // one use of wear + gs.update_held_item_highlight(); + assert_eq!(gs.remaining_highlight_ticks, 39, "wear is not a new item"); + + gs.inventory[36] = Some(SlotItem::new(35, 1, 0)); // white wool + gs.update_held_item_highlight(); + gs.remaining_highlight_ticks = 5; + gs.inventory[36] = Some(SlotItem::new(35, 1, 14)); // red wool + gs.update_held_item_highlight(); + assert_eq!(gs.remaining_highlight_ticks, 40, "a variant is a new item"); + } + #[test] fn use_block_predicts_placement_and_sends_held_item() { let mut gs = looking_along_x(); @@ -9619,6 +10087,408 @@ mod interaction_tests { } +#[cfg(test)] +mod skull_tests { + use super::*; + use fpsmaster_protocol::nbt::{NbtCompound, NbtTag}; + use fpsmaster_render::SkullKind; + + fn compound(entries: &[(&str, NbtTag)]) -> NbtCompound { + entries + .iter() + .map(|(k, v)| ((*k).to_owned(), v.clone())) + .collect() + } + + /// An `Owner` profile compound with an id and a base64 textures property. + fn owner(id: &str, texture: &str) -> NbtTag { + NbtTag::Compound(compound(&[ + ("Id", NbtTag::String(id.to_owned())), + ( + "Properties", + NbtTag::Compound(compound(&[( + "textures", + NbtTag::List(vec![NbtTag::Compound(compound(&[( + "Value", + NbtTag::String(texture.to_owned()), + )]))]), + )])), + ), + ])) + } + + #[test] + fn a_skull_payload_sets_the_head_type_and_rotation() { + let mut gs = GameState::empty_for_server(1.0); + gs.handle_update_block_entity( + 3, + 64, + -8, + 4, + Some(compound(&[ + ("SkullType", NbtTag::Byte(1)), + ("Rot", NbtTag::Byte(6)), + ])), + ); + assert_eq!( + gs.skulls.get(&[3, 64, -8]), + Some(&SkullState { kind: SkullKind::WitherSkeleton, rotation: 6, owner: None }), + ); + } + + #[test] + fn a_cleared_block_entity_forgets_the_skull() { + let mut gs = GameState::empty_for_server(1.0); + let cell = [0, 0, 0]; + gs.handle_update_block_entity(0, 0, 0, 4, Some(compound(&[("SkullType", NbtTag::Byte(2))]))); + assert!(gs.skulls.contains_key(&cell)); + gs.handle_update_block_entity(0, 0, 0, 4, None); + assert!(!gs.skulls.contains_key(&cell)); + } + + #[test] + fn other_block_entity_actions_are_ignored() { + // Actions 1/2/3/5/6 (spawner, command block, beacon, flower pot, + // banner) share the packet; none of them may land in the skull map. + let mut gs = GameState::empty_for_server(1.0); + for action in [1u8, 2, 3, 5, 6] { + gs.handle_update_block_entity(1, 2, 3, action, Some(compound(&[]))); + } + assert!(gs.skulls.is_empty()); + } + + #[test] + fn a_player_head_queues_its_owners_skin() { + let mut gs = GameState::empty_for_server(1.0); + gs.handle_update_block_entity( + 5, + 5, + 5, + 4, + Some(compound(&[ + ("SkullType", NbtTag::Byte(3)), + ("Owner", owner("069a79f4-44e9-4726-a5be-fca90e38aaf5", "eyJ0ZXh0dXJlcyI6e319")), + ])), + ); + let state = gs.skulls.get(&[5, 5, 5]).expect("skull stored"); + assert_eq!(state.kind, SkullKind::Player); + let uuid = state.owner.expect("owner parsed"); + assert_eq!(uuid[0], 0x06); + assert_eq!(uuid[15], 0xf5); + assert_eq!( + gs.skull_skin_profiles().collect::>(), + vec![(&uuid, &"eyJ0ZXh0dXJlcyI6e319".to_owned())], + ); + } + + #[test] + fn an_owner_without_a_skin_still_renders_the_default_head() { + // A head placed by name only (no Properties) must not be dropped — + // it just stays on the default skin. + let mut gs = GameState::empty_for_server(1.0); + gs.handle_update_block_entity( + 1, + 1, + 1, + 4, + Some(compound(&[ + ("SkullType", NbtTag::Byte(3)), + ( + "Owner", + NbtTag::Compound(compound(&[( + "Id", + NbtTag::String("069a79f4-44e9-4726-a5be-fca90e38aaf5".to_owned()), + )])), + ), + ])), + ); + assert!(gs.skulls[&[1, 1, 1]].owner.is_some()); + assert_eq!(gs.skull_skin_profiles().count(), 0); + } + + /// A camera at the origin looking down +x, with a skull placed in view. + fn world_with_a_skull(meta: u8) -> GameState { + let mut gs = GameState::empty_for_server(1.0); + gs.camera.position = Vec3::new(0.5, 0.5, 0.5); + gs.camera.yaw = -90.0; + gs.camera.pitch = 0.0; + gs.world.set_block(3, 0, 0, BlockState::new(144, meta)); + gs + } + + fn skull_mesh(gs: &GameState) -> ModelMesh { + let mut mesh = ModelMesh::new(); + gs.build_block_entity_models(&mut mesh, 1.0, 0.0, 4096.0, &std::collections::HashMap::new()); + mesh + } + + #[test] + fn a_skull_block_in_the_world_reaches_the_entity_mesh() { + // The end-to-end path the bug report was about: block 144 is indexed as + // a block-entity, survives the per-kind filter, and emits real geometry. + // Before this it had collision but drew nothing at all. + let gs = world_with_a_skull(1); + let mesh = skull_mesh(&gs); + assert_eq!(mesh.vertices.len(), 24, "one 6-face head box"); + let lo = mesh + .vertices + .iter() + .fold(Vec3::splat(f32::MAX), |a, v| a.min(Vec3::from(v.position))); + let hi = mesh + .vertices + .iter() + .fold(Vec3::splat(f32::MIN), |a, v| a.max(Vec3::from(v.position))); + assert!((lo - Vec3::new(3.25, 0.0, 0.25)).length() < 1e-4, "{lo:?}"); + assert!((hi - Vec3::new(3.75, 0.5, 0.75)).length() < 1e-4, "{hi:?}"); + } + + #[test] + fn the_block_entity_payload_changes_which_head_the_world_draws() { + // Same block, different S35 payload → a different texture slot. This is + // the half the block metadata cannot carry. + let mut gs = world_with_a_skull(1); + let plain = skull_mesh(&gs); + gs.handle_update_block_entity(3, 0, 0, 4, Some(compound(&[("SkullType", NbtTag::Byte(4))]))); + let creeper = skull_mesh(&gs); + assert_eq!(plain.vertices.len(), creeper.vertices.len()); + // Compare the full UVs, not just V: the atlas is two slots wide, so + // neighbouring slots share a row and differ only in U. + let uvs = |m: &ModelMesh| m.vertices.iter().map(|v| v.uv).collect::>(); + assert_ne!(uvs(&plain), uvs(&creeper), "a creeper head must sample its own texture"); + + // A player head adds the hat layer. + gs.handle_update_block_entity(3, 0, 0, 4, Some(compound(&[("SkullType", NbtTag::Byte(3))]))); + assert_eq!(skull_mesh(&gs).vertices.len(), 48); + } + + #[test] + fn a_wall_skull_hangs_off_the_cell_floor() { + // Metadata 5 (EAST) mounts at x 0.26 and y 0.25..0.75, not on the floor. + let gs = world_with_a_skull(5); + let mesh = skull_mesh(&gs); + let lo = mesh + .vertices + .iter() + .fold(Vec3::splat(f32::MAX), |a, v| a.min(Vec3::from(v.position))); + assert!((lo.y - 0.25).abs() < 1e-4, "wall skulls hang at y 0.25, got {}", lo.y); + assert!((lo.x - 3.01).abs() < 1e-4, "mounted on the -x wall, got {}", lo.x); + } + + #[test] + fn placing_a_skull_predicts_both_the_block_and_its_head() { + // Right-clicking the top of a block places a floor skull that shows the + // stack's head type immediately — without the local block-entity guess + // it would draw as the default skeleton until the server's S35 (and + // forever in the server-less demo world). + let mut gs = GameState::empty_for_server(1.0); + gs.camera.position = Vec3::new(0.5, 0.5, 0.5); + gs.camera.yaw = -90.0; + gs.player.yaw = 0.0; + gs.world.set_block(3, 0, 0, BlockState::STONE); + gs.selected_slot = 0; + gs.inventory[36] = Some(SlotItem::new(SKULL_ITEM_ID, 1, 4)); // creeper head + + let item = gs.held_item().cloned().unwrap(); + assert!(gs.predict_placement(3, 0, 0, 1, 8, Some(&item)), "placement predicted"); + assert_eq!(gs.world.block_at(3, 1, 0), BlockState::new(144, 1)); + assert_eq!( + gs.skulls.get(&[3, 1, 0]), + Some(&SkullState { kind: SkullKind::Creeper, rotation: 0, owner: None }), + ); + } + + #[test] + fn a_skull_takes_the_clicked_face_and_never_the_underside() { + let skull = SlotItem::new(SKULL_ITEM_ID, 1, 0); + // Face 0 is DOWN — vanilla refuses it, so no phantom block is predicted. + assert_eq!(placement_block_state(&skull, 0, 0.0, 8), None); + // Every other face becomes the metadata verbatim (an EnumFacing index). + for face in 1u8..=5 { + assert_eq!( + placement_block_state(&skull, face, 0.0, 8), + Some(BlockState::new(144, face)), + ); + } + } + + #[test] + fn only_a_floor_skull_takes_the_placers_yaw() { + let mut gs = GameState::empty_for_server(1.0); + gs.camera.position = Vec3::new(0.5, 0.5, 0.5); + gs.camera.yaw = -90.0; + gs.world.set_block(3, 0, 0, BlockState::STONE); + gs.world.set_block(3, 2, 0, BlockState::STONE); + let item = SlotItem::new(SKULL_ITEM_ID, 1, 0); + + // Facing west (yaw 90) → Rot 4 of the 16 steps. + gs.player.yaw = 90.0; + assert!(gs.predict_placement(3, 0, 0, 1, 8, Some(&item))); + assert_eq!(gs.skulls[&[3, 1, 0]].rotation, 4); + + // A wall skull's facing already comes from the metadata, so vanilla + // leaves its rotation at 0. (Face 3 is SOUTH, keeping the target cell in + // the one loaded chunk.) + assert!(gs.predict_placement(3, 2, 0, 3, 8, Some(&item))); + assert_eq!(gs.world.block_at(3, 2, 1), BlockState::new(144, 3)); + assert_eq!(gs.skulls[&[3, 2, 1]].rotation, 0); + + assert_eq!(skull_rotation_from_yaw(0.0), 0); + assert_eq!(skull_rotation_from_yaw(180.0), 8); + assert_eq!(skull_rotation_from_yaw(-90.0), 12, "negative yaw wraps"); + } + + fn dropped(item: SlotItem, phase: f32) -> crate::item_renderer::DroppedItem { + crate::item_renderer::DroppedItem { + item, + pos: Vec3::new(4.0, 65.0, -2.0), + phase, + light: [1.0, 1.0], + } + } + + #[test] + fn a_dropped_skull_draws_as_a_head_and_keeps_bobbing() { + let gs = GameState::empty_for_server(1.0); + let rows = std::collections::HashMap::new(); + let build = |phase: f32| { + let mut mesh = ModelMesh::new(); + gs.build_dropped_skull_models( + &mut mesh, + &[dropped(SlotItem::new(SKULL_ITEM_ID, 1, 2), phase)], + 1.0, + 0.0, + &rows, + ); + mesh + }; + let a = build(0.0); + assert_eq!(a.vertices.len(), 24, "one head box, quarter scale"); + // Quarter-scale: the 0.5-block head model becomes 0.125 blocks. + let (lo, hi) = a.vertices.iter().fold( + (Vec3::splat(f32::MAX), Vec3::splat(f32::MIN)), + |(l, h), v| (l.min(Vec3::from(v.position)), h.max(Vec3::from(v.position))), + ); + assert!(((hi.y - lo.y) - 0.125).abs() < 1e-5, "height {}", hi.y - lo.y); + + // The bob and spin must actually advance with the phase — this is the + // half that the cached entity mesh would otherwise freeze. + let b = build(9.0); + assert_ne!( + a.vertices.iter().map(|v| v.position).collect::>(), + b.vertices.iter().map(|v| v.position).collect::>(), + ); + } + + #[test] + fn only_skull_stacks_reach_the_dropped_head_pass() { + // Every other dropped item belongs to the block-atlas world-item pass. + let gs = GameState::empty_for_server(1.0); + let mut mesh = ModelMesh::new(); + gs.build_dropped_skull_models( + &mut mesh, + &[ + dropped(SlotItem::new(1, 1, 0), 0.0), // stone + dropped(SlotItem::new(276, 1, 0), 0.0), // diamond sword + ], + 1.0, + 0.0, + &std::collections::HashMap::new(), + ); + assert!(mesh.is_empty()); + } + + #[test] + fn a_dropped_skull_keeps_the_entity_mesh_rebuilding() { + // The entity mesh is cached behind a fingerprint. Dropped skulls live in + // that mesh and animate every frame, so the key must move with them — + // otherwise the head would hang frozen in mid-air. + let mut gs = GameState::empty_for_server(1.0); + let rows = std::collections::HashMap::new(); + let key = |gs: &GameState, alpha: f32| gs.entity_render_fingerprint(alpha, 1.0, true, &rows, 4096.0); + + // With nothing on the ground the key is stable across a tick's frames, + // so idle scenes still get the cache. + assert_eq!(key(&gs, 0.1), key(&gs, 0.9), "an idle scene must stay cached"); + + let id = EntityId(77); + gs.world.upsert_entity(EntityState::new_remote( + id, + EntityKind::Object(2), + DVec3::new(4.0, 65.0, -2.0), + 0.0, + 0.0, + )); + gs.entity_items.insert(id, SlotItem::new(SKULL_ITEM_ID, 1, 0)); + assert_ne!(key(&gs, 0.1), key(&gs, 0.9), "a dropped head must re-trigger"); + } + + #[test] + fn block_entity_state_is_dropped_when_its_block_goes() { + // The maps are keyed by position and only ever inserted into, so without + // pruning a long session keeps an entry for every sign and skull it ever + // saw. Breaking, replacing or unloading the block must clear it. + let mut gs = GameState::empty_for_server(1.0); + gs.world.set_block(1, 1, 1, BlockState::new(144, 1)); + gs.world.set_block(2, 1, 1, BlockState::new(63, 0)); + gs.handle_update_block_entity(1, 1, 1, 4, Some(compound(&[("SkullType", NbtTag::Byte(1))]))); + gs.handle_update_sign(2, 1, 1, std::array::from_fn(|_| String::new())); + // Never-placed positions: state that arrived for blocks we do not have. + gs.handle_update_block_entity(9, 9, 9, 4, Some(compound(&[]))); + + gs.prune_block_entity_state(); + assert!(gs.skulls.contains_key(&[1, 1, 1]), "a live skull survives"); + assert!(gs.signs.contains_key(&[2, 1, 1]), "a live sign survives"); + assert!(!gs.skulls.contains_key(&[9, 9, 9]), "a blockless entry is dropped"); + + // Break the skull and replace the sign with stone. + gs.world.set_block(1, 1, 1, BlockState::AIR); + gs.world.set_block(2, 1, 1, BlockState::STONE); + gs.prune_block_entity_state(); + assert!(gs.skulls.is_empty(), "broken skull state must go"); + assert!(gs.signs.is_empty(), "replaced sign state must go"); + } + + #[test] + fn the_prune_runs_on_a_timer_without_dropping_fresh_state() { + // The prune is on a 5s tick so it costs nothing per frame; a skull placed + // between two runs must still be there when the next one comes round. + let mut gs = GameState::empty_for_server(1.0); + gs.world.set_block(0, 1, 0, BlockState::new(144, 1)); + gs.handle_update_block_entity(0, 1, 0, 4, Some(compound(&[]))); + for _ in 0..250 { + gs.tick(0.05); + } + assert!(gs.skulls.contains_key(&[0, 1, 0]), "a standing skull survives the timer"); + } + + #[test] + fn the_preview_reads_armor_in_equipment_slot_order() { + // The player window lists armor helmet-first (inventory 5..8) while the + // model code numbers it boots-first, so a swapped mapping would put a + // helmet on the feet. + let mut gs = GameState::empty_for_server(1.0); + gs.inventory[5] = Some(SlotItem::new(310, 1, 0)); // diamond helmet + gs.inventory[6] = Some(SlotItem::new(311, 1, 0)); // diamond chestplate + gs.inventory[7] = Some(SlotItem::new(312, 1, 0)); // diamond leggings + gs.inventory[8] = Some(SlotItem::new(313, 1, 0)); // diamond boots + let armor = gs.local_armor(); + assert!(armor[0].is_none(), "slot 0 is the held slot, not armor"); + assert_eq!(armor[1].map(|s| s.id), Some(313), "1 = boots"); + assert_eq!(armor[2].map(|s| s.id), Some(312), "2 = leggings"); + assert_eq!(armor[3].map(|s| s.id), Some(311), "3 = chestplate"); + assert_eq!(armor[4].map(|s| s.id), Some(310), "4 = helmet"); + } + + #[test] + fn malformed_uuids_are_rejected_rather_than_mis_keyed() { + assert!(parse_dashed_uuid("069a79f4-44e9-4726-a5be-fca90e38aaf5").is_some()); + assert!(parse_dashed_uuid("069a79f444e94726a5befca90e38aaf5").is_some(), "dashless is fine"); + assert!(parse_dashed_uuid("").is_none()); + assert!(parse_dashed_uuid("069a79f4-44e9-4726-a5be-fca90e38aa").is_none(), "too short"); + assert!(parse_dashed_uuid("069a79f4-44e9-4726-a5be-fca90e38aazz").is_none(), "not hex"); + } +} + #[cfg(test)] mod weather_tests { use super::*; diff --git a/crates/fpsmaster_app/src/gui/ingame.rs b/crates/fpsmaster_app/src/gui/ingame.rs index f233b8d..2f66da7 100644 --- a/crates/fpsmaster_app/src/gui/ingame.rs +++ b/crates/fpsmaster_app/src/gui/ingame.rs @@ -26,7 +26,17 @@ pub struct HudState<'a> { pub xp_level: i32, pub selected_slot: i32, pub hotbar: &'a [Option], + /// The held-item name banner above the hotbar (vanilla + /// `GuiIngame.renderSelectedItem`): the `§`-coded name and its 0..1 alpha, + /// present for 40 ticks after the held stack changes. + pub held_item_highlight: Option<(String, f32)>, + /// Creative mode: vanilla drops the banner 14 GUI px because there are no + /// health/hunger rows above the hotbar to clear. + pub creative: bool, pub inventory: &'a [Option], + /// Downloaded-skin atlas rows by player UUID, so a player head in a slot + /// draws its owner's face instead of the default skin. + pub skin_rows: &'a std::collections::HashMap<[u8; 16], u32>, /// The open window (player inventory or a server container), whose slot /// layout the container screen renders. `None` when no window is open. pub container: Option<&'a crate::container::Container>, @@ -176,6 +186,7 @@ impl GuiIngame { draw_chat(ui, width, height, hud, chat_input); draw_status_bars(ui, width, height, hud); draw_hotbar(ui, width, height, hud); + draw_selected_item_name(ui, width, height, hud); draw_boss_bar(ui, width, height, hud); draw_title(ui, width, height, hud); draw_action_bar(ui, width, height, hud); @@ -335,7 +346,7 @@ fn draw_hotbar(ui: &mut UiFrame, width: i32, height: i32, hud: &HudState) { 16 * scale, 16 * scale, ); - draw_item_icon(ui, cell, item, scale.max(2), false); + draw_item_icon(ui, cell, item, scale.max(2), false, hud.skin_rows); } } @@ -619,9 +630,14 @@ pub(crate) fn draw_item_icon( item: &SlotItem, text_scale: i32, overlay: bool, + skin_rows: &std::collections::HashMap<[u8; 16], u32>, ) { let block = fpsmaster_render::gui_item::is_block_icon(item.id, item.damage); - if let Some((block_id, meta)) = block { + if item.id == crate::game::SKULL_ITEM_ID { + // Vanilla has no flat sprite for skulls — the head model is the icon. + let (kind, owner, _) = crate::game::skull_item_profile(item); + ui.skull_item(rect, kind, owner.and_then(|uuid| skin_rows.get(&uuid).copied())); + } else if let Some((block_id, meta)) = block { ui.block_item(rect, block_id, meta); } else if overlay { ui.overlay_item_icon(rect, item.id); @@ -819,6 +835,34 @@ fn draw_title(ui: &mut UiFrame, width: i32, height: i32, hud: &HudState) { ); } +/// Vanilla y of the held-item name banner, measured up from the bottom of the +/// screen in GUI px (`scaledHeight - 59`), and the creative offset that drops it +/// into the space the health/hunger rows would occupy (`j += 14`). +const SELECTED_ITEM_UP_FROM_BOTTOM: i32 = 59; +const SELECTED_ITEM_CREATIVE_DROP: i32 = 14; + +/// The name of the item that was just selected, centered above the hotbar +/// (vanilla `GuiIngame.renderSelectedItem`): shown for 40 ticks after the held +/// stack changes, fading out over the last 10. The name carries its own `§` +/// rarity color, so the draw color only supplies the fade alpha. +fn draw_selected_item_name(ui: &mut UiFrame, width: i32, height: i32, hud: &HudState) { + let Some((name, alpha)) = hud.held_item_highlight.as_ref() else { + return; + }; + let scale = gui_scale(width, height); + let mut y = height - SELECTED_ITEM_UP_FROM_BOTTOM * scale; + if hud.creative { + y += SELECTED_ITEM_CREATIVE_DROP * scale; + } + ui.text_shadowed( + (width - text_width(name, scale)) / 2, + y, + scale, + faded_white(*alpha), + name, + ); +} + /// The action-bar text (chat position 2) centered above the hotbar. fn draw_action_bar(ui: &mut UiFrame, width: i32, height: i32, hud: &HudState) { let Some((text, alpha)) = hud.chat.action_bar(Instant::now()) else { @@ -1099,7 +1143,7 @@ mod tests { // A plain diamond sword: a flat icon, no glint queued. let mut ui = UiFrame::new(); - draw_item_icon(&mut ui, rect, &SlotItem::new(276, 1, 0), 2, false); + draw_item_icon(&mut ui, rect, &SlotItem::new(276, 1, 0), 2, false, &Default::default()); assert!(ui.glint_items().is_empty(), "unenchanted item must not glint"); // The same sword with a non-empty `ench` tag: a glint overlay is queued. @@ -1112,7 +1156,7 @@ mod tests { NbtTag::List(vec![NbtTag::Compound(ench)]), )])); let mut ui = UiFrame::new(); - draw_item_icon(&mut ui, rect, &sword, 2, false); + draw_item_icon(&mut ui, rect, &sword, 2, false, &Default::default()); assert_eq!(ui.glint_items().len(), 1, "enchanted item must queue a glint overlay"); } diff --git a/crates/fpsmaster_app/src/gui/inventory.rs b/crates/fpsmaster_app/src/gui/inventory.rs index 83f9275..61552d2 100644 --- a/crates/fpsmaster_app/src/gui/inventory.rs +++ b/crates/fpsmaster_app/src/gui/inventory.rs @@ -48,6 +48,38 @@ const PREVIEW_LOOK_Y: i32 = PREVIEW_ANCHOR_Y - 50; /// Vanilla `drawEntityOnScreen` scale. pub const PREVIEW_SCALE: f32 = 30.0; +/// How far right the player-inventory window slides while potion effects are +/// active, in GUI px (vanilla `InventoryEffectRenderer.updateActivePotionEffects`: +/// `guiLeft = 160 + (width - xSize - 200) / 2`, i.e. centred + 60). +const EFFECT_PANEL_SHIFT: i32 = 60; + +/// Whether the active-effect panel is drawn beside this window — only the +/// player inventory shows it, and only while an effect is active. +pub fn effect_panel_shown(container: &Container, has_effects: bool) -> bool { + container.kind == WindowKind::Player && has_effects +} + +/// The window origin (vanilla `guiLeft`/`guiTop`) in physical px: the panel is +/// centred, then shifted right by [`EFFECT_PANEL_SHIFT`] when the effect panel +/// takes the space on its left. Every consumer of the window position must go +/// through this — the player preview is projected from the same origin, so a +/// second, un-shifted copy of the centring maths left the biped drawn outside +/// the window whenever a potion was active. +pub fn window_origin( + width: i32, + height: i32, + container: &Container, + has_effects: bool, + scale: i32, +) -> (i32, i32) { + let mut x = (width - container.x_size * scale) / 2; + let y = (height - container.y_size * scale) / 2; + if effect_panel_shown(container, has_effects) { + x += EFFECT_PANEL_SHIFT * scale; + } + (x, y) +} + /// The look pose from the cursor, mirroring vanilla `drawEntityOnScreen`: /// `f = atan((anchorX - mouseX)/40)`, `f1 = atan((lookY - mouseY)/40)`, with /// body yaw `f*20`, head total yaw `f*40` (so net head yaw `f*20`), head pitch @@ -150,15 +182,9 @@ impl GuiScreen for GuiContainer { let scale = ctx.scale; let pw = container.x_size * scale; let ph = container.y_size * scale; - let mut px = (ctx.width - pw) / 2; - let py = (ctx.height - ph) / 2; - // Vanilla `InventoryEffectRenderer` shifts the player inventory window - // 60 GUI px to the right to make room for the active-effect panel on its - // left (guiLeft = 160 + (W - xSize - 200)/2, i.e. centered + 60). - let show_effects = container.kind == WindowKind::Player && !hud.effects.is_empty(); - if show_effects { - px += 60 * scale; - } + let show_effects = effect_panel_shown(container, !hud.effects.is_empty()); + let (px, py) = + window_origin(ctx.width, ctx.height, container, !hud.effects.is_empty(), scale); // Cache the layout so the input handlers can map cursor → slot. self.px = px; self.py = py; @@ -180,7 +206,7 @@ impl GuiScreen for GuiContainer { draw_anvil_field(ui, container, hud.inventory, px, py, scale); } if container.kind == WindowKind::Villager { - super::merchant::draw_offer(ui, container, px, py, scale, ctx.mouse); + super::merchant::draw_offer(ui, container, px, py, scale, ctx.mouse, hud.skin_rows); } let icon = 16 * scale; @@ -188,7 +214,7 @@ impl GuiScreen for GuiContainer { for (i, slot) in container.slots().iter().enumerate() { let cell = UiRect::new(px + slot.x * scale, py + slot.y * scale, icon, icon); if let Some(ref item) = container.slot_item(i, hud.inventory) { - draw_item_icon(ui, cell, item, scale.max(2), false); + draw_item_icon(ui, cell, item, scale.max(2), false, hud.skin_rows); } if hovered == Some(i as i16) { // Vanilla highlights the hovered slot (white at ~50% alpha) — @@ -205,7 +231,7 @@ impl GuiScreen for GuiContainer { icon, icon, ); - draw_item_icon(ui, cell, item, scale.max(2), true); + draw_item_icon(ui, cell, item, scale.max(2), true, hud.skin_rows); } else if let Some(slot) = hovered { // Hovering an item with an empty cursor: show the vanilla tooltip. if let Some(ref item) = container.slot_item(slot as usize, hud.inventory) { @@ -689,6 +715,57 @@ mod tests { assert!(pose.body_yaw <= -31.0 && pose.body_yaw >= -31.5, "{pose:?}"); } + #[test] + fn the_effect_panel_shifts_the_window_and_the_preview_together() { + // The preview is projected from `window_origin`, so both must see the + // same 60-GUI-px shift; when they disagreed the biped drew outside the + // window (and over the effect panel) whenever a potion was active. + let player = Container::player(); + let (w, h, scale) = (1920, 1080, 3); + let centred = window_origin(w, h, &player, false, scale); + let shifted = window_origin(w, h, &player, true, scale); + assert_eq!(shifted.0 - centred.0, EFFECT_PANEL_SHIFT * scale); + assert_eq!(shifted.1, centred.1, "the shift is horizontal only"); + + // The preview panel and feet anchor ride the shifted origin. + let (scissor_a, anchor_a, _) = preview_layout(centred, scale); + let (scissor_b, anchor_b, _) = preview_layout(shifted, scale); + assert_eq!(scissor_b[0] - scissor_a[0], (EFFECT_PANEL_SHIFT * scale) as u32); + assert_eq!(anchor_b[0] - anchor_a[0], (EFFECT_PANEL_SHIFT * scale) as f32); + } + + #[test] + fn only_the_player_window_makes_room_for_the_effect_panel() { + // Server containers (chest here) never draw the panel, so they stay + // centred even with effects running. + let chest = Container::open(1, "minecraft:chest", "Chest".into(), 27); + assert!(!effect_panel_shown(&chest, true)); + assert_eq!( + window_origin(1920, 1080, &chest, true, 3), + window_origin(1920, 1080, &chest, false, 3), + ); + assert!(effect_panel_shown(&Container::player(), true)); + assert!(!effect_panel_shown(&Container::player(), false)); + } + + #[test] + fn worn_equipment_still_clears_the_preview_panel() { + // The preview is scissored to the panel box, so a layer taller than the + // bare biped gets clipped rather than overflowing. The tallest is a worn + // skull: `LayerCustomHead` grows the 8px head 1.1875× about the neck + // pivot at model y 24, reaching 33.5 against the biped's 32. + const WORN_HEAD_TOP_PX: f32 = 24.0 + 1.1875 * 8.0; + for scale in 1..=4 { + let (scissor, anchor, pixels_per_block) = preview_layout((0, 0), scale); + let top = anchor[1] - (WORN_HEAD_TOP_PX / 16.0) * pixels_per_block; + assert!( + top >= scissor[1] as f32, + "scale {scale}: a worn head reaches y={top}, above the panel top {}", + scissor[1], + ); + } + } + #[test] fn panel_layout_offsets_from_the_window_origin() { let (scissor, anchor, ppb) = preview_layout((100, 50), 2); diff --git a/crates/fpsmaster_app/src/gui/merchant.rs b/crates/fpsmaster_app/src/gui/merchant.rs index e925751..e4262a0 100644 --- a/crates/fpsmaster_app/src/gui/merchant.rs +++ b/crates/fpsmaster_app/src/gui/merchant.rs @@ -72,7 +72,16 @@ pub fn button_at( /// Draw the selected offer's preview items, the `< >` buttons, and (when the /// offer is locked out) the deprecated overlay. The interactive slots below are /// drawn separately by the container renderer. -pub fn draw_offer(ui: &mut UiFrame, container: &Container, px: i32, py: i32, scale: i32, mouse: (f64, f64)) { +#[allow(clippy::too_many_arguments)] +pub fn draw_offer( + ui: &mut UiFrame, + container: &Container, + px: i32, + py: i32, + scale: i32, + mouse: (f64, f64), + skin_rows: &std::collections::HashMap<[u8; 16], u32>, +) { let Some(recipe) = container.trades().get(container.selected_trade()) else { return; }; @@ -96,7 +105,7 @@ pub fn draw_offer(ui: &mut UiFrame, container: &Container, px: i32, py: i32, sca let icon = 16 * scale; let mut preview = |pos: (i32, i32), item: &fpsmaster_protocol::v1_8_9::packets::SlotItem| { let cell = UiRect::new(px + pos.0 * scale, py + pos.1 * scale, icon, icon); - draw_item_icon(ui, cell, item, scale.max(2), false); + draw_item_icon(ui, cell, item, scale.max(2), false, skin_rows); }; preview(BUY1, &recipe.buy); if let Some(ref buy2) = recipe.buy_secondary { diff --git a/crates/fpsmaster_app/src/item_renderer.rs b/crates/fpsmaster_app/src/item_renderer.rs index 953e9de..5994326 100644 --- a/crates/fpsmaster_app/src/item_renderer.rs +++ b/crates/fpsmaster_app/src/item_renderer.rs @@ -19,9 +19,10 @@ use glam::{Mat4, Vec3}; use fpsmaster_core::{BlockFace, BlockState, RenderShape, Tint}; use fpsmaster_protocol::v1_8_9::packets::SlotItem; use fpsmaster_render::texture::item_texture_name; -use fpsmaster_render::{ArmAttach, AtlasUv, BiomeColors, Camera, ModelMesh, Vertex}; +use fpsmaster_render::model::SKULL_ITEM_ROTATION; +use fpsmaster_render::{ArmAttach, AtlasUv, BiomeColors, Camera, ModelMesh, SkullKind, Vertex}; -use crate::game::{FirstPersonView, ItemUseAction}; +use crate::game::{FirstPersonView, ItemUseAction, SKULL_ITEM_ID}; /// One dropped item to render in the world: its stack, world position (entity /// feet) and animation phase (`age + partialTicks`) driving bob and spin. @@ -151,36 +152,7 @@ impl ItemRenderer { return out; }; - // Common head of the chain: swing translation and the base - // first-person pose. The item-model transforms below follow vanilla's - // RenderItem order per branch. - let mut m = first_person_base(view); - match view.use_action { - ItemUseAction::Block => { - // 1.8 locks the arm while blocking: no swing. 1.7 (old_animations) - // kept the swing playing during a block — layer the same swing - // translation + rotations vanilla uses for a free swing onto the - // block pose so a left-click visibly swings while right-blocking. - let swing = if old_animations { view.swing_progress } else { 0.0 }; - if old_animations { - do_item_used_transformations(&mut m, swing); - } - transform_first_person_item(&mut m, view.equip_progress, swing); - do_block_transformations(&mut m); - } - ItemUseAction::Eat | ItemUseAction::Drink => { - do_eat_drink_transformations(&mut m, view.use_ticks); - transform_first_person_item(&mut m, view.equip_progress, 0.0); - } - ItemUseAction::Bow => { - transform_first_person_item(&mut m, view.equip_progress, 0.0); - do_bow_transformations(&mut m, view.use_ticks); - } - ItemUseAction::None => { - do_item_used_transformations(&mut m, view.swing_progress); - transform_first_person_item(&mut m, view.equip_progress, view.swing_progress); - } - } + let mut m = first_person_hand_matrix(view, old_animations); if (0..256).contains(&item.id) { let block = BlockState::new(item.id as u16, (item.damage.max(0) & 15) as u8); @@ -230,6 +202,31 @@ impl ItemRenderer { out } + /// Build the first-person geometry for a held skull. Skulls sample the + /// entity atlas, not the block atlas the item pass binds, so they ride the + /// model mesh alongside the arm instead of [`Self::build_held_item`]'s + /// buffer. The pose chain is the one a 3D block item takes; only the + /// centring differs — vanilla's `RenderItem` swaps the model-centring + /// `translate(-0.5, -0.5, -0.5)` for a half turn on builtin-entity models, + /// and `renderSkull(-0.5, 0, -0.5, ..)` then stands the head on the model + /// origin rather than centring it, so the head sits half a unit higher. + pub fn build_held_skull( + mesh: &mut ModelMesh, + camera: &Camera, + view: &FirstPersonView, + kind: SkullKind, + skin_row: Option, + old_animations: bool, + ) { + let mut m = first_person_hand_matrix(view, old_animations); + gl_scale(&mut m, 2.0, 2.0, 2.0); + finish_item_model(&mut m); + let to_world = |c: Vec3| { + view_to_world(camera, m.transform_point3(c + Vec3::new(0.0, 0.5, 0.0))) + }; + mesh.push_skull_item(kind, skin_row, SKULL_ITEM_ROTATION, &to_world); + } + /// Build geometry for every dropped item in the world this frame: block /// items spin as a small cube, sprite items billboard toward the camera, and /// all bob up and down like vanilla `RenderEntityItem`. @@ -264,6 +261,11 @@ impl ItemRenderer { let mut v = Vec::new(); let mut i = Vec::new(); let item = &d.item; + if item.id == SKULL_ITEM_ID { + // Skulls sample the entity atlas, which this pass does not bind; + // `GameState::build_dropped_skull_models` draws them instead. + continue; + } if (0..256).contains(&item.id) { let block = BlockState::new(item.id as u16, (item.damage.max(0) & 15) as u8); if block.is_air() || block.render_shape() == RenderShape::None { @@ -490,7 +492,23 @@ fn is_handheld_item(name: &str) -> bool { /// vanilla `LayerHeldItem`/`RenderItem` THIRD_PERSON transform chain in authored /// (y-down) space, then a Y reflection into fpsmaster's feet-up arm-pixel frame and /// the rigid arm attachment. See [`ItemRenderer::build_player_held_items`]. +/// The `[0,1]` item-model-space → world mapping for a gui3d (block-shaped) item +/// held in the right hand. Exposed so a skull — a builtin-entity model that has +/// to be drawn into the model pass rather than this module's block-atlas buffers +/// — can hang off exactly the same arm as every other held item. +pub fn held_item_block_transform(attach: &ArmAttach) -> impl Fn(Vec3) -> Vec3 + '_ { + item_to_world(attach, false, HeldItemKind::Block) +} + fn held_item_to_world(h: &PlayerHeldItem, kind: HeldItemKind) -> impl Fn(Vec3) -> Vec3 + '_ { + item_to_world(&h.attach, h.sneaking, kind) +} + +fn item_to_world( + attach: &ArmAttach, + sneaking: bool, + kind: HeldItemKind, +) -> impl Fn(Vec3) -> Vec3 + '_ { // THIRD_PERSON display: translation (already block units), rotation degrees // (x, y, z), uniform scale — straight from the 1.8.9 item model JSON. let (trans, rot, scale) = match kind { @@ -503,7 +521,7 @@ fn held_item_to_world(h: &PlayerHeldItem, kind: HeldItemKind) -> impl Fn(Vec3) - // LayerHeldItem: postRenderArm anchor is supplied by ArmAttach; this is the // fixed post-arm offset, then the sneak nudge. gl_translate(&mut m, -0.0625, 0.4375, 0.0625); - if h.sneaking { + if sneaking { gl_translate(&mut m, 0.0, 0.203125, 0.0); } // ItemRenderer.renderItem: the single 2× (gui3d via renderItem, sprites via @@ -528,7 +546,7 @@ fn held_item_to_world(h: &PlayerHeldItem, kind: HeldItemKind) -> impl Fn(Vec3) - let v = m.transform_point3(c); let arm_px = ArmAttach::SHOULDER_PX + Vec3::new(v.x * 16.0, -v.y * 16.0, -v.z * 16.0); - h.attach.to_world(arm_px) + attach.to_world(arm_px) } } @@ -647,6 +665,41 @@ fn apply_first_person_display(m: &mut Mat4) { } /// `RenderItem.renderItem`: scale(0.5) then center the 0..1 model. +/// The first-person hand pose shared by every held-item model: the swing +/// translation and base pose, then the per-use-action chain in vanilla's +/// `ItemRenderer.renderItemInFirstPerson` order. The caller adds the item +/// model's own transforms on top. +fn first_person_hand_matrix(view: &FirstPersonView, old_animations: bool) -> Mat4 { + let mut m = first_person_base(view); + match view.use_action { + ItemUseAction::Block => { + // 1.8 locks the arm while blocking: no swing. 1.7 (old_animations) + // kept the swing playing during a block — layer the same swing + // translation + rotations vanilla uses for a free swing onto the + // block pose so a left-click visibly swings while right-blocking. + let swing = if old_animations { view.swing_progress } else { 0.0 }; + if old_animations { + do_item_used_transformations(&mut m, swing); + } + transform_first_person_item(&mut m, view.equip_progress, swing); + do_block_transformations(&mut m); + } + ItemUseAction::Eat | ItemUseAction::Drink => { + do_eat_drink_transformations(&mut m, view.use_ticks); + transform_first_person_item(&mut m, view.equip_progress, 0.0); + } + ItemUseAction::Bow => { + transform_first_person_item(&mut m, view.equip_progress, 0.0); + do_bow_transformations(&mut m, view.use_ticks); + } + ItemUseAction::None => { + do_item_used_transformations(&mut m, view.swing_progress); + transform_first_person_item(&mut m, view.equip_progress, view.swing_progress); + } + } + m +} + fn finish_item_model(m: &mut Mat4) { gl_scale(m, 0.5, 0.5, 0.5); gl_translate(m, -0.5, -0.5, -0.5); @@ -971,7 +1024,7 @@ mod tests { use fpsmaster_render::{arm_attach, ArmAttach, EntityAnim}; // A player at an offset position/holding pose: the item must sit at the // rendered hand, not at the origin or metres away (gross-anchor guard). - let anim = EntityAnim { held_item_right: 1, ..Default::default() }; + let anim = fpsmaster_render::EntityAnim { held_item_right: 1, ..Default::default() }; let attach = arm_attach(Vec3::new(10.0, 64.0, -3.0), 0.0, &anim); let hand = attach.to_world(ArmAttach::HAND_PX); let held = vec![PlayerHeldItem { @@ -1215,4 +1268,91 @@ mod tests { let expected = 0.4 * 2.0 * 1.7 * 0.5; assert!((a.distance(b) - expected).abs() < 1.0e-6); } + + #[test] + fn a_preview_held_item_lands_in_the_hand_not_at_the_feet() { + // The inventory preview builds the held item at a feet origin of ZERO so + // it shares the biped's model space. A px-vs-blocks slip in the arm + // chain would throw it 16× out of the panel, so pin the bounds: it must + // hang off the right shoulder, at hand height, within arm's reach. + let anim = fpsmaster_render::EntityAnim { held_item_right: 1, ..Default::default() }; + let attach = fpsmaster_render::arm_attach(Vec3::ZERO, 0.0, &anim); + let geo = ItemRenderer::build_player_held_items( + &[PlayerHeldItem { + item: SlotItem::new(1, 1, 0), // stone: a 3D block cube + attach, + sneaking: false, + light: fpsmaster_render::FULLBRIGHT, + }], + &atlas(), + ); + assert!(!geo.vertices.is_empty(), "a held block must draw"); + let (lo, hi) = geo.vertices.iter().fold( + (Vec3::splat(f32::MAX), Vec3::splat(f32::MIN)), + |(l, h), v| (l.min(Vec3::from(v.position)), h.max(Vec3::from(v.position))), + ); + // A 1.8-block-tall biped stands at the origin; the hand is roughly + // shoulder-to-hip height on its right (−x at yaw 0). + assert!(lo.y > 0.5 && hi.y < 1.8, "hand height, got {}..{}", lo.y, hi.y); + assert!(hi.x < 0.0, "the right hand is at −x, got up to {}", hi.x); + assert!(lo.length() < 2.0 && hi.length() < 2.0, "within reach: {lo:?}..{hi:?}"); + } + + #[test] + fn the_preview_item_follows_the_bodys_yaw() { + // The item is welded to the arm, so turning the model turns the item — + // this is what keeps it in the hand as the preview tracks the cursor. + let anim = fpsmaster_render::EntityAnim { held_item_right: 1, ..Default::default() }; + let build = |yaw: f32| { + ItemRenderer::build_player_held_items( + &[PlayerHeldItem { + item: SlotItem::new(1, 1, 0), + attach: fpsmaster_render::arm_attach(Vec3::ZERO, yaw, &anim), + sneaking: false, + light: fpsmaster_render::FULLBRIGHT, + }], + &atlas(), + ) + }; + let straight = centroid(&build(0.0)); + let turned = centroid(&build(90.0)); + // A quarter turn about Y swaps which axis the hand sticks out along. + assert!((straight.y - turned.y).abs() < 1e-4, "yaw must not change height"); + assert!(turned.length() > 0.1 && (straight - turned).length() > 0.1, "{straight:?} {turned:?}"); + } + + #[test] + fn a_held_skull_hangs_off_the_same_arm_transform() { + // Skulls cannot use the block-atlas buffers, so they go through + // `held_item_block_transform` into the model mesh instead — but they + // must end up in the same place a held block would. + let anim = fpsmaster_render::EntityAnim { held_item_right: 1, ..Default::default() }; + let attach = fpsmaster_render::arm_attach(Vec3::ZERO, 0.0, &anim); + let block = ItemRenderer::build_player_held_items( + &[PlayerHeldItem { + item: SlotItem::new(1, 1, 0), + attach, + sneaking: false, + light: fpsmaster_render::FULLBRIGHT, + }], + &atlas(), + ); + let mut mesh = ModelMesh::new(); + let to_world = held_item_block_transform(&attach); + mesh.push_skull_item( + fpsmaster_render::SkullKind::Skeleton, + None, + SKULL_ITEM_ROTATION, + &|c| to_world(c + Vec3::new(0.0, 0.5, 0.0)), + ); + assert!(!mesh.vertices.is_empty()); + let skull_mid = mesh.vertices.iter().map(|v| Vec3::from(v.position)).sum::() + / mesh.vertices.len() as f32; + // Same hand, so the two centroids sit within a few centimetres. + assert!( + (skull_mid - centroid(&block)).length() < 0.25, + "skull at {skull_mid:?} vs block at {:?}", + centroid(&block), + ); + } } diff --git a/crates/fpsmaster_app/src/main.rs b/crates/fpsmaster_app/src/main.rs index 6353a7d..7ec4370 100644 --- a/crates/fpsmaster_app/src/main.rs +++ b/crates/fpsmaster_app/src/main.rs @@ -2234,10 +2234,14 @@ fn build_inventory_preview( let container = app.game.open_container().expect("player container open"); let scale = gui::gui_scale(width, height); - // Window origin (vanilla `guiLeft`/`guiTop`): the panel is centred. - let origin_px = ( - (width - container.x_size * scale) / 2, - (height - container.y_size * scale) / 2, + // Window origin (vanilla `guiLeft`/`guiTop`) — shared with the container + // screen so the preview tracks the 60-px shift the effect panel forces. + let origin_px = gui::inventory::window_origin( + width, + height, + container, + app.game.has_active_effects(), + scale, ); let origin_gui = (origin_px.0 as f32 / scale as f32, origin_px.1 as f32 / scale as f32); let mouse_gui = ( @@ -2253,6 +2257,9 @@ fn build_inventory_preview( let anim = EntityAnim { net_head_yaw: pose.net_head_yaw, head_pitch: pose.head_pitch, + // Holding something lowers the right arm, so the preview matches the + // in-world model instead of showing an item hanging off a straight arm. + held_item_right: app.game.local_held_item_arm_state(), ..Default::default() }; let skin_row = app @@ -2261,8 +2268,58 @@ fn build_inventory_preview( let mut mesh = fpsmaster_render::ModelMesh::new(); mesh.push_entity(EntityKind::LocalPlayer, glam::Vec3::ZERO, pose.body_yaw, &anim, skin_row); + // Vanilla draws the real player entity here, so every worn layer shows. + // Armor first, then a head worn in the helmet slot (`LayerCustomHead`) — + // which is not armor, so the two never both draw. + let armor = app.game.local_armor(); + let armor_ids: [Option; 5] = std::array::from_fn(|i| armor[i].map(|s| s.id)); + if armor_ids[1..].iter().any(Option::is_some) { + mesh.push_armor(&armor_ids, &anim, glam::Vec3::ZERO, pose.body_yaw); + } + if let Some(head) = armor[4].filter(|s| s.id == game::SKULL_ITEM_ID) { + let (kind, owner, _) = game::skull_item_profile(head); + mesh.push_worn_skull( + kind, + owner.and_then(|uuid| app.skin_manager.rows().get(&uuid).copied()), + &anim, + glam::Vec3::ZERO, + pose.body_yaw, + ); + } + + // The held item (vanilla `LayerHeldItem`), built on the same arm attachment + // the in-world third-person item uses — with the preview's feet origin, so + // it lands in the preview's model space and rides the same projection. Most + // items sample the block atlas and go to the preview's item stream; a skull + // is entity-atlas geometry, so it joins the biped's mesh instead. + let mut held_item = item_renderer::ItemGeometry::default(); + if let Some(item) = app.game.held_item().cloned() { + let attach = fpsmaster_render::arm_attach(glam::Vec3::ZERO, pose.body_yaw, &anim); + if item.id == game::SKULL_ITEM_ID { + let (kind, owner, _) = game::skull_item_profile(&item); + let skin_row = owner.and_then(|uuid| app.skin_manager.rows().get(&uuid).copied()); + let to_world = item_renderer::held_item_block_transform(&attach); + mesh.push_skull_item(kind, skin_row, fpsmaster_render::model::SKULL_ITEM_ROTATION, &|c| { + // The head stands on the model origin instead of being centred. + to_world(c + glam::Vec3::new(0.0, 0.5, 0.0)) + }); + } else { + held_item = ItemRenderer::build_player_held_items( + &[item_renderer::PlayerHeldItem { + item, + attach, + sneaking: false, + light: fpsmaster_render::FULLBRIGHT, + }], + renderer.atlas_uv(), + ); + } + } + renderer.set_inventory_preview(Some(&fpsmaster_render::InventoryPreview { mesh: &mesh, + item_vertices: &held_item.vertices, + item_indices: &held_item.indices, anchor, pixels_per_block, tilt_rad: pose.tilt.to_radians(), @@ -2389,14 +2446,22 @@ fn render_frame( game::MovingSoundCommand::Stop { id } => app.sound.stop_moving_sound(id), } } - // Start skin downloads for newly-seen textured players, then upload any - // that finished, so the entity model can sample their atlas rows. + // Start skin downloads for newly-seen textured players and for the + // owners of any player skulls in range, then upload any that finished, + // so the entity model can sample their atlas rows. Skull owners need + // their own pass: the head can name someone who never joined, so the + // tab-list roster alone would leave it on the default skin. let new_skins: Vec<([u8; 16], String)> = app .game .player_list .iter() - .filter(|(uuid, _)| !app.skin_manager.is_requested(uuid)) .filter_map(|(uuid, info)| info.texture_property().map(|t| (*uuid, t.to_owned()))) + .chain( + app.game + .skull_skin_profiles() + .map(|(uuid, property)| (*uuid, property.clone())), + ) + .filter(|(uuid, _)| !app.skin_manager.is_requested(uuid)) .collect(); for (uuid, property) in new_skins { app.skin_manager.request(uuid, &property); @@ -2410,6 +2475,11 @@ fn render_frame( // Cull mobs shorter than terrain (capped at ENTITY_RENDER_CHUNKS) so weak // machines skip the distant crowd's per-frame articulated build. let entity_start = Instant::now(); + // Built before the entity mesh so skull stacks (entity atlas) can join + // the model pass while every other dropped item takes the block-atlas + // world-item pass below. + let mut dropped = app.game.dropped_items(tick_alpha); + dropped.extend(app.game.projectiles(tick_alpha)); let entity_chunks = app.settings.render_distance.min(ENTITY_RENDER_CHUNKS); let entity_max_dist_sq = (entity_chunks as f64 * 16.0).powi(2); let entity_key = app.game.entity_render_fingerprint( @@ -2448,8 +2518,18 @@ fn render_frame( app.settings.brightness, tick_alpha, entity_max_dist_sq, + app.skin_manager.rows(), ); renderer.set_sign_text(&sign_texts); + // Dropped skulls draw as their head model here rather than in the + // world-item pass, which binds the block atlas. + app.game.build_dropped_skull_models( + &mut app.entity_model, + &dropped, + app.settings.brightness, + tick_alpha, + app.skin_manager.rows(), + ); app.entity_model.fill_motion([0.0, 0.0, 0.0, 0.0]); if hud_visible { // Light the first-person hand + held item by the lightmap at the eye, @@ -2465,6 +2545,20 @@ fn render_frame( // item is held, since render_arm only emits the empty hand). let arm_index_start = app.entity_model.indices.len() as u32; ItemRenderer::render_arm(&mut app.entity_model, &app.game.camera, &first_person); + // A held skull joins the arm in the model pass (entity atlas) + // instead of the block-atlas first-person item buffer, so it + // lands in the same on-top range and picks up the hand lighting. + if let Some(skull) = first_person.item.as_ref().filter(|i| i.id == game::SKULL_ITEM_ID) { + let (kind, owner, _) = game::skull_item_profile(skull); + ItemRenderer::build_held_skull( + &mut app.entity_model, + &app.game.camera, + &first_person, + kind, + owner.and_then(|uuid| app.skin_manager.rows().get(&uuid).copied()), + app.settings.old_animations, + ); + } for v in &mut app.entity_model.vertices[arm_start..] { v.color[0] *= hand_light[0]; v.color[1] *= hand_light[1]; @@ -2511,8 +2605,6 @@ fn render_frame( // Dropped items, projectile sprites and falling-block cubes all share // the world-item pass (it binds the block/item atlas). Projectiles reuse // the dropped-item sprite path mapped to an item id. - let mut dropped = app.game.dropped_items(tick_alpha); - dropped.extend(app.game.projectiles(tick_alpha)); let mut world_items = ItemRenderer::build_world_items(&app.game.camera, &dropped, atlas_uv); let mut falling = app.game.falling_block_cubes(tick_alpha); @@ -2580,6 +2672,7 @@ fn render_frame( tab_open, ext, mod_textures, + skin_manager, .. } = app; // The overlay only makes sense in pure gameplay, never under an open screen. @@ -2594,7 +2687,10 @@ fn render_frame( xp_level: game.xp_level(), selected_slot: game.selected_slot(), hotbar: game.hotbar_items(), + held_item_highlight: game.held_item_highlight(), + creative: game.is_creative(), inventory: game.inventory_slots(), + skin_rows: skin_manager.rows(), container: game.open_container(), cursor_item: game.cursor_item(), effects: &active_effects, diff --git a/crates/fpsmaster_core/src/world.rs b/crates/fpsmaster_core/src/world.rs index 03e5226..f9d9ad1 100644 --- a/crates/fpsmaster_core/src/world.rs +++ b/crates/fpsmaster_core/src/world.rs @@ -32,9 +32,11 @@ impl Default for World { /// Block ids that carry a client-rendered block-entity (vanilla TileEntity): /// chest/trapped/ender (54/146/130), standing/wall sign (63/68), enchanting -/// table (116) and end portal (119). +/// table (116), end portal (119) and skull (144). These render no terrain +/// geometry of their own (shape `none`), so a block missing from this list +/// draws nothing at all. pub const fn is_block_entity(id: u16) -> bool { - matches!(id, 54 | 63 | 68 | 116 | 119 | 130 | 146) + matches!(id, 54 | 63 | 68 | 116 | 119 | 130 | 144 | 146) } /// Which of the two light nibbles a flood operates on. `light_at` returns a diff --git a/crates/fpsmaster_protocol/src/nbt.rs b/crates/fpsmaster_protocol/src/nbt.rs index 55da499..f03ff80 100644 --- a/crates/fpsmaster_protocol/src/nbt.rs +++ b/crates/fpsmaster_protocol/src/nbt.rs @@ -42,6 +42,16 @@ impl NbtTag { } } + /// The payload of a TAG_Byte. Block-entity NBT stores its small enums this + /// way (a skull's `SkullType` / `Rot`), which `as_i32` deliberately does not + /// widen into. + pub fn as_byte(&self) -> Option { + match self { + NbtTag::Byte(v) => Some(*v), + _ => None, + } + } + pub fn as_short(&self) -> Option { match self { NbtTag::Short(v) => Some(*v), diff --git a/crates/fpsmaster_protocol/src/v1_8_9/packets.rs b/crates/fpsmaster_protocol/src/v1_8_9/packets.rs index 1dc8292..99f256b 100644 --- a/crates/fpsmaster_protocol/src/v1_8_9/packets.rs +++ b/crates/fpsmaster_protocol/src/v1_8_9/packets.rs @@ -757,6 +757,17 @@ pub enum ClientboundPlayPacket { z: i32, lines: [String; 4], }, + /// S35 UpdateBlockEntity — one block-entity's client-visible NBT at its + /// block position. `action` names the payload (1 spawner, 2 command block, + /// 3 beacon, 4 skull, 5 flower pot, 6 banner); the compound is absent when + /// the server clears the block-entity, encoded as a bare TAG_End. + UpdateBlockEntity { + x: i32, + y: i32, + z: i32, + action: u8, + nbt: Option, + }, /// S3F PluginMessage — a custom-payload channel and its raw bytes (e.g. /// `MC|TrList` for villager trade offers, `MC|Brand` for the server brand). PluginMessage { @@ -1646,6 +1657,14 @@ impl ClientboundPlayPacket { ]; Ok(Self::UpdateSign { x, y, z, lines }) } + 0x35 => { + let (x, y, z) = read_block_pos(&mut body)?; + let action = body.read_u8()?; + // The NBT field uses the Slot encoding: a leading TAG_End (0) + // means "no compound" rather than an empty one. + let nbt = crate::nbt::read_slot_nbt(&mut body)?; + Ok(Self::UpdateBlockEntity { x, y, z, action, nbt }) + } 0x3f => { let channel = body.read_string(32767)?; let data = body.read_remaining().to_vec(); @@ -1986,6 +2005,48 @@ mod tests { ); } + #[test] + fn update_block_entity_decodes_a_skull_compound() { + // A wither-skeleton skull (SkullType 1) turned to rotation 6. + let mut body = PacketWriter::new(); + body.write_bytes(&encoded_block_pos(12, 70, -40)); + body.write_u8(4); // action 4 = skull + let mut nbt = vec![0x0a, 0x00, 0x00]; // TAG_Compound "" { + nbt.extend_from_slice(&[0x01, 0x00, 0x09]); + nbt.extend_from_slice(b"SkullType"); + nbt.push(0x01); // Byte SkullType: 1 + nbt.extend_from_slice(&[0x01, 0x00, 0x03]); + nbt.extend_from_slice(b"Rot"); + nbt.push(0x06); // Byte Rot: 6 + nbt.push(0x00); // TAG_End } + body.write_bytes(&nbt); + + let packet = + ClientboundPlayPacket::from_frame(PacketFrame::new(0x35, body.into_inner())).unwrap(); + let ClientboundPlayPacket::UpdateBlockEntity { x, y, z, action, nbt } = packet else { + panic!("got {packet:?}"); + }; + assert_eq!((x, y, z, action), (12, 70, -40, 4)); + let nbt = nbt.expect("compound present"); + assert_eq!(nbt.get("SkullType").and_then(|t| t.as_byte()), Some(1)); + assert_eq!(nbt.get("Rot").and_then(|t| t.as_byte()), Some(6)); + } + + #[test] + fn update_block_entity_decodes_an_absent_compound() { + // A bare TAG_End means the server cleared the block-entity. + let mut body = PacketWriter::new(); + body.write_bytes(&encoded_block_pos(0, 1, 2)); + body.write_u8(4); + body.write_u8(0); + let packet = + ClientboundPlayPacket::from_frame(PacketFrame::new(0x35, body.into_inner())).unwrap(); + assert_eq!( + packet, + ClientboundPlayPacket::UpdateBlockEntity { x: 0, y: 1, z: 2, action: 4, nbt: None } + ); + } + #[test] fn destroy_entities_decodes_id_list() { let mut body = PacketWriter::new(); diff --git a/crates/fpsmaster_render/src/gui_item.rs b/crates/fpsmaster_render/src/gui_item.rs index fdbdef5..9c795f4 100644 --- a/crates/fpsmaster_render/src/gui_item.rs +++ b/crates/fpsmaster_render/src/gui_item.rs @@ -7,8 +7,9 @@ use glam::{Mat3, Vec3}; use fpsmaster_core::{BlockFace, BlockState, Tint}; +use crate::model::SKULL_ITEM_ROTATION; use crate::ui::UiRect; -use crate::{AtlasUv, BiomeColors, Vertex, FULLBRIGHT}; +use crate::{AtlasUv, BiomeColors, ModelMesh, ModelVertex, SkullKind, Vertex, FULLBRIGHT}; /// One visible face of the unit cube: outward normal, the four `0/1` corners, /// the texture face it samples and the vanilla baked diffuse shade. @@ -141,6 +142,53 @@ pub fn append_block_icon( } } +/// Append a skull item's 3D icon geometry for slot `rect` — clip-space +/// `ModelVertex`es sampling the ENTITY atlas, so these draw in their own pass +/// rather than with the block cubes. Vanilla has no `textures/items` sprite for +/// item 397; it routes the stack through `TileEntityItemStackRenderer`, which +/// calls `renderSkull(-0.5, 0, -0.5, UP, 180)` under the same GUI pose a 3D +/// block icon gets. `skin_row` dresses a player head in a downloaded skin. +pub fn append_skull_icon( + vertices: &mut Vec, + indices: &mut Vec, + kind: SkullKind, + skin_row: Option, + rect: UiRect, + surface: (f32, f32), +) { + let rot = gui_rotation(); + // The unit cube edge in physical px (vanilla scales the 16px model by 0.625). + let edge = rect.width as f32 * 0.625; + let center = ( + rect.x as f32 + rect.width as f32 * 0.5, + rect.y as f32 + rect.height as f32 * 0.5, + ); + // `push_skull` lays a floor-mounted head out in block-local [0,1] space, so + // this shares the block icon's mapping — except in y: vanilla's item form + // puts the head's BASE on the model origin (`renderSkull(.., y = 0, ..)`, + // not -0.5), which lifts it into the upper half of the item box. + let to_clip = |p: Vec3| { + let r = rot * (p - Vec3::new(0.5, 0.0, 0.5)); + let sx = center.0 + r.x * edge; + let sy = center.1 - r.y * edge; + [ + sx / surface.0 * 2.0 - 1.0, + 1.0 - sy / surface.1 * 2.0, + (0.5 + r.z * 0.5).clamp(0.0, 1.0), + ] + }; + + let mut mesh = ModelMesh::new(); + mesh.push_skull_item(kind, skin_row, SKULL_ITEM_ROTATION, &|p| p); + let base = vertices.len() as u32; + vertices.extend(mesh.vertices.iter().map(|v| ModelVertex { + position: to_clip(Vec3::from(v.position)), + color: v.color, + uv: v.uv, + })); + indices.extend(mesh.indices.iter().map(|i| i + base)); +} + /// Append a flat item-icon's glint quad (clip-space `Vertex`es) for slot `rect`: /// an axis-aligned quad covering the slot, textured with the item sprite's /// block-atlas tile so the glint shader's alpha cutout masks the shimmer to the @@ -241,6 +289,78 @@ mod tests { } } + #[test] + fn a_skull_item_icon_is_a_head_model_inside_its_slot() { + // Item 397 has no `textures/items` sprite in vanilla, so `is_block_icon` + // rejects it (block 144 renders no boxes) and the flat path has nothing + // to draw — the head model is the icon. + assert!(is_block_icon(144, 0).is_none()); + assert!(crate::texture::item_texture_name(397).is_none()); + + let (surface, rect) = ((800.0, 600.0), UiRect::new(100, 100, 32, 32)); + let (mut v, mut i) = (Vec::new(), Vec::new()); + append_skull_icon(&mut v, &mut i, SkullKind::Skeleton, None, rect, surface); + assert_eq!(v.len(), 24, "one head box"); + assert_eq!(i.len(), 36); + + // Every vertex lands in clip space, inside the slot rect it was given. + let clip_x = |px: f32| px / surface.0 * 2.0 - 1.0; + let clip_y = |px: f32| 1.0 - px / surface.1 * 2.0; + for vert in &v { + let [x, y, z] = vert.position; + assert!((clip_x(100.0)..=clip_x(132.0)).contains(&x), "{x} outside the slot"); + assert!((clip_y(132.0)..=clip_y(100.0)).contains(&y), "{y} outside the slot"); + assert!((0.0..=1.0).contains(&z), "depth {z} out of range"); + } + } + + #[test] + fn the_skull_icon_shows_the_face_not_the_back_of_the_head() { + // The whole point of a 3D head icon is telling a Steve head from a + // creeper head at a glance, so the face quad must be the front-most of + // the six. Getting the item rotation wrong turns the head 180° and + // shows the back — which looks plausible until you compare two types. + let (mut v, mut i) = (Vec::new(), Vec::new()); + let rect = UiRect::new(0, 0, 32, 32); + append_skull_icon(&mut v, &mut i, SkullKind::Player, None, rect, (800.0, 600.0)); + // `push_textured_box` emits faces in `FACES` order; index 3 is the +z + // face, which `box_region` paints with the skin's face rect. + let depth = |face: usize| { + v[face * 4..face * 4 + 4].iter().map(|x| x.position[2]).sum::() / 4.0 + }; + let face_depth = depth(3); + // Strictly in front of the back of the head — this is the assertion the + // 180°-flipped orientation fails. + assert!(face_depth < depth(2), "face {face_depth} vs back {}", depth(2)); + // And front-most overall. The isometric pose shows two sides equally, so + // the -x quad ties with the face rather than losing to it. + for other in [0, 1, 2, 4, 5] { + assert!( + face_depth <= depth(other), + "face quad (depth {face_depth}) sits behind face {other} (depth {})", + depth(other), + ); + } + } + + #[test] + fn a_player_head_icon_wears_its_owners_skin_row() { + let rect = UiRect::new(0, 0, 32, 32); + let mut plain = (Vec::new(), Vec::new()); + let mut owned = (Vec::new(), Vec::new()); + append_skull_icon(&mut plain.0, &mut plain.1, SkullKind::Player, None, rect, (800.0, 600.0)); + append_skull_icon(&mut owned.0, &mut owned.1, SkullKind::Player, Some(2), rect, (800.0, 600.0)); + // Player heads carry the hat layer, and a resolved owner moves the whole + // icon onto that player's atlas row. + assert_eq!(plain.0.len(), 48, "head + hat"); + let plain_uvs: Vec<_> = plain.0.iter().map(|v| v.uv).collect(); + let owned_uvs: Vec<_> = owned.0.iter().map(|v| v.uv).collect(); + assert_ne!(plain_uvs, owned_uvs); + // Geometry is identical; only the sampled region moved. + let pos = |vs: &[ModelVertex]| vs.iter().map(|v| v.position).collect::>(); + assert_eq!(pos(&plain.0), pos(&owned.0)); + } + #[test] fn unknown_item_emits_no_glint() { let atlas = TextureAtlasImage::load_default().uv_table(); diff --git a/crates/fpsmaster_render/src/item_names.rs b/crates/fpsmaster_render/src/item_names.rs index a481ae9..f1665e8 100644 --- a/crates/fpsmaster_render/src/item_names.rs +++ b/crates/fpsmaster_render/src/item_names.rs @@ -277,6 +277,48 @@ fn title_case(s: &str) -> String { .join(" ") } +/// A stack's rendered display name, `§`-coded: the rarity color, then either +/// the custom NBT name (italic, vanilla `ItemStack.hasDisplayName`) or the +/// item's own name for its metadata. Vanilla `ItemStack.getDisplayName` plus +/// the italic that `GuiIngame`/tooltips add for renamed stacks — shared so the +/// hotbar name banner and the tooltip's first line can never drift apart. +pub fn stack_display_name(item: &SlotItem) -> String { + let nbt = item.nbt.as_ref(); + let has_custom_name = has_display_name(nbt); + let name = if has_custom_name { + custom_display_name(nbt).unwrap_or_default() + } else { + item_display_name(item.id, item.damage) + }; + let rarity_color = item_rarity_color(item); + if has_custom_name { + // Custom names are italic. + format!("{rarity_color}\u{00a7}o{name}\u{00a7}r") + } else { + format!("{rarity_color}{name}\u{00a7}r") + } +} + +/// Whether an item id has durability (vanilla `ItemStack.isItemStackDamageable`, +/// i.e. `maxDamage > 0`): the tools, weapons, bow, flint & steel, all four armor +/// pieces of each material, fishing rod, shears and the carrot on a stick. For +/// these the `damage` field is wear, not a variant — callers that compare two +/// stacks must ignore it, or a pickaxe would read as a different item after +/// every block broken. +pub fn is_damageable(id: i16) -> bool { + matches!(id, + 256..=259 // iron shovel/pickaxe/axe, flint and steel + | 261 // bow + | 267..=279 // iron + wooden + stone + diamond sword/shovel/pickaxe/axe + | 283..=286 // golden sword/shovel/pickaxe/axe + | 290..=294 // hoes + | 298..=317 // leather / chainmail / iron / diamond / golden armor + | 346 // fishing rod + | 359 // shears + | 398 // carrot on a stick + ) +} + /// Build the vanilla-style tooltip lines for an item stack, including NBT data. /// Each line may contain `§` formatting codes (rarity color, enchant gray, lore /// purple/italic). The first line is always the display name with rarity color. @@ -290,21 +332,7 @@ pub fn build_tooltip(item: &SlotItem) -> Vec { let mut lines = Vec::new(); // Line 1: display name with rarity color. - let has_custom_name = has_display_name(nbt); - let name = if has_custom_name { - custom_display_name(nbt).unwrap_or_default() - } else { - item_display_name(item.id, item.damage) - }; - - let rarity_color = item_rarity_color(item); - let name_line = if has_custom_name { - // Custom names are italic. - format!("{rarity_color}\u{00a7}o{name}\u{00a7}r") - } else { - format!("{rarity_color}{name}\u{00a7}r") - }; - lines.push(name_line); + lines.push(stack_display_name(item)); // Enchantments (flag bit 0). if hide_flags & 1 == 0 { diff --git a/crates/fpsmaster_render/src/lib.rs b/crates/fpsmaster_render/src/lib.rs index 17c88c4..71e7eb4 100644 --- a/crates/fpsmaster_render/src/lib.rs +++ b/crates/fpsmaster_render/src/lib.rs @@ -25,9 +25,9 @@ pub use chunk_mesh::{ ChunkMeshBuffers, ChunkVertex, EmissiveLight, TintTable, Vertex, FULLBRIGHT, }; pub use font::char_width; -pub use item_names::{build_tooltip, item_display_name}; +pub use item_names::{build_tooltip, is_damageable, item_display_name, stack_display_name}; pub use model::{ - arm_attach, ArmAttach, ChestKind, EntityAnim, ModelMesh, ModelVertex, SignKind, + arm_attach, ArmAttach, ChestKind, EntityAnim, ModelMesh, ModelVertex, SignKind, SkullKind, }; pub use particle::{build_particle_mesh, ParticleBillboard}; pub use renderer::{ diff --git a/crates/fpsmaster_render/src/model.rs b/crates/fpsmaster_render/src/model.rs index 2ea5e72..54e63d2 100644 --- a/crates/fpsmaster_render/src/model.rs +++ b/crates/fpsmaster_render/src/model.rs @@ -2063,6 +2063,133 @@ fn wall_sign_yaw(meta: u8) -> f32 { } } +// ─── Skull block-entity ──────────────────────────────────────────────────────── + +/// Which head a skull draws — vanilla's `SkullType` NBT byte (block 144's +/// block-entity, and the damage value of item 397). Each samples a different +/// entity texture, and only the player head carries a hat layer: vanilla gives +/// it `ModelHumanoidHead` while the rest use the bare `ModelSkeletonHead`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkullKind { + Skeleton, + WitherSkeleton, + Zombie, + Player, + Creeper, +} + +impl SkullKind { + /// The kind for a `SkullType` value; unknown types fall back to the + /// skeleton skull, as vanilla's `default` branch does. + pub fn from_type(skull_type: i32) -> Self { + match skull_type { + 1 => Self::WitherSkeleton, + 2 => Self::Zombie, + 3 => Self::Player, + 4 => Self::Creeper, + _ => Self::Skeleton, + } + } + + /// The entity-atlas slot whose head region this skull samples. + fn slot(self) -> EntitySlot { + match self { + Self::Skeleton => EntitySlot::Skeleton, + Self::WitherSkeleton => EntitySlot::SkeletonWither, + Self::Zombie => EntitySlot::Zombie, + Self::Player => EntitySlot::Player, + Self::Creeper => EntitySlot::Creeper, + } + } + + /// Whether the skin's hat overlay layers over the head box. + fn has_hat(self) -> bool { + matches!(self, Self::Player) + } +} + +/// The block metadata of a floor-mounted skull (`EnumFacing.UP`) — the form the +/// item icon and a worn head draw, and the only one that honours `Rot`. +pub const SKULL_META_FLOOR: u8 = 1; + +/// The `Rot` step an item-form skull is drawn at, so its face points at the +/// viewer. Vanilla stacks two half turns here — `RenderItem.renderItem` turns +/// every builtin-entity model 180° about Y, and `TileEntityItemStackRenderer` +/// then calls `renderSkull(.., 180.0F, ..)` — and the pair cancels, landing on +/// the same orientation `Rot` 0 gives a placed skull. +pub const SKULL_ITEM_ROTATION: u8 = 0; + +/// How much bigger a skull worn on the head is than the head it covers +/// (vanilla `LayerCustomHead`'s `f1 = 1.1875`), applied about the neck pivot so +/// the two boxes share a bottom plane and the skull encloses the wearer's head. +const WORN_SKULL_SCALE: f32 = 1.1875; + +/// The humanoid head's rotation point in engine model px — where a worn skull +/// hangs from, and the pivot of `humanoid_poses`' head entry. +const HUMANOID_HEAD_PIVOT_Y: f32 = 24.0; + +/// The two head layers of a skull, as engine [`Part`]s: vanilla +/// `ModelSkeletonHead`'s 8³ box at tex (0,0), then `ModelHumanoidHead`'s +/// 0.25-grown hat at (32,0) (drawn for player heads only). The box hangs above +/// the pivot — `scale` grows it about that pivot (1 as a block, `WORN_SKULL_SCALE` +/// worn on a head) and `pivot_y` lifts it into the target frame. +fn skull_parts(scale: f32, pivot_y: f32) -> [Part; 2] { + let layer = |grow: f32, tex: [f32; 2]| -> Part { + // `grow` is vanilla's `addBox` inflation, in model px before the scale. + let lo = Vec3::new(-4.0 - grow, -grow, -4.0 - grow) * scale; + let hi = Vec3::new(4.0 + grow, 8.0 + grow, 4.0 + grow) * scale; + let up = Vec3::new(0.0, pivot_y, 0.0); + ( + (lo + up).to_array(), + (hi + up).to_array(), + box_region(tex[0], tex[1], 8.0, 8.0, 8.0), + ) + }; + [layer(0.0, [0.0, 0.0]), layer(0.25, [32.0, 0.0])] +} + +/// The atlas slot a skull samples: a player head takes its owner's downloaded +/// skin row when one is resolved, everything else its kind's fixed slot. +fn skull_slot(kind: SkullKind, skin_row: Option) -> u32 { + match (kind, skin_row) { + (SkullKind::Player, Some(row)) => PLAYER_SKIN_BASE_ROW + row, + _ => kind.slot() as u32, + } +} + +/// How many of [`skull_parts`]' layers this kind draws (the hat is player-only). +fn skull_layer_count(kind: SkullKind) -> usize { + if kind.has_hat() { + 2 + } else { + 1 + } +} + +/// A floor skull's model yaw for its 0..15 block-entity `Rot` (vanilla +/// `TileEntitySkullRenderer`: `skullRotation * 360 / 16`). +fn skull_rotation_degrees(rotation: u8) -> f32 { + (rotation & 15) as f32 * 360.0 / 16.0 +} + +/// A skull's offset inside its cell and its model yaw in degrees, porting the +/// facing switch in `TileEntitySkullRenderer.renderSkull`. The metadata is an +/// `EnumFacing` index: 1 (UP) stands the head on the floor and keeps the +/// block-entity's own rotation, while 2..5 hang it at mid-height on a wall and +/// override the rotation so the face points out of that wall. NORTH is the one +/// wall facing vanilla's switch does *not* override — harmless, since wall +/// skulls carry `Rot` 0. Metadata 0 (DOWN) never occurs on a placed skull and +/// falls through to the EAST branch, matching vanilla's `default`. +fn skull_placement(meta: u8, rotation_deg: f32) -> (Vec3, f32) { + match meta & 7 { + 1 => (Vec3::new(0.5, 0.0, 0.5), rotation_deg), + 2 => (Vec3::new(0.5, 0.25, 0.74), rotation_deg), + 3 => (Vec3::new(0.5, 0.25, 0.26), 180.0), + 4 => (Vec3::new(0.74, 0.25, 0.5), 270.0), + _ => (Vec3::new(0.26, 0.25, 0.5), 90.0), + } +} + impl ModelMesh { /// Append a sign block-entity (vanilla `ModelSign` + `TileEntitySignRenderer`) /// at world cell `cell`, oriented by its block `meta`. Standing signs sit on a @@ -2154,6 +2281,104 @@ impl ModelMesh { ) } + /// Append a skull block-entity (vanilla `ModelSkeletonHead` / + /// `ModelHumanoidHead` via `TileEntitySkullRenderer`) at world cell `cell`. + /// `meta` is the block metadata (an `EnumFacing` index: 1 stands on the + /// floor, 2..5 hang on a wall), `rotation` the block-entity's 0..15 `Rot` + /// (floor skulls only), and `skin_row` the owner's downloaded skin row for a + /// player head. Samples the head region of the matching entity texture. + pub fn push_skull( + &mut self, + cell: [i32; 3], + meta: u8, + rotation: u8, + kind: SkullKind, + skin_row: Option, + ) { + let (offset, model_yaw) = skull_placement(meta, skull_rotation_degrees(rotation)); + // `renderSkull`'s `scale(-1,-1,1)` is the standard entity flip + // (`diag(1,-1,-1)`, what `vbox` bakes into every mob part) composed with + // a half turn about Y. Folding the flip into the box bounds therefore + // leaves a plain yaw of `model_yaw + 180` — which is why a floor skull + // faces back at the player who placed it rather than away. + let yaw = (model_yaw + 180.0).to_radians(); + let (sy, cy) = yaw.sin_cos(); + let base = Vec3::new(cell[0] as f32, cell[1] as f32, cell[2] as f32) + offset; + + let (ox, oy) = slot_grid_origin(skull_slot(kind, skin_row)); + let origin = [ox as f32, oy as f32]; + + let layers = skull_parts(1.0, 0.0); + for (min_px, max_px, region) in &layers[..skull_layer_count(kind)] { + self.push_textured_box( + Vec3::from(*min_px), + Vec3::from(*max_px), + region, + origin, + 1.0, + &|p| base + rotate_y(p * MODEL_SCALE, sy, cy), + ); + } + } + + /// Append a skull worn in the head slot (vanilla `LayerCustomHead`), riding + /// the wearer's head through the same pose the helmet layer uses. Vanilla + /// composes `scale(1.1875, -1.1875, -1.1875)` with `renderSkull`'s own + /// `scale(-1,-1,1)` and its 180° item turn; those cancel to a plain 1.1875× + /// growth about the neck pivot, which is why a worn head faces the same way + /// the wearer does and shares the head box's bottom plane. + pub fn push_worn_skull( + &mut self, + kind: SkullKind, + skin_row: Option, + anim: &EntityAnim, + feet: Vec3, + yaw_degrees: f32, + ) { + self.death_roll = anim.death_roll; + let head_pose = humanoid_poses(anim)[5]; + let layers = skull_parts(WORN_SKULL_SCALE, HUMANOID_HEAD_PIVOT_Y); + let count = skull_layer_count(kind); + self.push_parts( + &layers[..count], + &[head_pose; 2][..count], + skull_slot(kind, skin_row), + feet, + yaw_degrees, + ); + self.death_roll = 0.0; + } + + /// Append a skull item's head model through an arbitrary `transform`, which + /// maps a point in the skull's own block-local space (an upright floor skull + /// in `[0,1]³`, as [`push_skull`] builds at a cell origin) to world space. + /// The first-person hand and dropped-item passes use this to put the head + /// where their item transform chains say the item goes. + pub fn push_skull_item( + &mut self, + kind: SkullKind, + skin_row: Option, + rotation: u8, + transform: &dyn Fn(Vec3) -> Vec3, + ) { + let (offset, model_yaw) = skull_placement(SKULL_META_FLOOR, skull_rotation_degrees(rotation)); + let (sy, cy) = (model_yaw + 180.0).to_radians().sin_cos(); + let (ox, oy) = slot_grid_origin(skull_slot(kind, skin_row)); + let origin = [ox as f32, oy as f32]; + + let layers = skull_parts(1.0, 0.0); + for (min_px, max_px, region) in &layers[..skull_layer_count(kind)] { + self.push_textured_box( + Vec3::from(*min_px), + Vec3::from(*max_px), + region, + origin, + 1.0, + &|p| transform(offset + rotate_y(p * MODEL_SCALE, sy, cy)), + ); + } + } + /// Append the floating enchanting-table book (vanilla `ModelBook` via /// `TileEntityEnchantmentTableRenderer`) above the table at world cell `cell`. /// `time` is a free-running tick counter (`tickCount + partialTicks`): it @@ -3108,4 +3333,260 @@ mod tests { .collect(); assert!(vs.contains(&0.0) && vs.contains(&10.0), "cover front spans the full rect height upright"); } + + /// The four corners of one face of the first box in a mesh. + /// `push_textured_box` emits faces in `FACES` order, four verts each, so + /// index 3 is the box's local +z face — the one `box_region` paints with the + /// head's face texture. + fn skull_box(mesh: &ModelMesh, box_index: usize) -> &[ModelVertex] { + &mesh.vertices[box_index * 24..box_index * 24 + 24] + } + + fn centroid(verts: &[ModelVertex]) -> Vec3 { + verts.iter().fold(Vec3::ZERO, |a, v| a + Vec3::from(v.position)) / verts.len() as f32 + } + + /// Which way the head's face points: the local +z quad's centroid relative + /// to the head box's centre, normalized. + fn face_direction(mesh: &ModelMesh) -> Vec3 { + let head = skull_box(mesh, 0); + (centroid(&head[12..16]) - centroid(head)).normalize() + } + + fn skull(meta: u8, rotation: u8, kind: SkullKind) -> ModelMesh { + let mut mesh = ModelMesh::new(); + mesh.push_skull([4, 70, -9], meta, rotation, kind, None); + mesh + } + + #[test] + fn a_floor_skull_fills_the_collision_box_it_already_had() { + // Block 144's floor collision is (0.25, 0, 0.25)..(0.75, 0.5, 0.75) — + // the 8px head at 1/16 scale, standing on the cell floor. The model was + // missing entirely, so the box was there to walk into but nothing drew. + let mesh = skull(1, 0, SkullKind::Skeleton); + assert_well_formed(&mesh); + let head = skull_box(&mesh, 0); + let lo = head.iter().fold(Vec3::splat(f32::MAX), |a, v| a.min(Vec3::from(v.position))); + let hi = head.iter().fold(Vec3::splat(f32::MIN), |a, v| a.max(Vec3::from(v.position))); + let cell = Vec3::new(4.0, 70.0, -9.0); + assert!((lo - (cell + Vec3::new(0.25, 0.0, 0.25))).length() < 1e-5, "{lo:?}"); + assert!((hi - (cell + Vec3::new(0.75, 0.5, 0.75))).length() < 1e-5, "{hi:?}"); + } + + #[test] + fn a_floor_skull_turns_to_face_the_player_who_placed_it() { + // `Rot` is the placer's yaw quantized to 16ths, and vanilla's + // `scale(-1,-1,1)` adds the half turn that points the face back at them: + // a player looking south (yaw 0 → Rot 0) gets a head looking north. + // Each of the 16 steps is 22.5°, and the head always ends up looking + // back down the placer's line of sight: yaw 0 (south) → Rot 0 → north, + // yaw 90 (west) → Rot 4 → east, and so on around the compass. + for (rot, placer_facing, head_facing) in [ + (0u8, Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.0, 0.0, -1.0)), + (4, Vec3::new(-1.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)), + (8, Vec3::new(0.0, 0.0, -1.0), Vec3::new(0.0, 0.0, 1.0)), + (12, Vec3::new(1.0, 0.0, 0.0), Vec3::new(-1.0, 0.0, 0.0)), + ] { + let dir = face_direction(&skull(1, rot, SkullKind::Skeleton)); + assert!((dir - head_facing).length() < 1e-5, "Rot {rot}: {dir:?}"); + assert!((dir + placer_facing).length() < 1e-5, "Rot {rot} must face the placer"); + } + } + + #[test] + fn wall_skulls_hang_where_their_collision_box_is_and_look_out_of_the_wall() { + // Metadata 2..5 is an EnumFacing index. Each hangs at y 0.25..0.75 and + // looks out along its facing, matching BlockSkull's per-facing bounds. + let cell = Vec3::new(4.0, 70.0, -9.0); + for (meta, centre, facing) in [ + (2u8, Vec3::new(0.5, 0.5, 0.74), Vec3::new(0.0, 0.0, -1.0)), + (3, Vec3::new(0.5, 0.5, 0.26), Vec3::new(0.0, 0.0, 1.0)), + (4, Vec3::new(0.74, 0.5, 0.5), Vec3::new(-1.0, 0.0, 0.0)), + (5, Vec3::new(0.26, 0.5, 0.5), Vec3::new(1.0, 0.0, 0.0)), + ] { + let mesh = skull(meta, 0, SkullKind::Skeleton); + assert_well_formed(&mesh); + let mid = centroid(skull_box(&mesh, 0)); + assert!((mid - (cell + centre)).length() < 1e-5, "meta {meta}: {mid:?}"); + let dir = face_direction(&mesh); + assert!((dir - facing).length() < 1e-5, "meta {meta}: {dir:?}"); + } + } + + #[test] + fn each_skull_type_samples_its_own_texture_and_only_players_wear_a_hat() { + for (kind, slot) in [ + (SkullKind::Skeleton, EntitySlot::Skeleton), + (SkullKind::WitherSkeleton, EntitySlot::SkeletonWither), + (SkullKind::Zombie, EntitySlot::Zombie), + (SkullKind::Creeper, EntitySlot::Creeper), + (SkullKind::Player, EntitySlot::Player), + ] { + let mesh = skull(1, 0, kind); + assert_well_formed(&mesh); + // Check both axes: the atlas is two slots wide, so a V-only bound + // would also accept the neighbouring slot in the same row. + let (ox, _) = entity_slot_origin(slot); + let (u0, u1) = ( + ox as f32 / ENTITY_ATLAS_WIDTH as f32, + (ox + ENTITY_SLOT_PX) as f32 / ENTITY_ATLAS_WIDTH as f32, + ); + let (v0, v1) = slot_v_range(slot); + assert!( + mesh.vertices + .iter() + .all(|v| (u0..=u1).contains(&v.uv[0]) && (v0..=v1).contains(&v.uv[1])), + "{kind:?} sampled outside its slot" + ); + // One box (6 faces × 4 verts), plus the hat layer for player heads. + let expected = if matches!(kind, SkullKind::Player) { 48 } else { 24 }; + assert_eq!(mesh.vertices.len(), expected, "{kind:?}"); + } + // The hat is the same box grown 0.25px, sampling the (32,0) overlay rect. + let player = skull(1, 0, SkullKind::Player); + let head_lo = skull_box(&player, 0) + .iter() + .fold(Vec3::splat(f32::MAX), |a, v| a.min(Vec3::from(v.position))); + let hat_lo = skull_box(&player, 1) + .iter() + .fold(Vec3::splat(f32::MAX), |a, v| a.min(Vec3::from(v.position))); + assert!(hat_lo.x < head_lo.x && hat_lo.y < head_lo.y, "hat must enclose the head"); + let head_us: Vec = skull_box(&player, 0).iter().map(|v| v.uv[0]).collect(); + let hat_us: Vec = skull_box(&player, 1).iter().map(|v| v.uv[0]).collect(); + assert_ne!(head_us, hat_us, "the hat samples its own UV region"); + } + + #[test] + fn the_face_quad_samples_the_skins_face_rect() { + // The head's front face must land on texture (8,8)..(16,16) — the face + // in every 1.8 skin/mob head layout. A mis-derived flip would paint an + // ear or the back of the head there instead. + let mesh = skull(1, 0, SkullKind::Skeleton); + let (ox, oy) = entity_slot_origin(EntitySlot::Skeleton); + for v in &skull_box(&mesh, 0)[12..16] { + let upx = v.uv[0] * ENTITY_ATLAS_WIDTH as f32 - ox as f32; + let vpx = v.uv[1] * ENTITY_ATLAS_HEIGHT as f32 - oy as f32; + assert!( + (8.0..=16.0).contains(&upx) && (8.0..=16.0).contains(&vpx), + "face quad sampled ({upx},{vpx}), want the (8,8)..(16,16) face rect" + ); + } + } + + #[test] + fn the_item_form_lands_in_block_local_space_for_its_caller_to_place() { + // `push_skull_item` hands its caller the same [0,1]³ block-local frame a + // block icon works in, so the GUI / hand / dropped-item transforms can be + // shared — except in y, where the head stands on the model origin. + let mut mesh = ModelMesh::new(); + mesh.push_skull_item(SkullKind::Skeleton, None, SKULL_ITEM_ROTATION, &|p| p); + let (lo, hi) = mesh.vertices.iter().fold( + (Vec3::splat(f32::MAX), Vec3::splat(f32::MIN)), + |(l, h), v| (l.min(Vec3::from(v.position)), h.max(Vec3::from(v.position))), + ); + assert!((lo - Vec3::new(0.25, 0.0, 0.25)).length() < 1e-5, "{lo:?}"); + assert!((hi - Vec3::new(0.75, 0.5, 0.75)).length() < 1e-5, "{hi:?}"); + + // The transform is applied to every vertex. + let mut moved = ModelMesh::new(); + moved.push_skull_item(SkullKind::Skeleton, None, SKULL_ITEM_ROTATION, &|p| { + p + Vec3::new(10.0, 0.0, 0.0) + }); + for (a, b) in mesh.vertices.iter().zip(&moved.vertices) { + assert!((b.position[0] - a.position[0] - 10.0).abs() < 1e-5); + } + } + + #[test] + fn a_worn_skull_encloses_the_wearers_head() { + // Vanilla's 1.1875× growth is about the neck pivot, so the worn skull + // shares the head box's bottom plane and wraps it on the other five + // sides — not a head-sized box z-fighting with the player's own. + let mut worn = ModelMesh::new(); + worn.push_worn_skull( + SkullKind::Skeleton, + None, + &EntityAnim::default(), + Vec3::new(2.0, 70.0, 3.0), + 0.0, + ); + assert_eq!(worn.vertices.len(), 24); + + let mut player = ModelMesh::new(); + player.push_entity( + EntityKind::RemotePlayer, + Vec3::new(2.0, 70.0, 3.0), + 0.0, + &EntityAnim::default(), + None, + ); + // The player's own head is box 5 of the seven humanoid parts. + let head = &player.vertices[120..144]; + let bounds = |vs: &[ModelVertex]| { + vs.iter().fold( + (Vec3::splat(f32::MAX), Vec3::splat(f32::MIN)), + |(lo, hi), v| (lo.min(Vec3::from(v.position)), hi.max(Vec3::from(v.position))), + ) + }; + let (hlo, hhi) = bounds(head); + let (slo, shi) = bounds(&worn.vertices); + assert!((slo.y - hlo.y).abs() < 1e-5, "shares the neck plane: {slo:?} vs {hlo:?}"); + assert!(slo.x < hlo.x && slo.z < hlo.z, "wraps the head: {slo:?} vs {hlo:?}"); + assert!(shi.x > hhi.x && shi.y > hhi.y && shi.z > hhi.z, "{shi:?} vs {hhi:?}"); + // 1.1875× an 8px head is 9.5px = 0.59375 blocks tall. + assert!(((shi.y - slo.y) - 9.5 / 16.0).abs() < 1e-5, "{}", shi.y - slo.y); + } + + #[test] + fn a_worn_skull_turns_and_tilts_with_the_head() { + // It rides the head pose, so a look-up/left turn moves it exactly as the + // helmet layer moves — otherwise the head would float off the shoulders. + let anim = EntityAnim { net_head_yaw: 40.0, head_pitch: -25.0, ..Default::default() }; + let mut turned = ModelMesh::new(); + turned.push_worn_skull(SkullKind::Zombie, None, &anim, Vec3::ZERO, 0.0); + let mut still = ModelMesh::new(); + still.push_worn_skull(SkullKind::Zombie, None, &EntityAnim::default(), Vec3::ZERO, 0.0); + assert_ne!( + turned.vertices.iter().map(|v| v.position).collect::>(), + still.vertices.iter().map(|v| v.position).collect::>(), + ); + // The wearer's body yaw turns it too. + let mut yawed = ModelMesh::new(); + yawed.push_worn_skull(SkullKind::Zombie, None, &EntityAnim::default(), Vec3::ZERO, 90.0); + assert_ne!( + yawed.vertices.iter().map(|v| v.position).collect::>(), + still.vertices.iter().map(|v| v.position).collect::>(), + ); + } + + #[test] + fn a_worn_player_head_adds_the_hat_and_takes_the_owner_skin() { + let mut mesh = ModelMesh::new(); + mesh.push_worn_skull(SkullKind::Player, Some(1), &EntityAnim::default(), Vec3::ZERO, 0.0); + assert_eq!(mesh.vertices.len(), 48, "head + hat"); + let (_, oy) = slot_grid_origin(PLAYER_SKIN_BASE_ROW + 1); + let v0 = oy as f32 / ENTITY_ATLAS_HEIGHT as f32; + let v1 = (oy + ENTITY_SLOT_PX) as f32 / ENTITY_ATLAS_HEIGHT as f32; + assert!(mesh.vertices.iter().all(|v| (v0..=v1).contains(&v.uv[1]))); + } + + #[test] + fn a_player_skull_with_a_resolved_owner_samples_that_skin_row() { + let mut mesh = ModelMesh::new(); + mesh.push_skull([0, 0, 0], 1, 0, SkullKind::Player, Some(3)); + let (_, oy) = slot_grid_origin(PLAYER_SKIN_BASE_ROW + 3); + let v0 = oy as f32 / ENTITY_ATLAS_HEIGHT as f32; + let v1 = (oy + ENTITY_SLOT_PX) as f32 / ENTITY_ATLAS_HEIGHT as f32; + assert!(mesh.vertices.iter().all(|v| (v0..=v1).contains(&v.uv[1]))); + } + + #[test] + fn unknown_skull_types_fall_back_to_the_skeleton_skull() { + assert_eq!(SkullKind::from_type(0), SkullKind::Skeleton); + assert_eq!(SkullKind::from_type(1), SkullKind::WitherSkeleton); + assert_eq!(SkullKind::from_type(3), SkullKind::Player); + assert_eq!(SkullKind::from_type(9), SkullKind::Skeleton); + assert_eq!(SkullKind::from_type(-1), SkullKind::Skeleton); + } } diff --git a/crates/fpsmaster_render/src/renderer/mod.rs b/crates/fpsmaster_render/src/renderer/mod.rs index 11c81af..ba43e57 100644 --- a/crates/fpsmaster_render/src/renderer/mod.rs +++ b/crates/fpsmaster_render/src/renderer/mod.rs @@ -629,6 +629,12 @@ pub struct SignTextDraw { pub struct InventoryPreview<'a> { /// The posed local-player biped (feet at y=0, +y up, +z front toward viewer). pub mesh: &'a ModelMesh, + /// Held-item geometry in the same model space, sampling the BLOCK atlas + /// rather than the entity atlas the biped uses — so it needs its own vertex + /// type and draw. Both streams take the identical projection, so the item + /// stays welded to the hand however the model leans. + pub item_vertices: &'a [Vertex], + pub item_indices: &'a [u32], /// Feet anchor in physical px (panel-center x, near the panel bottom). pub anchor: [f32; 2], /// Physical px per model block (vanilla `scale` × the GUI pixel scale). @@ -727,6 +733,9 @@ pub struct Renderer { /// `surface_format` target (the UI pass writes the swapchain, not HDR). inventory_preview_pipeline: wgpu::RenderPipeline, inventory_preview_mesh: Option, + /// Held-item geometry for the preview (block atlas), projected with the + /// same transform as the biped and clipped to the same panel. + inventory_preview_item_mesh: Option, /// Scissor rect (physical px: x, y, w, h) clipping the preview to its panel. inventory_preview_scissor: Option<[u32; 4]>, /// First-person held-item geometry (block-atlas textured), per frame. @@ -847,6 +856,9 @@ pub struct Renderer { gui_camera_bind_group: wgpu::BindGroup, /// 3D block-icon geometry for this frame (block atlas textured, clip-space). gui_item_mesh: Option, + /// 3D skull-head item icons (entity atlas), drawn in the UI pass right + /// after the block cubes so they share its depth buffer. + gui_skull_mesh: Option, /// Clip-space glint geometry for enchanted GUI item icons, drawn additively /// with the scrolling glint texture over the icons in the UI pass. gui_glint_mesh: Option, @@ -5151,6 +5163,7 @@ impl Renderer { debug_tris: None, inventory_preview_pipeline, inventory_preview_mesh: None, + inventory_preview_item_mesh: None, inventory_preview_scissor: None, first_person_item: None, world_items: None, @@ -5216,6 +5229,7 @@ impl Renderer { ui_crosshair, gui_camera_bind_group, gui_item_mesh: None, + gui_skull_mesh: None, gui_glint_mesh: None, nametag_mesh: None, nametag_texture, @@ -7339,7 +7353,13 @@ impl Renderer { pub fn set_inventory_preview(&mut self, preview: Option<&InventoryPreview>) { let Some(p) = preview else { self.inventory_preview_scissor = None; - if let Some(m) = self.inventory_preview_mesh.as_mut() { + for m in [ + self.inventory_preview_mesh.as_mut(), + self.inventory_preview_item_mesh.as_mut(), + ] + .into_iter() + .flatten() + { m.index_count = 0; } return; @@ -7388,6 +7408,25 @@ impl Renderer { p.mesh.indices.len() as u32, "inventory-preview", ); + // The held item takes the very same projection, so it tracks the model's + // cursor lean instead of sliding off the hand. + let item_vertices: Vec = p + .item_vertices + .iter() + .map(|v| Vertex { + position: to_clip(v.position), + ..*v + }) + .collect(); + fill_dynamic_mesh( + &self.device, + &self.queue, + &mut self.inventory_preview_item_mesh, + bytemuck::cast_slice(&item_vertices), + bytemuck::cast_slice(p.item_indices), + p.item_indices.len() as u32, + "inventory-preview-item", + ); self.inventory_preview_scissor = Some(p.scissor); } @@ -10555,6 +10594,19 @@ impl Renderer { up.draw_indexed(0..mesh.index_count, 0, 0..1); draw_calls += 1; } + // Skull item icons: head models from the entity atlas, drawn with + // the preview pipeline (same vertex layout and bind groups) into the + // depth the block cubes just wrote, so a skull and a cube in + // neighbouring slots occlude their own faces alike. + if let Some(mesh) = self.gui_skull_mesh.as_ref().filter(|m| m.index_count > 0) { + up.set_pipeline(&self.inventory_preview_pipeline); + up.set_bind_group(0, &self.gui_camera_bind_group, &[]); + up.set_bind_group(1, &self.entity_bind_group, &[]); + up.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); + up.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + up.draw_indexed(0..mesh.index_count, 0, 0..1); + draw_calls += 1; + } // Inventory player preview (vanilla `GuiInventory.drawEntityOnScreen`): // the local-player biped baked into clip space, scissored to the // panel's model box so it never overdraws the rest of the window. The @@ -10573,8 +10625,28 @@ impl Renderer { up.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); up.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); up.draw_indexed(0..mesh.index_count, 0, 0..1); - up.set_scissor_rect(0, 0, self.config.width, self.config.height); draw_calls += 1; + // The held item shares the panel scissor and the pass depth, so + // it occludes against the biped it is attached to. Block atlas, + // so it takes the GUI cube pipeline rather than the model one. + if let Some(item) = self + .inventory_preview_item_mesh + .as_ref() + .filter(|m| m.index_count > 0) + { + up.set_pipeline(&self.gui_cube_pipeline); + up.set_bind_group(0, &self.gui_camera_bind_group, &[]); + up.set_bind_group( + 1, + &self.texture_bind_groups[self.mipmap_levels as usize], + &[], + ); + up.set_vertex_buffer(0, item.vertex_buffer.slice(..)); + up.set_index_buffer(item.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + up.draw_indexed(0..item.index_count, 0, 0..1); + draw_calls += 1; + } + up.set_scissor_rect(0, 0, self.config.width, self.config.height); } // Enchantment glint over enchanted item icons (block cubes and flat // sprites), additive with the scrolling glint texture — drawn under @@ -10702,6 +10774,30 @@ impl Renderer { "gui-block-items", ); + // Skull item icons: the same GUI pose, but head geometry sampling the + // entity atlas, so they need their own mesh and draw. + let mut skull_v: Vec = Vec::new(); + let mut skull_i: Vec = Vec::new(); + for item in ui.skull_items() { + crate::gui_item::append_skull_icon( + &mut skull_v, + &mut skull_i, + item.kind, + item.skin_row, + item.dst, + surface, + ); + } + fill_dynamic_mesh( + &self.device, + &self.queue, + &mut self.gui_skull_mesh, + bytemuck::cast_slice(&skull_v), + bytemuck::cast_slice(&skull_i), + skull_i.len() as u32, + "gui-skull-items", + ); + // Enchanted item icons: a clip-space glint overlay per slot — the cube // faces for block icons, a flat sprite quad otherwise — drawn additively // with the scrolling glint texture over the icons (vanilla `renderEffect`). diff --git a/crates/fpsmaster_render/src/texture.rs b/crates/fpsmaster_render/src/texture.rs index 63c5350..5346407 100644 --- a/crates/fpsmaster_render/src/texture.rs +++ b/crates/fpsmaster_render/src/texture.rs @@ -1765,7 +1765,7 @@ pub const ENTITY_SLOT_PX: u32 = 128; /// Number of fixed slots stacked at the top of the entity atlas (one per /// [`EntitySlot`], including the trailing guaranteed-white slot). -pub const ENTITY_SLOT_COUNT: u32 = 50; +pub const ENTITY_SLOT_COUNT: u32 = 51; /// Extra 64x64 rows reserved below the fixed slots for per-player downloaded /// skins, allocated at runtime by the skin loader. @@ -1887,8 +1887,11 @@ pub enum EntitySlot { EnchantBook = 47, /// Arrow projectile (entity/arrow.png, 32x32). Arrow = 48, + /// Wither skeleton (skeleton/wither_skeleton.png, 64x32) — only its head + /// region is sampled, by the wither-skeleton skull block-entity. + SkeletonWither = 49, /// Guaranteed opaque-white slot sampled by solid-color geometry. - White = 49, + White = 50, } /// Pixel origin (top-left corner) of an entity atlas slot. @@ -1911,7 +1914,7 @@ pub const ENTITY_WHITE_UV: [f32; 2] = { /// The 1.8 entity textures loaded into each mob slot, plus the procedural /// fallback tint used when the asset is missing. The player slot is handled /// separately (normalize_skin / procedural_skin). -const MOB_SLOT_ASSETS: [(EntitySlot, &str, [u8; 3]); 38] = [ +const MOB_SLOT_ASSETS: [(EntitySlot, &str, [u8; 3]); 39] = [ (EntitySlot::ArmorStand, "armorstand/wood", [150, 120, 75]), (EntitySlot::ChestNormal, "chest/normal", [162, 114, 51]), (EntitySlot::ChestTrapped, "chest/trapped", [162, 80, 51]), @@ -1922,6 +1925,7 @@ const MOB_SLOT_ASSETS: [(EntitySlot, &str, [u8; 3]); 38] = [ (EntitySlot::EnchantBook, "enchanting_table_book", [120, 30, 30]), (EntitySlot::Zombie, "zombie/zombie", [88, 124, 80]), (EntitySlot::Skeleton, "skeleton/skeleton", [192, 192, 192]), + (EntitySlot::SkeletonWither, "skeleton/wither_skeleton", [50, 50, 50]), (EntitySlot::Creeper, "creeper/creeper", [86, 170, 70]), (EntitySlot::Pig, "pig/pig", [238, 158, 158]), (EntitySlot::Cow, "cow/cow", [108, 80, 58]), @@ -2601,6 +2605,7 @@ mod tests { EntitySlot::Sign, EntitySlot::EnchantBook, EntitySlot::Arrow, + EntitySlot::SkeletonWither, EntitySlot::White, ]; let mut seen = std::collections::HashSet::new(); diff --git a/crates/fpsmaster_render/src/ui.rs b/crates/fpsmaster_render/src/ui.rs index aee8d39..c5ef41a 100644 --- a/crates/fpsmaster_render/src/ui.rs +++ b/crates/fpsmaster_render/src/ui.rs @@ -273,6 +273,18 @@ pub struct GuiBlockItem { pub meta: u8, } +/// A skull item (397) rendered as its 3D block-entity head into a slot rect. +/// Vanilla ships no flat sprite for it — the head model IS the icon — so it +/// needs its own list: it samples the entity atlas rather than the block atlas +/// the cube pass is bound to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GuiSkullItem { + pub dst: UiRect, + pub kind: crate::SkullKind, + /// The owner's downloaded skin row for a player head, if one is resolved. + pub skin_row: Option, +} + /// An enchanted item icon to overlay with the scrolling glint: a 3D block-icon /// cube (`block` set) shimmers over its cube geometry, anything else as a flat /// quad over its slot rect. Built into a clip-space glint mesh by the renderer. @@ -293,6 +305,9 @@ pub struct UiFrame { overlay: Vec, /// 3D block icons, rendered by the GPU cube pass between the two layers. block_items: Vec, + /// 3D skull-head icons, drawn alongside the block icons but from the entity + /// atlas (see [`GuiSkullItem`]). + skull_items: Vec, /// Enchanted item icons to overlay with the scrolling glint (drawn over the /// icons in the UI pass, additively, like the held/world item glint). glint_items: Vec, @@ -311,6 +326,7 @@ impl UiFrame { self.commands.is_empty() && self.overlay.is_empty() && self.block_items.is_empty() + && self.skull_items.is_empty() && self.glint_items.is_empty() && self.crosshair.is_empty() } @@ -328,6 +344,10 @@ impl UiFrame { &self.block_items } + pub fn skull_items(&self) -> &[GuiSkullItem] { + &self.skull_items + } + pub fn glint_items(&self) -> &[GuiGlintItem] { &self.glint_items } @@ -369,6 +389,12 @@ impl UiFrame { }); } + /// Queue a 3D skull-head icon (item 397). Drawn in the same pass as the + /// block cubes, from the entity atlas. + pub fn skull_item(&mut self, dst: UiRect, kind: crate::SkullKind, skin_row: Option) { + self.skull_items.push(GuiSkullItem { dst, kind, skin_row }); + } + /// Overlay-layer variants (drawn over the 3D block icons). pub fn overlay_rect(&mut self, rect: UiRect, color: UiColor) { self.overlay.push(UiCommand::Rect { rect, color }); diff --git a/docs/BLOCK_RENDER_PARITY.md b/docs/BLOCK_RENDER_PARITY.md index f33330a..ceaf2d6 100644 --- a/docs/BLOCK_RENDER_PARITY.md +++ b/docs/BLOCK_RENDER_PARITY.md @@ -40,7 +40,17 @@ ## 近似项分类(精确化分属其他任务) - **附着/朝向类**(拉杆、红石火把、可可、按 meta 朝向)→ 见 **T25** 火把/附着方块。 -- **block-entity 特殊渲染**(附魔台、酿造台、末地/地狱传送门、告示牌、旗帜、头颅、箱子)→ 见 **T3 / T16 / T17 / T28**。 +- **block-entity 特殊渲染**(附魔台、酿造台、末地/地狱传送门、告示牌、旗帜、箱子)→ 见 **T3 / T16 / T17 / T28**。 + - 头颅(144 / 物品 397)已完整实现,5 种头(骷髅/凋灵骷髅/僵尸/玩家/苦力怕)共用 + `skull_parts` 的头+帽两层,玩家头按 `Owner`/`SkullOwner` 档案下载真皮肤: + - **世界方块** `ModelMesh::push_skull`:落地/贴墙 5 种朝向 + `Rot` 16 档转向, + 类型与转向来自 S35 UpdateBlockEntity(action 4)。 + - **物品栏图标** `gui_item::append_skull_icon`:原版无 `textures/items` 贴图, + 走 `TileEntityItemStackRenderer` 的 3D 头模型,采样实体图集的独立 GUI pass。 + - **戴在头上** `push_worn_skull`(`LayerCustomHead`):绕颈部枢轴放大 1.1875 倍。 + - **手持/掉落物** `push_skull_item` + `ItemRenderer::build_held_skull` / + `GameState::build_dropped_skull_models`:走模型 pass 而非方块图集物品 pass。 + - **本地放置预测**:`ItemSkull.onItemUse` 的朝向/转向规则,连 block-entity 一起预测。 - **平铺红石元件连接纹理**(红石线 0/15 连接、中继器/比较器朝向)→ 后续可加专用 flat-decal 形状。 - **流体不完整方块/斜面**(水/岩浆 8 级高度)→ 见 **T24**。