From 8ab9426f71adb954ff4f723d0aecba9a32aadfd6 Mon Sep 17 00:00:00 2001 From: Liam Bigelow <40188355+bglw@users.noreply.github.com> Date: Sun, 13 Apr 2025 10:27:44 +1200 Subject: [PATCH 1/5] Add UI interactions for dragging a tile within the hand --- truncate_client/src/lil_bits/hand.rs | 470 +++++++++++++++------------ truncate_client/src/utils/depot.rs | 1 + 2 files changed, 267 insertions(+), 204 deletions(-) diff --git a/truncate_client/src/lil_bits/hand.rs b/truncate_client/src/lil_bits/hand.rs index c29453a3..3a8f8a1c 100644 --- a/truncate_client/src/lil_bits/hand.rs +++ b/truncate_client/src/lil_bits/hand.rs @@ -51,9 +51,145 @@ impl<'a> HandUI<'a> { .. } = depot; + let mut started_interaction = false; + let rearrange = None; + let mut next_selection = None; + let mut highlights = interactions.highlight_tiles.clone(); + interactions.hovered_tile_in_hand = None; + if !ui.memory(|m| m.is_anything_being_dragged()) { + interactions.rearranging_tiles = false; + } + + ui.style_mut().spacing.item_spacing = egui::vec2(0.0, 0.0); + + let (_, mut margin, theme) = aesthetics.theme.calc_rescale( + &ui.available_rect_before_wrap(), + self.hand.len(), + 1, + 0.5..1.3, + (0.0, 0.0), + ); + + depot.ui_state.hand_height_last_frame = theme.grid_size; + + let old_theme = aesthetics.theme.clone(); + aesthetics.theme = theme; + + margin.top = 0.0; + margin.bottom = 0.0; + + let outer_frame = egui::Frame::none().inner_margin(margin); + + let mut dragging_tile: Option = None; + + let render_slots: Vec<_> = outer_frame + .show(ui, |ui| { + ui.horizontal(|ui| { + self.hand + .iter() + .map(|_| { + let (base_rect, _) = ui.allocate_exact_size( + egui::vec2(aesthetics.theme.grid_size, aesthetics.theme.grid_size), + egui::Sense::hover(), + ); + + base_rect + }) + .collect() + }) + .inner + }) + .inner; + + let hovered_slot = if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { + render_slots.iter().position(|s| s.contains(pointer_pos)) + } else { + None + }; + + for (i, char) in self.hand.iter().enumerate() { + let tile_id = Id::new("Hand").with(i).with(char); + let raw_dragged = ui.memory(|mem| mem.is_being_dragged(tile_id)); + let is_decidedly_dragging = ui.ctx().input(|inp| inp.pointer.is_decidedly_dragging()); + + // Bail out of a drag if we're not "decidedly dragging", + // as this could instead be just a click. + let is_being_dragged = if raw_dragged { + is_decidedly_dragging + } else { + false + }; + if is_being_dragged { + dragging_tile = Some(i); + } + } + + if self.interactive { + for (i, char) in self.hand.iter().enumerate() { + let tile_id = Id::new("Hand").with(i).with(char); + + // TODO: Remove? + let _highlight = if let Some(highlights) = highlights.as_mut() { + if let Some(c) = highlights.iter().position(|c| c == char) { + highlights.remove(c); + true + } else { + false + } + } else { + false + }; + + let mut base_rect = render_slots[i].clone(); + + // TODO: Remove magic number somehow (currently 2px/16px for tile sprite border) + let tile_margin = aesthetics.theme.grid_size * 0.125; + let tile_rect = base_rect.shrink(tile_margin); + let tile_response = ui.interact(tile_rect, tile_id, Sense::click_and_drag()); + if tile_response.hovered() { + ui.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand); + interactions.hovered_tile_in_hand = Some((i, *char)); + } + + if tile_response.drag_started() { + if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { + let delta = pointer_pos - tile_response.rect.center(); + ui.memory_mut(|mem| { + mem.data.insert_temp(tile_id, delta); + mem.data.insert_temp(tile_id, depot.timing.current_time); + }); + } + ui.ctx() + .animate_value_with_time(tile_id.with("initial_offset"), 0.0, 0.0); + + started_interaction = true; + } else if tile_response.drag_released() && dragging_tile.is_some() { + if let Some(HoveredRegion { + coord: Some(coord), .. + }) = interactions.hovered_unoccupied_square_on_board + { + interactions.released_tile = Some((i, *char, coord)); + } + } + + if tile_response.clicked() { + if matches!( + interactions.selected_tile_in_hand, + Some((selected_index, selected_char)) + if selected_index == i && selected_char == self.hand.0[i]) + { + next_selection = Some(None); + } else { + next_selection = Some(Some((i, self.hand.0[i]))); + } + + started_interaction = true; + } + } + } + let selected = interactions.selected_tile_in_hand; let hovered = interactions.hovered_tile_in_hand; - mapped_tiles.remap_texture( ui.ctx(), self.hand @@ -61,10 +197,10 @@ impl<'a> HandUI<'a> { .iter() .enumerate() .map(|(i, c)| { - // NB: Hovering and selecting here will be delayed by one frame since - // we remap before handling interactions. - let hovered = matches!(hovered, Some((p, _)) if p == i); - let selected = matches!(selected, Some((p, _)) if p == i); + let hovered = + dragging_tile.is_none() && matches!(hovered, Some((p, _)) if p == i); + let selected = + dragging_tile.is_none() && matches!(selected, Some((p, _)) if p == i); let color = if self.active { aesthetics.player_colors[gameplay.player_number as usize] @@ -93,221 +229,147 @@ impl<'a> HandUI<'a> { Some(interactions), ); - let mut started_interaction = false; - let rearrange = None; - let mut next_selection = None; - let mut highlights = interactions.highlight_tiles.clone(); - interactions.hovered_tile_in_hand = None; - - ui.style_mut().spacing.item_spacing = egui::vec2(0.0, 0.0); - - let (_, mut margin, theme) = aesthetics.theme.calc_rescale( - &ui.available_rect_before_wrap(), - self.hand.len(), - 1, - 0.5..1.3, - (0.0, 0.0), - ); - - depot.ui_state.hand_height_last_frame = theme.grid_size; - - let old_theme = aesthetics.theme.clone(); - aesthetics.theme = theme; - - margin.top = 0.0; - margin.bottom = 0.0; - - let outer_frame = egui::Frame::none().inner_margin(margin); + for (i, char) in self.hand.iter().enumerate() { + let tile_id = Id::new("Hand").with(i).with(char); + let mut base_rect = render_slots[i].clone(); + + if let (Some(hovered_slot), Some(dragging_tile)) = (hovered_slot, dragging_tile) { + if dragging_tile > i && i >= hovered_slot { + interactions.rearranging_tiles = true; + base_rect = render_slots + .get(i + 1) + .expect("shouldn't be able to drag from a tile beyond the last") + .clone(); + } else if dragging_tile < i && i <= hovered_slot { + interactions.rearranging_tiles = true; + base_rect = render_slots + .get(i - 1) + .expect("shouldn't be able to drag from a tile beyond the last") + .clone(); + } + } + + let animated_position = pos2( + ui.ctx().animate_value_with_time( + tile_id.with("hand_delta_x"), + base_rect.left_top().x, + aesthetics.theme.animation_time, + ), + ui.ctx().animate_value_with_time( + tile_id.with("hand_delta_y"), + base_rect.left_top().y, + aesthetics.theme.animation_time, + ), + ); + let animated_rect = Rect::from_min_size(animated_position, base_rect.size()); + + if dragging_tile != Some(i) { + mapped_tiles.render_tile_to_rect(i, animated_rect, ui); + } + } - outer_frame.show(ui, |ui| { - ui.horizontal(|ui| { - for (i, char) in self.hand.iter().enumerate() { - HandSquareUI::new().render(ui, depot, |ui, depot| { - let tile_id = Id::new("Hand").with(i).with(char); - let mut is_being_dragged = ui.memory(|mem| mem.is_being_dragged(tile_id)); - let is_decidedly_dragging = - ui.ctx().input(|inp| inp.pointer.is_decidedly_dragging()); - - // Bail out of a drag if we're not "decidedly dragging", - // as this could instead be just a click. - if is_being_dragged && !is_decidedly_dragging { - is_being_dragged = false; - } - - let _highlight = if let Some(highlights) = highlights.as_mut() { - if let Some(c) = highlights.iter().position(|c| c == char) { - highlights.remove(c); - true - } else { - false - } - } else { - false - }; - - let (base_rect, _) = ui.allocate_exact_size( - egui::vec2( - depot.aesthetics.theme.grid_size, - depot.aesthetics.theme.grid_size, - ), - egui::Sense::hover(), - ); - - mapped_tiles.render_tile_to_rect(i, base_rect, ui); - - if !self.interactive { - return; - } - - // TODO: Remove magic number somehow (currently 2px/16px for tile sprite border) - let tile_margin = depot.aesthetics.theme.grid_size * 0.125; - let tile_rect = base_rect.shrink(tile_margin); - let tile_response = - ui.interact(tile_rect, tile_id, Sense::click_and_drag()); - if tile_response.hovered() { - ui.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand); - depot.interactions.hovered_tile_in_hand = Some((i, *char)); - } - - if tile_response.drag_started() { - if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { - let delta = pointer_pos - tile_response.rect.center(); - ui.memory_mut(|mem| { - mem.data.insert_temp(tile_id, delta); - mem.data.insert_temp(tile_id, depot.timing.current_time); - }); - } - ui.ctx().animate_value_with_time( - tile_id.with("initial_offset"), - 0.0, - 0.0, - ); + if let Some(dragging_tile) = dragging_tile { + next_selection = Some(None); + + let tile_id = Id::new("Hand") + .with(dragging_tile) + .with(self.hand.get(dragging_tile)); + let drag_id: Duration = ui + .memory(|mem| mem.data.get_temp(tile_id)) + .unwrap_or_default(); + + let area = egui::Area::new(tile_id.with("floating").with(drag_id)) + .movable(false) + .order(Order::Tooltip) + .anchor(Align2::LEFT_TOP, vec2(0.0, 0.0)); + + area.show(ui.ctx(), |ui| { + let ideal_width = + if let Some(region) = &interactions.hovered_unoccupied_square_on_board { + region.rect.width() + } else { + aesthetics.theme.grid_size + }; - started_interaction = true; - } else if tile_response.drag_released() && is_decidedly_dragging { - if let Some(HoveredRegion { - coord: Some(coord), .. - }) = depot.interactions.hovered_unoccupied_square_on_board - { - depot.interactions.released_tile = Some((i, *char, coord)); - } - } - - if is_being_dragged { - next_selection = Some(None); - - let drag_id: Duration = ui - .memory(|mem| mem.data.get_temp(tile_id)) - .unwrap_or_default(); - - let area = egui::Area::new(tile_id.with("floating").with(drag_id)) - .movable(false) - .order(Order::Tooltip) - .anchor(Align2::LEFT_TOP, vec2(0.0, 0.0)); - - area.show(ui.ctx(), |ui| { - let ideal_width = if let Some(region) = - &depot.interactions.hovered_unoccupied_square_on_board - { - region.rect.width() - } else { - depot.aesthetics.theme.grid_size - }; - - let bouncy_width = ui.ctx().animate_value_with_time( - area.layer().id.with("width"), - ideal_width, - depot.aesthetics.theme.animation_time, - ); - - let snap_to_rect = depot - .interactions - .hovered_unoccupied_square_on_board - .as_ref() - .map(|region| region.rect); - - let position = if let Some(snap) = snap_to_rect { - snap.left_top() - } else if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { - let drag_offset = - if depot.ui_state.is_touch { -50.0 } else { 0.0 }; - let bounce_offset = ui.ctx().animate_value_with_time( - tile_id.with("initial_offset"), - drag_offset, - depot.aesthetics.theme.animation_time, - ); - let original_delta: Vec2 = ui.memory(|mem| { - mem.data.get_temp(tile_id).unwrap_or_default() - }); - pointer_pos + vec2(0.0, bounce_offset) - - original_delta - - Vec2::splat(bouncy_width / 2.0) - } else { - tile_rect.left_top() - }; - - let animated_position = pos2( - ui.ctx().animate_value_with_time( - area.layer().id.with("delta_x"), - position.x, - depot.aesthetics.theme.animation_time, - ), - ui.ctx().animate_value_with_time( - area.layer().id.with("delta_y"), - position.y, - depot.aesthetics.theme.animation_time, - ), - ); - - mapped_tiles.render_tile_to_rect( - i, - Rect::from_min_size( - animated_position, - Vec2::splat(bouncy_width), - ), - ui, - ); - }) - .response; - - ui.ctx() - .output_mut(|out| out.cursor_icon = CursorIcon::Grabbing); - } - - if tile_response.clicked() { - if matches!( - depot.interactions.selected_tile_in_hand, - Some((selected_index, selected_char)) - if selected_index == i && selected_char == self.hand.0[i]) - { - next_selection = Some(None); - } else { - next_selection = Some(Some((i, self.hand.0[i]))); - } - - started_interaction = true; - } + let bouncy_width = ui.ctx().animate_value_with_time( + area.layer().id.with("width"), + ideal_width, + aesthetics.theme.animation_time, + ); + + let mut snap_to_rect = interactions + .hovered_unoccupied_square_on_board + .as_ref() + .map(|region| region.rect); + + if interactions.rearranging_tiles { + let hovered_hand_rect = hovered_slot.map(|hovered_slot| { + let rect = render_slots[hovered_slot].clone(); + rect.translate(vec2(0.0, -(rect.height() / 4.0))) }); + + snap_to_rect = snap_to_rect.or(hovered_hand_rect); } - }); - }); + + let position = if let Some(snap) = snap_to_rect { + snap.left_top() + } else if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { + let drag_offset = if depot.ui_state.is_touch { -50.0 } else { 0.0 }; + let bounce_offset = ui.ctx().animate_value_with_time( + tile_id.with("initial_offset"), + drag_offset, + aesthetics.theme.animation_time, + ); + let original_delta: Vec2 = + ui.memory(|mem| mem.data.get_temp(tile_id).unwrap_or_default()); + pointer_pos + vec2(0.0, bounce_offset) + - original_delta + - Vec2::splat(bouncy_width / 2.0) + } else { + pos2(0.0, 0.0) + }; + + let animated_position = pos2( + ui.ctx().animate_value_with_time( + area.layer().id.with("delta_x"), + position.x, + aesthetics.theme.animation_time, + ), + ui.ctx().animate_value_with_time( + area.layer().id.with("delta_y"), + position.y, + aesthetics.theme.animation_time, + ), + ); + + mapped_tiles.render_tile_to_rect( + dragging_tile, + Rect::from_min_size(animated_position, Vec2::splat(bouncy_width)), + ui, + ); + }) + .response; + + ui.ctx() + .output_mut(|out| out.cursor_icon = CursorIcon::Grabbing); + } if let Some((from, to)) = rearrange { self.hand.rearrange(from, to); } if let Some(new_selection) = next_selection { - depot.interactions.selected_tile_in_hand = new_selection; - depot.interactions.selected_tile_on_board = None; + interactions.selected_tile_in_hand = new_selection; + interactions.selected_tile_on_board = None; } - depot.aesthetics.theme = old_theme; + aesthetics.theme = old_theme; if started_interaction { depot.ui_state.dictionary_open = false; depot.ui_state.dictionary_focused = false; - depot.interactions.selected_square_on_board = None; - depot.interactions.selected_tile_on_board = None; + interactions.selected_square_on_board = None; + interactions.selected_tile_on_board = None; } } } diff --git a/truncate_client/src/utils/depot.rs b/truncate_client/src/utils/depot.rs index c2514f39..c779c0dc 100644 --- a/truncate_client/src/utils/depot.rs +++ b/truncate_client/src/utils/depot.rs @@ -37,6 +37,7 @@ pub struct InteractionDepot { pub selected_tile_in_hand: Option<(usize, char)>, pub highlight_tiles: Option>, pub highlight_squares: Option>, + pub rearranging_tiles: bool, } #[derive(Clone, Default)] From 78785f0f335da28e62d8e7c88a6173f0cc9e1e7c Mon Sep 17 00:00:00 2001 From: Liam Bigelow <40188355+bglw@users.noreply.github.com> Date: Sun, 13 Apr 2025 20:40:24 +1200 Subject: [PATCH 2/5] Implement handling for hand rearranging and improve visual drag glitches --- truncate_client/src/lil_bits/hand.rs | 49 +++++++++++++------ .../src/regions/active_game/control_strip.rs | 6 ++- truncate_client/src/regions/single_player.rs | 4 +- truncate_client/src/utils/depot.rs | 1 + truncate_core/src/messages.rs | 4 ++ truncate_core/src/player.rs | 18 +++++++ truncate_server/src/game_state.rs | 8 +++ truncate_server/src/main.rs | 9 ++++ 8 files changed, 82 insertions(+), 17 deletions(-) diff --git a/truncate_client/src/lil_bits/hand.rs b/truncate_client/src/lil_bits/hand.rs index 3a8f8a1c..0bb553ee 100644 --- a/truncate_client/src/lil_bits/hand.rs +++ b/truncate_client/src/lil_bits/hand.rs @@ -1,5 +1,5 @@ use instant::Duration; -use truncate_core::player::Hand; +use truncate_core::{messages::PlayerMessage, player::Hand}; use eframe::egui::{self, CursorIcon, Id, Order, Sense}; use epaint::{emath::Align2, pos2, vec2, Rect, Vec2}; @@ -43,7 +43,7 @@ impl<'a> HandUI<'a> { ui: &mut egui::Ui, depot: &mut TruncateDepot, mapped_tiles: &mut MappedTiles, - ) { + ) -> Option { let TruncateDepot { interactions, aesthetics, @@ -51,10 +51,12 @@ impl<'a> HandUI<'a> { .. } = depot; + let mut msg = None; let mut started_interaction = false; - let rearrange = None; + let mut rearrange = None; let mut next_selection = None; let mut highlights = interactions.highlight_tiles.clone(); + let hand_animation_generation = interactions.hand_animation_generation; interactions.hovered_tile_in_hand = None; if !ui.memory(|m| m.is_anything_being_dragged()) { interactions.rearranging_tiles = false; @@ -108,7 +110,10 @@ impl<'a> HandUI<'a> { }; for (i, char) in self.hand.iter().enumerate() { - let tile_id = Id::new("Hand").with(i).with(char); + let tile_id = Id::new("Hand") + .with(i) + .with(char) + .with(hand_animation_generation); let raw_dragged = ui.memory(|mem| mem.is_being_dragged(tile_id)); let is_decidedly_dragging = ui.ctx().input(|inp| inp.pointer.is_decidedly_dragging()); @@ -126,7 +131,10 @@ impl<'a> HandUI<'a> { if self.interactive { for (i, char) in self.hand.iter().enumerate() { - let tile_id = Id::new("Hand").with(i).with(char); + let tile_id = Id::new("Hand") + .with(i) + .with(char) + .with(hand_animation_generation); // TODO: Remove? let _highlight = if let Some(highlights) = highlights.as_mut() { @@ -140,7 +148,7 @@ impl<'a> HandUI<'a> { false }; - let mut base_rect = render_slots[i].clone(); + let base_rect = render_slots[i].clone(); // TODO: Remove magic number somehow (currently 2px/16px for tile sprite border) let tile_margin = aesthetics.theme.grid_size * 0.125; @@ -164,12 +172,19 @@ impl<'a> HandUI<'a> { started_interaction = true; } else if tile_response.drag_released() && dragging_tile.is_some() { + interactions.hand_animation_generation += 1; if let Some(HoveredRegion { coord: Some(coord), .. }) = interactions.hovered_unoccupied_square_on_board { interactions.released_tile = Some((i, *char, coord)); } + + if let Some(dropped_slot) = hovered_slot { + if dropped_slot != i { + rearrange = Some((i, dropped_slot)); + } + } } if tile_response.clicked() { @@ -230,7 +245,10 @@ impl<'a> HandUI<'a> { ); for (i, char) in self.hand.iter().enumerate() { - let tile_id = Id::new("Hand").with(i).with(char); + let tile_id = Id::new("Hand") + .with(i) + .with(char) + .with(hand_animation_generation); let mut base_rect = render_slots[i].clone(); if let (Some(hovered_slot), Some(dragging_tile)) = (hovered_slot, dragging_tile) { @@ -273,12 +291,10 @@ impl<'a> HandUI<'a> { let tile_id = Id::new("Hand") .with(dragging_tile) - .with(self.hand.get(dragging_tile)); - let drag_id: Duration = ui - .memory(|mem| mem.data.get_temp(tile_id)) - .unwrap_or_default(); + .with(self.hand.get(dragging_tile)) + .with(hand_animation_generation); - let area = egui::Area::new(tile_id.with("floating").with(drag_id)) + let area = egui::Area::new(tile_id.with("floating")) .movable(false) .order(Order::Tooltip) .anchor(Align2::LEFT_TOP, vec2(0.0, 0.0)); @@ -292,7 +308,7 @@ impl<'a> HandUI<'a> { }; let bouncy_width = ui.ctx().animate_value_with_time( - area.layer().id.with("width"), + tile_id.with("width"), ideal_width, aesthetics.theme.animation_time, ); @@ -331,12 +347,12 @@ impl<'a> HandUI<'a> { let animated_position = pos2( ui.ctx().animate_value_with_time( - area.layer().id.with("delta_x"), + tile_id.with("delta_x"), position.x, aesthetics.theme.animation_time, ), ui.ctx().animate_value_with_time( - area.layer().id.with("delta_y"), + tile_id.with("delta_y"), position.y, aesthetics.theme.animation_time, ), @@ -356,6 +372,7 @@ impl<'a> HandUI<'a> { if let Some((from, to)) = rearrange { self.hand.rearrange(from, to); + msg = Some(PlayerMessage::RearrangeHand(self.hand.clone())); } if let Some(new_selection) = next_selection { @@ -371,5 +388,7 @@ impl<'a> HandUI<'a> { interactions.selected_square_on_board = None; interactions.selected_tile_on_board = None; } + + msg } } diff --git a/truncate_client/src/regions/active_game/control_strip.rs b/truncate_client/src/regions/active_game/control_strip.rs index e01e8937..aac62139 100644 --- a/truncate_client/src/regions/active_game/control_strip.rs +++ b/truncate_client/src/regions/active_game/control_strip.rs @@ -277,11 +277,15 @@ impl ActiveGame { .next_player_number .is_some_and(|n| n == self.depot.gameplay.player_number); - HandUI::new(&mut self.hand).active(active_hand).render( + let hand_msg = HandUI::new(&mut self.hand).active(active_hand).render( &mut hand_ui, &mut self.depot, &mut self.mapped_hand, ); + + if hand_msg.is_some() { + msg = hand_msg; + } }, ); diff --git a/truncate_client/src/regions/single_player.rs b/truncate_client/src/regions/single_player.rs index 137a0f01..ac5b3130 100644 --- a/truncate_client/src/regions/single_player.rs +++ b/truncate_client/src/regions/single_player.rs @@ -434,7 +434,9 @@ impl SinglePlayerState { .render(&mut ui, current_time, Some(&self.game)) .map(|msg| (human_player, msg)); - if matches!(next_msg, Some((_, PlayerMessage::Rematch))) { + if let Some((player_index, PlayerMessage::RearrangeHand(new_hand))) = next_msg.as_ref() { + _ = self.game.players[*player_index].rearrange_hand(new_hand.clone()); + } else if matches!(next_msg, Some((_, PlayerMessage::Rematch))) { self.reset(current_time, ui.ctx(), backchannel); return msgs_to_server; } else if matches!(next_msg, Some((_, PlayerMessage::Resign))) { diff --git a/truncate_client/src/utils/depot.rs b/truncate_client/src/utils/depot.rs index c779c0dc..db05b76b 100644 --- a/truncate_client/src/utils/depot.rs +++ b/truncate_client/src/utils/depot.rs @@ -38,6 +38,7 @@ pub struct InteractionDepot { pub highlight_tiles: Option>, pub highlight_squares: Option>, pub rearranging_tiles: bool, + pub hand_animation_generation: usize, } #[derive(Clone, Default)] diff --git a/truncate_core/src/messages.rs b/truncate_core/src/messages.rs index 52c7324f..f45a1079 100644 --- a/truncate_core/src/messages.rs +++ b/truncate_core/src/messages.rs @@ -49,6 +49,7 @@ pub enum PlayerMessage { tile: char, }, Swap(Coordinate, Coordinate), + RearrangeHand(Hand), Rematch, Pause, Unpause, @@ -111,6 +112,9 @@ impl fmt::Display for PlayerMessage { write!(f, "Place slot {:?} ({}) at {}", slot, tile, coord) } PlayerMessage::Swap(a, b) => write!(f, "Swap the tiles at {} and {}", a, b), + PlayerMessage::RearrangeHand(hand) => { + write!(f, "Rearrange the hand to {hand}") + } PlayerMessage::Rematch => write!(f, "Rematch!"), PlayerMessage::Pause => write!(f, "Pause!"), PlayerMessage::Unpause => write!(f, "Unpause!"), diff --git a/truncate_core/src/player.rs b/truncate_core/src/player.rs index eec96e18..d33ca26c 100644 --- a/truncate_core/src/player.rs +++ b/truncate_core/src/player.rs @@ -62,6 +62,15 @@ impl Hand { let c = self.0.remove(from); self.0.insert(to, c); } + + pub fn is_equivalent_to(&self, other: &Hand) -> bool { + let mut ours = self.0.clone(); + let mut theirs = other.0.clone(); + ours.sort(); + theirs.sort(); + + ours == theirs + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -109,6 +118,15 @@ impl Player { } } + pub fn rearrange_hand(&mut self, new_hand: Hand) -> Result<(), ()> { + if self.hand.is_equivalent_to(&new_hand) { + self.hand = new_hand; + Ok(()) + } else { + Err(()) + } + } + pub fn has_tile(&self, tile: char) -> bool { self.hand.0.contains(&tile) } diff --git a/truncate_server/src/game_state.rs b/truncate_server/src/game_state.rs index f645ba1a..60dc2c80 100644 --- a/truncate_server/src/game_state.rs +++ b/truncate_server/src/game_state.rs @@ -8,6 +8,7 @@ use truncate_core::{ generation::{ArtifactType, BoardParams}, messages::{GameMessage, GamePlayerMessage, GameStateMessage, LobbyPlayerMessage}, moves::Move, + player::Hand, reporting::Change, rules::GameRules, }; @@ -355,6 +356,13 @@ impl GameManager { } } + pub fn rearrange_hand(&mut self, player: SocketAddr, new_hand: Hand) { + if let Some(player_index) = self.get_player_index(player) { + // TODO: Return this error to the player + _ = self.core_game.players[player_index].rearrange_hand(new_hand); + } + } + pub fn pause(&mut self, words: Arc>) -> Vec<(&Player, GameMessage)> { self.core_game.pause(); diff --git a/truncate_server/src/main.rs b/truncate_server/src/main.rs index 57bf4b6c..63c33f21 100644 --- a/truncate_server/src/main.rs +++ b/truncate_server/src/main.rs @@ -570,6 +570,15 @@ async fn handle_player_msg( todo!("Handle player not being enrolled in a game"); } } + RearrangeHand(hand) => { + if let Some(existing_game) = server_state.get_game_by_player(&player_addr) { + let mut game_manager = existing_game.lock(); + game_manager.rearrange_hand(player_addr, hand); + // TODO: Catch an error here and return to the client + } else { + todo!("Handle player not being enrolled in a game"); + } + } Rematch => { if let Some(existing_game) = server_state.get_game_by_player(&player_addr) { let connection_player = connection_info_mutex.lock().player.clone(); From 112ef68b904790827cd0d4e4d7fed21d480d3d47 Mon Sep 17 00:00:00 2001 From: Liam Bigelow <40188355+bglw@users.noreply.github.com> Date: Mon, 2 Jun 2025 10:16:24 +1200 Subject: [PATCH 3/5] Improve hand drag rearranging on mobile --- Cargo.lock | 25 ++++++++++++++----------- truncate_client/src/lil_bits/hand.rs | 13 +++++++++++-- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ecd7f9a..02b6c889 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5216,24 +5216,24 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.93" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.93" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", "syn 2.0.96", @@ -5254,9 +5254,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.93" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5264,9 +5264,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.93" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", @@ -5277,9 +5277,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.93" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "wayland-backend" diff --git a/truncate_client/src/lil_bits/hand.rs b/truncate_client/src/lil_bits/hand.rs index 0bb553ee..a4908793 100644 --- a/truncate_client/src/lil_bits/hand.rs +++ b/truncate_client/src/lil_bits/hand.rs @@ -104,7 +104,11 @@ impl<'a> HandUI<'a> { .inner; let hovered_slot = if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { - render_slots.iter().position(|s| s.contains(pointer_pos)) + render_slots.iter().position(|s| { + s.expand2(vec2(0.0, 25.0)) + .translate(vec2(0.0, 25.0)) + .contains(pointer_pos) + }) } else { None }; @@ -321,7 +325,12 @@ impl<'a> HandUI<'a> { if interactions.rearranging_tiles { let hovered_hand_rect = hovered_slot.map(|hovered_slot| { let rect = render_slots[hovered_slot].clone(); - rect.translate(vec2(0.0, -(rect.height() / 4.0))) + let lift_height = if depot.ui_state.is_touch { + rect.height() / 2.0 + } else { + rect.height() / 4.0 + }; + rect.translate(vec2(0.0, -lift_height)) }); snap_to_rect = snap_to_rect.or(hovered_hand_rect); From 9984ee1d6e581a213e259d8d51e0d27950db804d Mon Sep 17 00:00:00 2001 From: Liam Bigelow <40188355+bglw@users.noreply.github.com> Date: Mon, 2 Jun 2025 10:38:04 +1200 Subject: [PATCH 4/5] Add hand rearranging changelog --- truncate_client/src/app_inner.rs | 54 ++++++++++++------- truncate_client/src/utils/includes.rs | 5 ++ truncate_client/tutorials/update_03.yml | 12 +++++ .../20250601223036_update_03.down.sql | 3 ++ .../20250601223036_update_03.up.sql | 3 ++ 5 files changed, 59 insertions(+), 18 deletions(-) create mode 100644 truncate_client/tutorials/update_03.yml create mode 100644 truncate_server/migrations/20250601223036_update_03.down.sql create mode 100644 truncate_server/migrations/20250601223036_update_03.up.sql diff --git a/truncate_client/src/app_inner.rs b/truncate_client/src/app_inner.rs index 7f35665d..9ce62eb3 100644 --- a/truncate_client/src/app_inner.rs +++ b/truncate_client/src/app_inner.rs @@ -128,24 +128,30 @@ pub fn render(outer: &mut OuterApplication, ui: &mut egui::Ui, current_time: Dur if tutorial.priority == Some(ChangePriority::High) { outer.event_dispatcher.event(format!("interrupt_{unread}")); - let changelog_ui = outer.inner_storage.changelog_ui.get_or_insert_with(|| { - ChangelogSplashUI::new(splash_message.clone(), current_time) - .with_button( - "view", - "VIEW SCENARIO".to_string(), - outer.theme.button_primary, - ) - .with_button( - "skip", - "REMIND ME LATER".to_string(), - outer.theme.button_primary, - ) - .with_button( - "ignore", - "IGNORE FOREVER".to_string(), - outer.theme.button_scary, - ) - }); + let changelog_ui = + outer.inner_storage.changelog_ui.get_or_insert_with(|| { + if tutorial.rules.is_empty() { + ChangelogSplashUI::new(splash_message.clone(), current_time) + .with_button("okay", "OKAY".to_string(), outer.theme.button_primary) + } else { + ChangelogSplashUI::new(splash_message.clone(), current_time) + .with_button( + "view", + "VIEW SCENARIO".to_string(), + outer.theme.button_primary, + ) + .with_button( + "skip", + "REMIND ME LATER".to_string(), + outer.theme.button_primary, + ) + .with_button( + "ignore", + "IGNORE FOREVER".to_string(), + outer.theme.button_scary, + ) + } + }); let resp = changelog_ui.render(ui, &outer.theme, current_time, &outer.map_texture); @@ -192,6 +198,18 @@ pub fn render(outer: &mut OuterApplication, ui: &mut egui::Ui, current_time: Dur break; } + if resp.clicked == Some("okay") { + outer + .event_dispatcher + .event(format!("interrupt_okay_{unread}")); + outer.unread_changelogs = vec![]; + outer + .tx_player + .try_send(PlayerMessage::MarkChangelogRead(unread.clone())) + .unwrap(); + break; + } + return; } } diff --git a/truncate_client/src/utils/includes.rs b/truncate_client/src/utils/includes.rs index 3b20f086..22c6413c 100644 --- a/truncate_client/src/utils/includes.rs +++ b/truncate_client/src/utils/includes.rs @@ -78,6 +78,11 @@ pub fn changelogs() -> HashMap<&'static str, Tutorial> { serde_yaml::from_slice(include_bytes!("../../tutorials/update_02.yml")) .expect("Tutorial should match Tutorial format"), ), + ( + "update_03", + serde_yaml::from_slice(include_bytes!("../../tutorials/update_03.yml")) + .expect("Tutorial should match Tutorial format"), + ), ]) } diff --git a/truncate_client/tutorials/update_03.yml b/truncate_client/tutorials/update_03.yml new file mode 100644 index 00000000..4be7f502 --- /dev/null +++ b/truncate_client/tutorials/update_03.yml @@ -0,0 +1,12 @@ +splash_message: + - Hello! + - "New feature: You can now rearrange the tiles in your hand by dragging them." + - Have a good day :-) + +changelog_name: "" + +priority: High + +effective_day: 480 + +rules: [] diff --git a/truncate_server/migrations/20250601223036_update_03.down.sql b/truncate_server/migrations/20250601223036_update_03.down.sql new file mode 100644 index 00000000..54f1d4df --- /dev/null +++ b/truncate_server/migrations/20250601223036_update_03.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here +DELETE FROM changelogs +WHERE changelog_id = 'update_03'; diff --git a/truncate_server/migrations/20250601223036_update_03.up.sql b/truncate_server/migrations/20250601223036_update_03.up.sql new file mode 100644 index 00000000..fc021b7f --- /dev/null +++ b/truncate_server/migrations/20250601223036_update_03.up.sql @@ -0,0 +1,3 @@ +-- Add up migration script here +INSERT INTO changelogs (changelog_id, changelog_timestamp) +VALUES ('update_03', CURRENT_TIMESTAMP); From ad636ff2f12eab18661854ec3279aa705f3fee6d Mon Sep 17 00:00:00 2001 From: Liam Bigelow <40188355+bglw@users.noreply.github.com> Date: Mon, 2 Jun 2025 10:44:58 +1200 Subject: [PATCH 5/5] Update wasm-bindgen version --- .github/workflows/test.yml | 2 +- Dockerfile.client | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8606ee04..522e21a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,7 +59,7 @@ jobs: - name: Install Rust components if: matrix.workspace == 'truncate_client' run: |- - cargo install -f wasm-bindgen-cli --version 0.2.93 + cargo install -f wasm-bindgen-cli --version 0.2.100 rustup target add wasm32-unknown-unknown - name: Test diff --git a/Dockerfile.client b/Dockerfile.client index 3338cf4b..df61faa2 100644 --- a/Dockerfile.client +++ b/Dockerfile.client @@ -15,7 +15,7 @@ RUN apt-get install -y nodejs RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh # Keep this wasm-bindgen-cli version aligned with the latest egui release's dependency -RUN cargo install -f wasm-bindgen-cli --version 0.2.93 +RUN cargo install -f wasm-bindgen-cli --version 0.2.100 RUN rustup target add wasm32-unknown-unknown RUN mkdir /app