diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index f4020794..dc7491b2 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -436,9 +436,13 @@ pub fn update_game( if game.options_from_game { let menu_input = core.build_menu_input(); let r = &gfx.renderer; - let result = core - .menu - .build(sw, sh, &menu_input, |t, s| r.menu_text_width(t, s)); + let result = core.menu.build( + sw, + sh, + &menu_input, + |t, s| r.menu_text_width(t, s), + &|name| r.block_textures(name), + ); elements.extend(result.elements); core.input.clear_just_pressed_actions(); } else if game.dead { @@ -755,6 +759,12 @@ pub fn update_game( game.paused = false; core.apply_cursor_grab(&gfx.window, Some(game)); } + PauseAction::TextureEditor => { + core.menu.open_texture_editor(); + game.options_from_game = true; + game.paused = false; + core.apply_cursor_grab(&gfx.window, Some(game)); + } PauseAction::Disconnect => { return GameUpdateResult::ManualDisconnect; } @@ -776,7 +786,10 @@ pub fn update_game( if core.menu.render_distance != game.last_render_distance { game.sync_render_distance(connection, core.menu.render_distance); } - if !core.menu.is_options_screen() && !core.menu.is_editor_screen() { + if !core.menu.is_options_screen() + && !core.menu.is_editor_screen() + && !core.menu.is_texture_editor_screen() + { game.options_from_game = false; game.paused = true; core.apply_cursor_grab(&gfx.window, Some(game)); diff --git a/pomme-client/src/app/phases/in_menu.rs b/pomme-client/src/app/phases/in_menu.rs index f7b9dcb3..176b5df8 100644 --- a/pomme-client/src/app/phases/in_menu.rs +++ b/pomme-client/src/app/phases/in_menu.rs @@ -25,9 +25,13 @@ pub fn update_menu( let menu_input = core.build_menu_input(); - let result = core.menu.build(sw, sh, &menu_input, |t, s| { - gfx.renderer.menu_text_width(t, s) - }); + let result = core.menu.build( + sw, + sh, + &menu_input, + |t, s| gfx.renderer.menu_text_width(t, s), + &|name| gfx.renderer.block_textures(name), + ); core.audio.set_volumes(core.menu.category_volumes()); let action = result.action; diff --git a/pomme-client/src/renderer/mod.rs b/pomme-client/src/renderer/mod.rs index 1396a686..b5f7d3d7 100644 --- a/pomme-client/src/renderer/mod.rs +++ b/pomme-client/src/renderer/mod.rs @@ -98,6 +98,10 @@ pub struct RenderTimings { pub present_ms: f32, } +/// One of a block's textures for the texture editor: (face label, texture key, +/// atlas UV region `[u0, v0, u1, v1]`). +pub type BlockTextureRef = (String, String, [f32; 4]); + pub struct Renderer { ctx: VulkanContext, swapchain: Swapchain, @@ -201,6 +205,7 @@ impl Renderer { &texture_names, None, )?; + menu_pipeline.set_block_atlas(&ctx.device, atlas.view, atlas.sampler); splash(&mut menu_pipeline, 0.5, "Creating pipelines..."); @@ -530,7 +535,7 @@ impl Renderer { cmd.set_scissor(0, &[scissor]); let empty_uvs: HashMap = HashMap::new(); - menu.draw(cmd, sw, sh, &elements, &empty_uvs); + menu.draw(0, cmd, sw, sh, &elements, &empty_uvs); cmd.end_render_pass(); cmd.end()?; @@ -888,6 +893,19 @@ impl Renderer { ) } + /// Labeled textures making up a block's appearance, with each one's UV + /// region in the block atlas, for the texture editor. Empty for non-blocks. + pub fn block_textures(&self, item_name: &str) -> Vec { + self.registry + .block_face_textures(item_name) + .into_iter() + .map(|(label, key)| { + let r = self.atlas.uv_map.get_region(&key); + (label, key, [r.u_min, r.v_min, r.u_max, r.v_max]) + }) + .collect() + } + pub fn reload_assets( &mut self, game_dir: &Path, @@ -927,6 +945,8 @@ impl Renderer { .rebind_atlas(&self.ctx.device, &self.atlas); self.held_item_pipeline .rebind_atlas(&self.ctx.device, &self.atlas); + self.menu_pipeline + .set_block_atlas(&self.ctx.device, self.atlas.view, self.atlas.sampler); tracing::info!("Assets reloaded"); } @@ -1167,30 +1187,25 @@ impl Renderer { slot: pipelines::gui_item_atlas::Slot, name: String, is_block: bool, - needs_clear: bool, } + // Re-bake all visible icons every frame; the bake pass clears the whole + // atlas, so nothing carries over between frames. let mut bake_list: Vec = Vec::new(); for name in &unique_names { let discard = pipelines::gui_item_atlas::is_animated_item(name); - if let Some((slot, state)) = self.gui_item_atlas.get_or_allocate(name, discard) { + if let Some((slot, _state)) = self.gui_item_atlas.get_or_allocate(name, discard) { item_atlas_uvs.insert(name.clone(), self.gui_item_atlas.slot_uv(&slot)); - if !matches!(state, pipelines::gui_item_atlas::SlotState::Ready) { - bake_list.push(BakeJob { - slot, - name: name.clone(), - is_block: self.registry.get_item_model(name).is_some(), - needs_clear: matches!(state, pipelines::gui_item_atlas::SlotState::Stale), - }); - } + bake_list.push(BakeJob { + slot, + name: name.clone(), + is_block: self.registry.get_item_model(name).is_some(), + }); } } if !bake_list.is_empty() { self.gui_item_atlas.begin_bake_pass(cmd); self.gui_item_pipeline.bind_for_bake_pass(cmd); for job in &bake_list { - if job.needs_clear { - self.gui_item_atlas.clear_slot_color(cmd, &job.slot); - } cmd.set_scissor(0, &[self.gui_item_atlas.scissor_rect(&job.slot)]); let (sx, sy) = self.gui_item_atlas.slot_origin_pixels(&job.slot); self.gui_item_pipeline.bake_to_slot( @@ -1342,7 +1357,7 @@ impl Renderer { } self.menu_pipeline - .draw(cmd, sw, sh, overlay, &item_atlas_uvs); + .draw(frame, cmd, sw, sh, overlay, &item_atlas_uvs); if let Some(p) = player_preview { let x0 = p.rect[0].max(0.0) as i32; @@ -1430,7 +1445,7 @@ impl Renderer { } self.menu_pipeline - .draw(cmd, sw, sh, elements, &item_atlas_uvs); + .draw(frame, cmd, sw, sh, elements, &item_atlas_uvs); } } diff --git a/pomme-client/src/renderer/pipelines/gui_item_atlas.rs b/pomme-client/src/renderer/pipelines/gui_item_atlas.rs index 786ea4b1..282a1723 100644 --- a/pomme-client/src/renderer/pipelines/gui_item_atlas.rs +++ b/pomme-client/src/renderer/pipelines/gui_item_atlas.rs @@ -336,24 +336,6 @@ impl GuiItemAtlas { cmd.end_render_pass(); } - pub fn clear_slot_color(&self, cmd: vk::CommandBuffer, slot: &Slot) { - let clear_attachment = vk::ClearAttachment { - aspect_mask: vk::ImageAspectFlags::Color, - color_attachment: 0, - clear_value: vk::ClearValue { - color: vk::ClearColorValue { - float32: [0.0, 0.0, 0.0, 0.0], - }, - }, - }; - let clear_rect = vk::ClearRect { - rect: self.scissor_rect(slot), - base_array_layer: 0, - layer_count: 1, - }; - cmd.clear_attachments(&[clear_attachment], &[clear_rect]); - } - pub fn color_view(&self) -> vk::ImageView { self.color_view } @@ -563,7 +545,10 @@ fn create_render_pass(device: &vk::Device) -> vk::RenderPass { vk::AttachmentDescription { format: COLOR_FORMAT, samples: vk::SampleCountFlags::Type1, - load_op: vk::AttachmentLoadOp::Load, + // Clear (not Load): icons are re-baked every frame, so prior atlas + // contents are never relied on — sidesteps MoltenVK dropping + // LoadOp::Load / clear_attachments content. + load_op: vk::AttachmentLoadOp::Clear, store_op: vk::AttachmentStoreOp::Store, initial_layout: vk::ImageLayout::ShaderReadOnlyOptimal, final_layout: vk::ImageLayout::ShaderReadOnlyOptimal, diff --git a/pomme-client/src/renderer/pipelines/menu_overlay.rs b/pomme-client/src/renderer/pipelines/menu_overlay.rs index c09a1aac..dd647af0 100644 --- a/pomme-client/src/renderer/pipelines/menu_overlay.rs +++ b/pomme-client/src/renderer/pipelines/menu_overlay.rs @@ -7,7 +7,7 @@ use pomme_gpu_allocator::vulkan::{Allocation, Allocator}; use pyronyx::vk; use crate::assets::{AssetIndex, resolve_asset_path}; -use crate::renderer::{shader, util}; +use crate::renderer::{MAX_FRAMES_IN_FLIGHT, shader, util}; use crate::ui::font::GlyphMap; use crate::ui::text::TextSpan; @@ -24,6 +24,8 @@ pub const ICON_GLOBE: char = '\u{f0ac}'; pub const ICON_COMMENT: char = '\u{f075}'; pub const ICON_CODE: char = '\u{f121}'; pub const ICON_CHECK: char = '\u{f00c}'; +pub const ICON_TRASH: char = '\u{f1f8}'; +pub const ICON_CUBES: char = '\u{f1b3}'; #[repr(C)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] @@ -38,6 +40,8 @@ struct Vertex { const MAX_VERTICES: usize = 16384; const VERTEX_SIZE: usize = size_of::(); +/// Per-in-flight-frame slice of the vertex buffer. +const VERTEX_REGION_BYTES: usize = MAX_VERTICES * VERTEX_SIZE; struct DrawOp { start: u32, @@ -81,6 +85,8 @@ fn build_font_atlas() -> FontAtlas { ICON_COMMENT, ICON_CODE, ICON_CHECK, + ICON_TRASH, + ICON_CUBES, ] .iter() .map(|&ch| (ch, &icon_font)) @@ -236,6 +242,13 @@ impl MenuOverlayPipeline { stage_flags: vk::ShaderStageFlags::Fragment, ..Default::default() }, + vk::DescriptorSetLayoutBinding { + binding: 6, + descriptor_type: vk::DescriptorType::CombinedImageSampler, + descriptor_count: 1, + stage_flags: vk::ShaderStageFlags::Fragment, + ..Default::default() + }, ]; let tex_layout_info = vk::DescriptorSetLayoutCreateInfo { binding_count: tex_bindings.len() as u32, @@ -265,7 +278,7 @@ impl MenuOverlayPipeline { }, vk::DescriptorPoolSize { ty: vk::DescriptorType::CombinedImageSampler, - descriptor_count: 6, + descriptor_count: 7, }, ]; let pool_info = vk::DescriptorPoolCreateInfo { @@ -517,13 +530,23 @@ impl MenuOverlayPipeline { image_info: &favicon_img_info, ..Default::default() }, + vk::WriteDescriptorSet { + dst_set: tex_set, + dst_binding: 6, + descriptor_count: 1, + descriptor_type: vk::DescriptorType::CombinedImageSampler, + image_info: &item_img_info, + ..Default::default() + }, ]; device.update_descriptor_sets(&writes, &[]); + // One region per in-flight frame so a frame being recorded never + // overwrites vertices another in-flight frame is still reading. let (vertex_buffer, vertex_allocation) = util::create_host_buffer( device, allocator, - (MAX_VERTICES * VERTEX_SIZE) as u64, + (VERTEX_REGION_BYTES * MAX_FRAMES_IN_FLIGHT) as u64, vk::BufferUsageFlags::VertexBuffer, "menu_vertices", ); @@ -573,6 +596,7 @@ impl MenuOverlayPipeline { pub fn draw( &mut self, + frame: usize, cmd: vk::CommandBuffer, screen_w: f32, screen_h: f32, @@ -871,6 +895,30 @@ impl MenuOverlayPipeline { ); } } + MenuElement::AtlasTexture { + x, + y, + w, + h, + region, + tint, + } => { + push_quad( + &mut vertices, + *x, + *y, + *w, + *h, + region[0], + region[1], + region[2], + region[3], + *tint, + 7.0, + [*w, *h], + 0.0, + ); + } _ => {} } } @@ -1051,6 +1099,7 @@ impl MenuOverlayPipeline { return; } + let region_offset = frame * VERTEX_REGION_BYTES; if !vertices.is_empty() { let count = vertices.len().min(MAX_VERTICES); let byte_data = bytemuck::cast_slice(&vertices[..count]); @@ -1058,7 +1107,7 @@ impl MenuOverlayPipeline { .as_mut() .unwrap() .mapped_slice_mut() - .unwrap()[..byte_data.len()] + .unwrap()[region_offset..region_offset + byte_data.len()] .copy_from_slice(byte_data); } @@ -1079,7 +1128,7 @@ impl MenuOverlayPipeline { &[self.globals_set, self.tex_set], &[], ); - cmd.bind_vertex_buffers(0, &[self.vertex_buffer], &[0]); + cmd.bind_vertex_buffers(0, &[self.vertex_buffer], &[region_offset as u64]); } for op in &draw_ops { let rect = if let Some(s) = op.scissor { @@ -1119,6 +1168,23 @@ impl MenuOverlayPipeline { device.update_descriptor_sets(&[write], &[]); } + pub fn set_block_atlas(&self, device: &vk::Device, view: vk::ImageView, sampler: vk::Sampler) { + let info = vk::DescriptorImageInfo { + sampler, + image_view: view, + image_layout: vk::ImageLayout::ShaderReadOnlyOptimal, + }; + let write = vk::WriteDescriptorSet { + dst_set: self.tex_set, + dst_binding: 6, + descriptor_count: 1, + descriptor_type: vk::DescriptorType::CombinedImageSampler, + image_info: &info, + ..Default::default() + }; + device.update_descriptor_sets(&[write], &[]); + } + pub fn set_blur_texture(&self, device: &vk::Device, view: vk::ImageView, sampler: vk::Sampler) { let info = vk::DescriptorImageInfo { sampler, @@ -1452,6 +1518,15 @@ pub enum MenuElement { size: f32, address: String, }, + /// A texture drawn straight from the block atlas, by its UV region. + AtlasTexture { + x: f32, + y: f32, + w: f32, + h: f32, + region: [f32; 4], + tint: [f32; 4], + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] diff --git a/pomme-client/src/renderer/shaders/menu_overlay.frag b/pomme-client/src/renderer/shaders/menu_overlay.frag index 99206932..0bb09d54 100644 --- a/pomme-client/src/renderer/shaders/menu_overlay.frag +++ b/pomme-client/src/renderer/shaders/menu_overlay.frag @@ -11,6 +11,7 @@ layout(set = 1, binding = 2) uniform sampler2D item_tex; layout(set = 1, binding = 3) uniform sampler2D mc_font_tex; layout(set = 1, binding = 4) uniform sampler2D blur_tex; layout(set = 1, binding = 5) uniform sampler2D favicon_tex; +layout(set = 1, binding = 6) uniform sampler2D block_tex; layout(location = 0) in vec2 v_uv; layout(location = 1) in vec4 v_color; @@ -26,6 +27,12 @@ float sdf_rounded_rect(vec2 p, vec2 half_size, float radius) { } void main() { + if (v_mode > 6.5) { + vec4 tex = texture(block_tex, v_uv); + out_color = vec4(tex.rgb * v_color.rgb * tex.a * v_color.a, tex.a * v_color.a); + return; + } + if (v_mode > 5.5) { vec4 tex = texture(favicon_tex, v_uv); out_color = vec4(tex.rgb * tex.a * v_color.a, tex.a * v_color.a); diff --git a/pomme-client/src/ui/creative_inventory.rs b/pomme-client/src/ui/creative_inventory.rs index e967c03f..2b732f24 100644 --- a/pomme-client/src/ui/creative_inventory.rs +++ b/pomme-client/src/ui/creative_inventory.rs @@ -1075,6 +1075,12 @@ fn stack_of(kind: ItemKind) -> ItemStack { }) } +/// All blocks/items that appear in the creative tabs, deduped — used by the +/// texture editor's block grid. +pub fn all_block_items() -> &'static [ItemKind] { + search_items_cached() +} + fn search_items_cached() -> &'static [ItemKind] { static CACHE: OnceLock> = OnceLock::new(); CACHE.get_or_init(|| { diff --git a/pomme-client/src/ui/menu/editor.rs b/pomme-client/src/ui/menu/editor.rs index 8e8e25e9..58dd08ae 100644 --- a/pomme-client/src/ui/menu/editor.rs +++ b/pomme-client/src/ui/menu/editor.rs @@ -6,15 +6,6 @@ use super::*; const STUB_NOTICE: &str = "Plugin runtime not yet implemented \u{2014} coming soon"; -struct Pal { - glass: [f32; 4], - glass_hover: [f32; 4], - accent: [f32; 4], - text: [f32; 4], - bright: [f32; 4], - dim: [f32; 4], -} - impl MainMenu { pub(super) fn scan_plugins(&mut self) { let prev = self @@ -102,6 +93,16 @@ impl MainMenu { } } + fn delete_editor_file(&mut self, path: &Path) { + match std::fs::remove_file(path) { + Ok(()) => { + self.scan_plugins(); + self.editor_status = format!("Deleted {}", file_name(path)); + } + Err(e) => self.editor_status = format!("Delete failed: {e}"), + } + } + #[allow(clippy::too_many_lines)] pub(super) fn build_editor( &mut self, @@ -111,18 +112,15 @@ impl MainMenu { text_width_fn: &dyn Fn(&str, f32) -> f32, ) -> MainMenuResult { if input.escape { - self.set_screen(Screen::Main); - return helpers::empty_result(1.0); + if self.editor_pending_delete.is_some() { + self.editor_pending_delete = None; + } else { + self.set_screen(Screen::Main); + return helpers::empty_result(1.0); + } } - let pal = Pal { - glass: [0.07, 0.08, 0.16, 0.55], - glass_hover: [0.12, 0.14, 0.25, 0.75], - accent: [0.29, 0.87, 0.5, 1.0], - text: [0.89, 0.90, 0.96, 0.85], - bright: [0.94, 0.95, 0.98, 1.0], - dim: [0.53, 0.56, 0.69, 0.7], - }; + let pal = Pal::dark(); let cursor = input.cursor; let clicked = input.clicked; @@ -385,6 +383,7 @@ impl MainMenu { let row_h = 17.0 * s; let list_top = new_rect[1] + new_rect[3] + 6.0 * s; let mut clicked_file: Option = None; + let mut delete_request: Option = None; if self.editor_files.is_empty() { elements.push(MenuElement::Text { x: sidebar_x + pad, @@ -440,11 +439,34 @@ impl MainMenu { }, centered: false, }); - if clicked && hovered { + + let trash_rect = [rect[0] + rect[2] - row_h, ry, row_h, row_h]; + let trash_hover = common::hit_test(cursor, trash_rect); + any_hovered |= trash_hover; + if hovered || trash_hover { + elements.push(MenuElement::Icon { + x: trash_rect[0] + row_h / 2.0, + y: ry + row_h / 2.0, + icon: ICON_TRASH, + scale: 8.0 * s, + color: if trash_hover { + [0.95, 0.45, 0.45, 1.0] + } else { + pal.dim + }, + }); + } + + if clicked && trash_hover { + delete_request = Some(path.clone()); + } else if clicked && hovered { clicked_file = Some(i); } } - if let Some(i) = clicked_file { + if let Some(path) = delete_request { + any_clicked = true; + self.editor_pending_delete = Some(path); + } else if let Some(i) = clicked_file { any_clicked = true; self.load_editor_file(i); } @@ -555,19 +577,66 @@ impl MainMenu { 6.0 * s, [0.05, 0.055, 0.11, 0.92], ); - let status = if self.editor_status.is_empty() { - "Ready" + if let Some(path) = self.editor_pending_delete.clone() { + elements.push(MenuElement::Text { + x: x0 + pad, + y: console_y + (console_h - ui_fs) / 2.0, + text: format!("Delete {}?", file_name(&path)), + scale: ui_fs, + color: [0.95, 0.6, 0.6, 1.0], + centered: false, + }); + let cb_w = 56.0 * s; + let cb_gap = 5.0 * s; + let cb_h = console_h - 6.0 * s; + let cb_y = console_y + 3.0 * s; + let del_rect = [x1 - pad - cb_w, cb_y, cb_w, cb_h]; + let cancel_rect = [del_rect[0] - cb_gap - cb_w, cb_y, cb_w, cb_h]; + let del_hover = button( + &mut elements, + &mut any_hovered, + cursor, + del_rect, + ui_fs, + "Delete", + 5.0 * s, + &pal, + true, + ); + let cancel_hover = button( + &mut elements, + &mut any_hovered, + cursor, + cancel_rect, + ui_fs, + "Cancel", + 5.0 * s, + &pal, + false, + ); + if clicked && del_hover { + any_clicked = true; + self.delete_editor_file(&path); + self.editor_pending_delete = None; + } else if clicked && cancel_hover { + any_clicked = true; + self.editor_pending_delete = None; + } } else { - &self.editor_status - }; - elements.push(MenuElement::Text { - x: x0 + pad, - y: console_y + (console_h - ui_fs) / 2.0, - text: format!("\u{203a} {status}"), - scale: ui_fs, - color: pal.dim, - centered: false, - }); + let status = if self.editor_status.is_empty() { + "Ready" + } else { + &self.editor_status + }; + elements.push(MenuElement::Text { + x: x0 + pad, + y: console_y + (console_h - ui_fs) / 2.0, + text: format!("\u{203a} {status}"), + scale: ui_fs, + color: pal.dim, + centered: false, + }); + } MainMenuResult { elements, @@ -579,54 +648,6 @@ impl MainMenu { } } -fn hover_col(pal: &Pal, hovered: bool) -> [f32; 4] { - if hovered { pal.glass_hover } else { pal.glass } -} - -fn push_panel(elements: &mut Vec, r: [f32; 4], radius: f32, color: [f32; 4]) { - elements.push(MenuElement::Rect { - x: r[0], - y: r[1], - w: r[2], - h: r[3], - corner_radius: radius, - color, - }); -} - -#[allow(clippy::too_many_arguments)] -fn button( - elements: &mut Vec, - any_hovered: &mut bool, - cursor: (f32, f32), - r: [f32; 4], - fs: f32, - label: &str, - radius: f32, - pal: &Pal, - accent_text: bool, -) -> bool { - let hovered = common::hit_test(cursor, r); - *any_hovered |= hovered; - push_panel(elements, r, radius, hover_col(pal, hovered)); - let color = if accent_text { - pal.accent - } else if hovered { - pal.bright - } else { - pal.text - }; - elements.push(MenuElement::Text { - x: r[0] + r[2] / 2.0, - y: r[1] + (r[3] - fs) / 2.0, - text: label.into(), - scale: fs, - color, - centered: true, - }); - hovered -} - fn file_name(p: &Path) -> String { p.file_name() .map(|n| n.to_string_lossy().into_owned()) diff --git a/pomme-client/src/ui/menu/helpers.rs b/pomme-client/src/ui/menu/helpers.rs index baeb1574..8c855ece 100644 --- a/pomme-client/src/ui/menu/helpers.rs +++ b/pomme-client/src/ui/menu/helpers.rs @@ -1,6 +1,82 @@ use super::*; use crate::ui::text::TextSpan; +/// Dark "studio" palette shared by the code editor and texture editor. +pub(super) struct Pal { + pub(super) glass: [f32; 4], + pub(super) glass_hover: [f32; 4], + pub(super) accent: [f32; 4], + pub(super) text: [f32; 4], + pub(super) bright: [f32; 4], + pub(super) dim: [f32; 4], +} + +impl Pal { + pub(super) fn dark() -> Self { + Self { + glass: [0.07, 0.08, 0.16, 0.55], + glass_hover: [0.12, 0.14, 0.25, 0.75], + accent: [0.29, 0.87, 0.5, 1.0], + text: [0.89, 0.90, 0.96, 0.85], + bright: [0.94, 0.95, 0.98, 1.0], + dim: [0.53, 0.56, 0.69, 0.7], + } + } +} + +pub(super) fn hover_col(pal: &Pal, hovered: bool) -> [f32; 4] { + if hovered { pal.glass_hover } else { pal.glass } +} + +pub(super) fn push_panel( + elements: &mut Vec, + r: [f32; 4], + radius: f32, + color: [f32; 4], +) { + elements.push(MenuElement::Rect { + x: r[0], + y: r[1], + w: r[2], + h: r[3], + corner_radius: radius, + color, + }); +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn button( + elements: &mut Vec, + any_hovered: &mut bool, + cursor: (f32, f32), + r: [f32; 4], + fs: f32, + label: &str, + radius: f32, + pal: &Pal, + accent_text: bool, +) -> bool { + let hovered = common::hit_test(cursor, r); + *any_hovered |= hovered; + push_panel(elements, r, radius, hover_col(pal, hovered)); + let color = if accent_text { + pal.accent + } else if hovered { + pal.bright + } else { + pal.text + }; + elements.push(MenuElement::Text { + x: r[0] + r[2] / 2.0, + y: r[1] + (r[3] - fs) / 2.0, + text: label.into(), + scale: fs, + color, + centered: true, + }); + hovered +} + pub(super) fn empty_result(blur: f32) -> MainMenuResult { MainMenuResult { elements: Vec::new(), diff --git a/pomme-client/src/ui/menu/main_screen.rs b/pomme-client/src/ui/menu/main_screen.rs index f53c3da4..89daf6f8 100644 --- a/pomme-client/src/ui/menu/main_screen.rs +++ b/pomme-client/src/ui/menu/main_screen.rs @@ -242,10 +242,11 @@ impl MainMenu { let icon_scale = 13.0 * s; let drop_style = DropdownStyle::new(gs); - let bottom_icons: [(f32, char); 5] = [ + let bottom_icons: [(f32, char); 6] = [ (btn_x, ICON_USER), (btn_x + icon_size + icon_gap, ICON_LINK), (btn_x + (icon_size + icon_gap) * 2.0, ICON_CODE), + (btn_x + (icon_size + icon_gap) * 3.0, ICON_CUBES), (btn_x + content_w - icon_size, ICON_GEAR), ( btn_x + content_w - icon_size * 2.0 - icon_gap, @@ -293,6 +294,9 @@ impl MainMenu { ICON_CODE => { self.open_editor(); } + ICON_CUBES => { + self.open_texture_editor(); + } ICON_PAINTBRUSH => { self.theme_open = !self.theme_open; if self.theme_open { diff --git a/pomme-client/src/ui/menu/mod.rs b/pomme-client/src/ui/menu/mod.rs index a11b2e24..86ba4e9c 100644 --- a/pomme-client/src/ui/menu/mod.rs +++ b/pomme-client/src/ui/menu/mod.rs @@ -3,6 +3,7 @@ mod helpers; mod main_screen; mod options; mod servers; +mod texture_editor; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -11,9 +12,11 @@ use std::time::Instant; use serde::{Deserialize, Serialize}; use crate::app::core::DisplayMode; +use azalea_registry::builtin::ItemKind; + use crate::renderer::pipelines::menu_overlay::{ - ICON_CHECK, ICON_CODE, ICON_COMMENT, ICON_GEAR, ICON_GLOBE, ICON_LINK, ICON_PAINTBRUSH, - ICON_USER, MenuElement, SpriteId, + ICON_CHECK, ICON_CODE, ICON_COMMENT, ICON_CUBES, ICON_GEAR, ICON_GLOBE, ICON_LINK, + ICON_PAINTBRUSH, ICON_TRASH, ICON_USER, MenuElement, SpriteId, }; #[derive(Serialize, Deserialize)] @@ -233,6 +236,7 @@ enum Screen { OptionsTelemetry, OptionsCredits, Editor, + TextureEditor, } impl Screen { @@ -240,6 +244,7 @@ impl Screen { match self { Self::Main => Self::Main, Self::Editor => Self::Editor, + Self::TextureEditor => Self::TextureEditor, Self::Options => Self::Options, Self::OptionsOnline => Self::OptionsOnline, Self::OptionsVideo => Self::OptionsVideo, @@ -332,6 +337,11 @@ pub struct MainMenu { editor_scroll: f32, editor_dirty: bool, editor_status: String, + editor_pending_delete: Option, + tex_search: String, + tex_scroll: f32, + tex_selected: Option, + tex_status: String, } impl MainMenu { @@ -409,6 +419,11 @@ impl MainMenu { editor_scroll: 0.0, editor_dirty: false, editor_status: String::new(), + editor_pending_delete: None, + tex_search: String::new(), + tex_scroll: 0.0, + tex_selected: None, + tex_status: String::new(), } } @@ -478,6 +493,7 @@ impl MainMenu { pub fn open_editor(&mut self) { self.set_screen(Screen::Editor); + self.editor_pending_delete = None; self.scan_plugins(); } @@ -485,6 +501,17 @@ impl MainMenu { matches!(self.screen, Screen::Editor) } + pub fn open_texture_editor(&mut self) { + self.set_screen(Screen::TextureEditor); + self.tex_selected = None; + self.tex_scroll = 0.0; + self.tex_status.clear(); + } + + pub fn is_texture_editor_screen(&self) -> bool { + matches!(self.screen, Screen::TextureEditor) + } + pub fn is_options_screen(&self) -> bool { matches!( self.screen, @@ -577,6 +604,7 @@ impl MainMenu { screen_h: f32, input: &MenuInput, text_width_fn: impl Fn(&str, f32) -> f32, + block_textures_fn: &dyn Fn(&str) -> Vec, ) -> MainMenuResult { match self.screen { Screen::Main => self.build_main(screen_w, screen_h, input, text_width_fn), @@ -632,6 +660,9 @@ impl MainMenu { Screen::Options, ), Screen::Editor => self.build_editor(screen_w, screen_h, input, &text_width_fn), + Screen::TextureEditor => { + self.build_texture_editor(screen_w, screen_h, input, &text_width_fn, block_textures_fn) + } } } diff --git a/pomme-client/src/ui/menu/texture_editor.rs b/pomme-client/src/ui/menu/texture_editor.rs new file mode 100644 index 00000000..a97ec584 --- /dev/null +++ b/pomme-client/src/ui/menu/texture_editor.rs @@ -0,0 +1,341 @@ +//! In-game texture editor (UI shell): browse blocks as MC renders them, pick +//! one, and see its per-face textures with a (stubbed) import affordance. +//! Actual texture import + live atlas patch is a deferred backend phase. + +use super::*; +use crate::player::inventory::item_resource_name; +use crate::ui::creative_inventory::all_block_items; + +const IMPORT_STUB: &str = "Texture import not yet wired"; +const PANEL_BG: [f32; 4] = [0.05, 0.055, 0.11, 1.0]; + +impl MainMenu { + #[allow(clippy::too_many_lines)] + pub(super) fn build_texture_editor( + &mut self, + sw: f32, + sh: f32, + input: &MenuInput, + text_width_fn: &dyn Fn(&str, f32) -> f32, + block_textures_fn: &dyn Fn(&str) -> Vec, + ) -> MainMenuResult { + if input.escape { + self.set_screen(Screen::Main); + return helpers::empty_result(1.0); + } + + let pal = Pal::dark(); + let cursor = input.cursor; + let clicked = input.clicked; + let s = (sh / 400.0).max(1.0); + + let margin = 16.0 * s; + let pad = 8.0 * s; + let header_h = 28.0 * s; + let console_h = 22.0 * s; + let ui_fs = 9.0 * s; + let title_fs = 13.0 * s; + + let x0 = margin; + let x1 = sw - margin; + let y0 = margin; + let y1 = sh - margin; + + let header_y = y0; + let body_y = header_y + header_h + pad; + let body_bottom = y1 - console_h - pad; + let detail_w = (220.0 * s).min((x1 - x0) * 0.4); + let detail_x = x1 - detail_w; + let grid_x = x0; + let grid_right = detail_x - pad; + + let search_h = 20.0 * s; + let search_w = grid_right - grid_x; + let grid_top = body_y + search_h + pad; + let cell = 30.0 * s; + let gap = 6.0 * s; + let stride = cell + gap; + let cols = (((grid_right - grid_x) + gap) / stride).floor().max(1.0) as usize; + let visible_rows = (((body_bottom - grid_top) + gap) / stride).floor().max(1.0) as usize; + + if !input.typed_chars.is_empty() { + self.tex_search.extend(input.typed_chars.iter()); + self.tex_scroll = 0.0; + } + if input.backspace { + self.tex_search.pop(); + self.tex_scroll = 0.0; + } + self.cursor_blink = if input.typed_chars.is_empty() && !input.backspace { + self.cursor_blink + } else { + Instant::now() + }; + + let needle = self.tex_search.to_lowercase(); + let items: Vec = all_block_items() + .iter() + .copied() + .filter(|k| needle.is_empty() || item_resource_name(*k).to_lowercase().contains(&needle)) + .collect(); + + let row_count = items.len().div_ceil(cols); + let max_scroll = row_count.saturating_sub(visible_rows) as f32; + if input.scroll_delta != 0.0 { + self.tex_scroll = (self.tex_scroll - input.scroll_delta).clamp(0.0, max_scroll); + } + self.tex_scroll = self.tex_scroll.clamp(0.0, max_scroll); + let first_row = self.tex_scroll.floor() as usize; + + let mut elements = Vec::new(); + let mut any_hovered = false; + let mut any_clicked = false; + + elements.push(MenuElement::FrostedRect { + x: 0.0, + y: 0.0, + w: sw, + h: sh, + corner_radius: 0.0, + tint: [0.035, 0.04, 0.08, 0.92], + }); + + let back_w = 64.0 * s; + let back_rect = [x0, header_y, back_w, header_h]; + let back_hover = common::hit_test(cursor, back_rect); + any_hovered |= back_hover; + push_panel(&mut elements, back_rect, 6.0 * s, hover_col(&pal, back_hover)); + elements.push(MenuElement::Text { + x: x0 + back_w / 2.0, + y: header_y + (header_h - ui_fs) / 2.0, + text: "\u{2190} Back".into(), + scale: ui_fs, + color: if back_hover { pal.bright } else { pal.text }, + centered: true, + }); + if clicked && back_hover { + self.set_screen(Screen::Main); + return helpers::empty_result(1.0); + } + elements.push(MenuElement::Text { + x: x0 + back_w + pad * 1.5, + y: header_y + (header_h - title_fs) / 2.0, + text: "Texture Editor".into(), + scale: title_fs, + color: pal.bright, + centered: false, + }); + + push_panel( + &mut elements, + [grid_x, body_y, grid_right - grid_x, body_bottom - body_y], + 7.0 * s, + PANEL_BG, + ); + + helpers::push_text_field( + &mut elements, + grid_x, + body_y, + search_w, + search_h, + ui_fs, + s, + &self.tex_search, + true, + false, + &self.cursor_blink, + text_width_fn, + ); + if self.tex_search.is_empty() { + elements.push(MenuElement::Text { + x: grid_x + 6.0 * s, + y: body_y + (search_h - ui_fs) / 2.0, + text: "Search blocks\u{2026}".into(), + scale: ui_fs, + color: pal.dim, + centered: false, + }); + } + + elements.push(MenuElement::ScissorPush { + x: grid_x, + y: grid_top, + w: grid_right - grid_x, + h: body_bottom - grid_top, + }); + let mut idx = first_row * cols; + 'rows: for row in 0..visible_rows { + for col in 0..cols { + if idx >= items.len() { + break 'rows; + } + let kind = items[idx]; + idx += 1; + let cx = grid_x + col as f32 * stride; + let cy = grid_top + row as f32 * stride; + let rect = [cx, cy, cell, cell]; + let hovered = common::hit_test(cursor, rect); + any_hovered |= hovered; + let selected = self.tex_selected == Some(kind); + let cell_col = if selected { + pal.glass_hover + } else if hovered { + pal.glass + } else { + [1.0, 1.0, 1.0, 0.04] + }; + push_panel(&mut elements, rect, 4.0 * s, cell_col); + elements.push(MenuElement::ItemIcon { + x: cx, + y: cy, + w: cell, + h: cell, + item_name: item_resource_name(kind), + tint: WHITE, + }); + if clicked && hovered { + self.tex_selected = Some(kind); + any_clicked = true; + } + } + } + elements.push(MenuElement::ScissorPop); + + push_panel( + &mut elements, + [detail_x, body_y, detail_w, body_bottom - body_y], + 7.0 * s, + PANEL_BG, + ); + if let Some(kind) = self.tex_selected { + let name = item_resource_name(kind); + let icon = 48.0 * s; + elements.push(MenuElement::ItemIcon { + x: detail_x + (detail_w - icon) / 2.0, + y: body_y + pad, + w: icon, + h: icon, + item_name: name.clone(), + tint: WHITE, + }); + elements.push(MenuElement::Text { + x: detail_x + detail_w / 2.0, + y: body_y + pad + icon + 4.0 * s, + text: name.clone(), + scale: ui_fs, + color: pal.bright, + centered: true, + }); + + let faces = block_textures_fn(&name); + let mut ry = body_y + pad + icon + 4.0 * s + ui_fs + pad; + if faces.is_empty() { + elements.push(MenuElement::Text { + x: detail_x + detail_w / 2.0, + y: ry, + text: "No editable block textures".into(), + scale: ui_fs, + color: pal.dim, + centered: true, + }); + } + let row_h = 26.0 * s; + let btn_w = 50.0 * s; + let sw_sz = row_h - 6.0 * s; + for (label, key, region) in &faces { + if ry + row_h > body_bottom { + break; + } + let sw_x = detail_x + pad; + let sw_y = ry + 3.0 * s; + push_panel( + &mut elements, + [sw_x - 1.0 * s, sw_y - 1.0 * s, sw_sz + 2.0 * s, sw_sz + 2.0 * s], + 0.0, + [0.0, 0.0, 0.0, 0.4], + ); + elements.push(MenuElement::AtlasTexture { + x: sw_x, + y: sw_y, + w: sw_sz, + h: sw_sz, + region: *region, + tint: WHITE, + }); + let text_x = sw_x + sw_sz + 6.0 * s; + elements.push(MenuElement::Text { + x: text_x, + y: ry + 2.0 * s, + text: label.clone(), + scale: ui_fs, + color: pal.text, + centered: false, + }); + elements.push(MenuElement::Text { + x: text_x, + y: ry + 2.0 * s + ui_fs + 2.0 * s, + text: key.clone(), + scale: ui_fs * 0.8, + color: pal.dim, + centered: false, + }); + let rect = [detail_x + detail_w - pad - btn_w, sw_y, btn_w, sw_sz]; + let hovered = button( + &mut elements, + &mut any_hovered, + cursor, + rect, + ui_fs * 0.85, + "Import", + 5.0 * s, + &pal, + true, + ); + if clicked && hovered { + any_clicked = true; + self.tex_status = format!("{IMPORT_STUB}: {key}"); + } + ry += row_h; + } + } else { + elements.push(MenuElement::Text { + x: detail_x + detail_w / 2.0, + y: body_y + (body_bottom - body_y) / 2.0, + text: "Select a block".into(), + scale: ui_fs, + color: pal.dim, + centered: true, + }); + } + + let console_y = y1 - console_h; + push_panel( + &mut elements, + [x0, console_y, x1 - x0, console_h], + 6.0 * s, + PANEL_BG, + ); + let status = if self.tex_status.is_empty() { + "Ready" + } else { + &self.tex_status + }; + elements.push(MenuElement::Text { + x: x0 + pad, + y: console_y + (console_h - ui_fs) / 2.0, + text: format!("\u{203a} {status}"), + scale: ui_fs, + color: pal.dim, + centered: false, + }); + + MainMenuResult { + elements, + action: MenuAction::None, + cursor_pointer: any_hovered, + blur: 1.0, + clicked_button: any_clicked, + } + } +} diff --git a/pomme-client/src/ui/pause.rs b/pomme-client/src/ui/pause.rs index 9fc4899b..ad4c08a0 100644 --- a/pomme-client/src/ui/pause.rs +++ b/pomme-client/src/ui/pause.rs @@ -14,6 +14,7 @@ pub enum PauseAction { Options, Benchmark, Editor, + TextureEditor, } pub fn build_pause_menu( @@ -112,7 +113,7 @@ pub fn build_pause_menu( { action = PauseAction::Editor; } - common::push_button( + if common::push_button( elements, cursor, col2_x, @@ -121,9 +122,12 @@ pub fn build_pause_menu( btn_h, gs, fs, - "Report Bugs", - false, - ); + "Textures", + true, + ) && clicked + { + action = PauseAction::TextureEditor; + } if common::push_button( elements, diff --git a/pomme-client/src/world/block/registry.rs b/pomme-client/src/world/block/registry.rs index 9ba913ed..3833a80e 100644 --- a/pomme-client/src/world/block/registry.rs +++ b/pomme-client/src/world/block/registry.rs @@ -137,6 +137,64 @@ impl BlockRegistry { self.textures.get(block.id()) } + /// Labeled, key-deduped textures making up a block's appearance, keyed by + /// block id (== item name for blocks). Empty for non-block items. + pub fn block_face_textures(&self, name: &str) -> Vec<(String, String)> { + if let Some(ft) = self.textures.get(name) { + let faces: [(&str, &str); 6] = [ + ("Top", &ft.top), + ("Bottom", &ft.bottom), + ("North", &ft.north), + ("South", &ft.south), + ("East", &ft.east), + ("West", &ft.west), + ]; + let mut order: Vec<&str> = Vec::new(); + let mut groups: HashMap<&str, Vec<&str>> = HashMap::new(); + for (label, key) in faces { + let entry = groups.entry(key).or_default(); + if entry.is_empty() { + order.push(key); + } + entry.push(label); + } + let mut out: Vec<(String, String)> = order + .into_iter() + .map(|key| { + let labels = &groups[key]; + let label = if labels.len() == 6 { + "All faces".to_string() + } else if labels.len() == 4 + && !labels.contains(&"Top") + && !labels.contains(&"Bottom") + { + "Sides".to_string() + } else { + labels.join("/") + }; + (label, key.to_string()) + }) + .collect(); + if let Some(overlay) = &ft.side_overlay { + out.push(("Overlay".to_string(), overlay.clone())); + } + out + } else if let Some(variants) = self.baked.get(name) { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for model in variants.values() { + for quad in &model.quads { + if seen.insert(quad.texture.as_str()) { + out.push((format!("Texture {}", out.len() + 1), quad.texture.clone())); + } + } + } + out + } else { + Vec::new() + } + } + pub fn get_baked_model(&self, state: BlockState) -> Option<&BakedModel> { let block: Box = state.into(); let variants = self.baked.get(block.id())?;