Skip to main content

egui_hexweb/
widget.rs

1//! The egui widget that renders and drives a [`HexwebGame`]. This is the
2//! only module allowed to touch `egui::Ui`/`Painter`.
3//!
4//! # Interaction model
5//!
6//! Pieces move by drag & drop, with a click-to-select fallback: press a
7//! piece and release it over any node that can take it, or click a piece
8//! and then click where it should go. An empty node takes the piece as a
9//! move; an occupied one swaps the two pieces. While a piece is dragged it
10//! goes translucent at its origin and every node the drop could land on
11//! grows a landing ring, the nearest one emphasized; releasing anywhere
12//! that is not a legal target (the piece's own node included, where the
13//! swap would change nothing) snaps the piece back. A piece never leaves
14//! its node until the gesture commits, so a drag that ends badly is not a
15//! move at all.
16
17use std::f32::consts::TAU;
18
19use egui::{
20    Color32, CornerRadius, CursorIcon, FontId, Pos2, Rect, Response, Sense, Shape, Stroke,
21    StrokeKind, Ui, Vec2, Widget,
22};
23
24use crate::game::{Dir, GameStatus, HexwebGame, Node, NodeId, PieceId};
25
26/// `sqrt(3)`: the vertical distance between two hexagon rows, as a
27/// fraction of the circumradius.
28const SQRT_3: f32 = 1.732_050_8;
29
30/// Piece hexagon radius, as a fraction of the cell size. Slightly under 1.0
31/// so a rim of the socket shows around every piece.
32const PIECE_RADIUS: f32 = 0.88;
33/// Chevron geometry, as fractions of the piece radius: how far from the
34/// piece's centre the arrow tip sits, how far back its wings start, and how
35/// far apart they are.
36const ARROW_TIP: f32 = 0.62;
37const ARROW_BACK: f32 = 0.30;
38const ARROW_SPREAD: f32 = 0.17;
39/// Arrow stroke width, as a fraction of the cell size.
40const ARROW_STROKE: f32 = 0.085;
41/// Radius of the landing and selection rings, as a fraction of the cell
42/// size. Under the socket apothem (`sqrt(3)/2`) so rings stay inside the
43/// node they mark.
44const RING_RADIUS: f32 = 0.80;
45
46/// The total footprint [`HexwebWidget`] will occupy for `game` at a given
47/// `cell_size`. Derived from the actual node centers, so irregular board
48/// shapes (a pendant node, a notched hexagon) get a tight box instead of a
49/// bounding square. Lets a caller pre-size a container before laying the
50/// widget out.
51pub fn content_size(game: &HexwebGame, cell_size: f32) -> Vec2 {
52    let (min, max) = center_bounds(game, cell_size);
53    (max - min) + Vec2::new(2.0, SQRT_3) * cell_size
54}
55
56/// The cell size (hex circumradius, in logical pixels) that fits `game`'s
57/// board into `available` space — the same auto-sizing formula
58/// [`HexwebWidget`] uses internally when no explicit `cell_size` is set.
59/// Lets a caller pre-compute that fit (e.g. to size a `Scene` before laying
60/// the widget out inside it) instead of duplicating the formula.
61pub fn fit_cell_size(game: &HexwebGame, available: Vec2) -> f32 {
62    let unit = content_size(game, 1.0);
63    (available.x / unit.x).min(available.y / unit.y).max(4.0)
64}
65
66/// Centre of node `(q, r)` relative to the `(0, 0)` centre, for a flat-top
67/// hexagon of circumradius `size`: columns step `1.5·size` sideways, rows
68/// `sqrt(3)·size` vertically, with every odd column shifted half a row.
69fn node_center(q: i32, r: i32, size: f32) -> Vec2 {
70    Vec2::new(
71        1.5 * size * q as f32,
72        SQRT_3 * size * (r as f32 + 0.5 * q as f32),
73    )
74}
75
76/// Screen angle of a direction. `Dir::ALL` runs clockwise from the top, and
77/// egui angles run clockwise from +x with y pointing down, so `Dir::Up` is
78/// at -90°.
79fn dir_angle(dir: Dir) -> f32 {
80    -TAU / 4.0 + TAU / 6.0 * dir as u8 as f32
81}
82
83/// The (min, max) of the node centers laid out at circumradius `size`.
84fn center_bounds(game: &HexwebGame, size: f32) -> (Vec2, Vec2) {
85    let mut min = Vec2::splat(f32::INFINITY);
86    let mut max = Vec2::splat(f32::NEG_INFINITY);
87    for node in game.nodes() {
88        let c = node_center(node.q, node.r, size);
89        min = min.min(c);
90        max = max.max(c);
91    }
92    (min, max)
93}
94
95/// The corners of a flat-top hexagon, clockwise.
96fn hex_corners(center: Pos2, radius: f32) -> Vec<Pos2> {
97    (0..6)
98        .map(|i| center + Vec2::angled(TAU / 6.0 * i as f32) * radius)
99        .collect()
100}
101
102/// One arrow, as a chevron pointing from the piece's centre toward `dir`.
103fn draw_arrow(painter: &egui::Painter, center: Pos2, dir: Dir, radius: f32, stroke: Stroke) {
104    let v = Vec2::angled(dir_angle(dir));
105    let tip = center + v * (radius * ARROW_TIP);
106    let back = center + v * (radius * ARROW_BACK);
107    let spread = v.rot90() * (radius * ARROW_SPREAD);
108    painter.add(Shape::line(vec![back + spread, tip, back - spread], stroke));
109}
110
111/// Whether dropping `piece` on `node` would land: an empty node takes a
112/// move, an occupied one takes a swap with its piece. The piece's own node
113/// never qualifies, so the highlighted target always marks a drop that
114/// actually lands.
115fn can_drop_at(game: &HexwebGame, piece: PieceId, node: NodeId) -> bool {
116    match game.piece_at(node) {
117        Some(other) => other != piece && game.can_swap(piece, other),
118        None => game.can_move(piece, node),
119    }
120}
121
122/// The mapping between board nodes and screen positions for one widget
123/// pass. Shared by painting and hit-testing so the two can never drift
124/// apart.
125#[derive(Clone, Copy)]
126struct Geometry {
127    /// Top-left of the widget's allocated rect.
128    origin: Pos2,
129    /// Hex circumradius.
130    cell: f32,
131    /// Node-center bounding-box min at this `cell`, used to shift the board
132    /// into the allocated rect with a margin of one hexagon.
133    min: Vec2,
134}
135
136impl Geometry {
137    fn new(origin: Pos2, cell: f32, game: &HexwebGame) -> Self {
138        Self {
139            origin,
140            cell,
141            min: center_bounds(game, cell).0,
142        }
143    }
144
145    /// Screen position of `node`'s center.
146    fn center(&self, node: &Node) -> Pos2 {
147        self.origin + node_center(node.q, node.r, self.cell) - self.min
148            + Vec2::new(self.cell, SQRT_3 * 0.5 * self.cell)
149    }
150
151    /// The node nearest `pos`, if it is close enough to claim the pointer.
152    /// Neighboring centers sit `sqrt(3)·cell` apart, so gating the nearest
153    /// at one circumradius covers every node's own hexagonal cell without
154    /// any two cells overlapping.
155    fn node_at(&self, game: &HexwebGame, pos: Pos2) -> Option<NodeId> {
156        let mut best = None;
157        let mut best_d = f32::INFINITY;
158        for (id, node) in game.nodes().iter().enumerate() {
159            let d = pos.distance(self.center(node));
160            if d < best_d {
161                best = Some(id);
162                best_d = d;
163            }
164        }
165        match best {
166            Some(id) if best_d <= self.cell => Some(id),
167            _ => None,
168        }
169    }
170}
171
172/// An egui widget that renders an interactive hexweb board.
173///
174/// Drag a piece onto an empty node to move it there, or onto another piece
175/// to swap the two, or click a piece and then click an empty destination.
176/// Arrows pointing at another piece are drawn in
177/// `satisfied_color`; the ones still pointing at an empty node or off the
178/// board are drawn in `unsatisfied_color`, which is the board's live
179/// progress feedback. The puzzle is solved when no arrow is unsatisfied.
180///
181/// ```ignore
182/// ui.add(egui_hexweb::HexwebWidget::new(&mut game));
183/// ```
184pub struct HexwebWidget<'a> {
185    game: &'a mut HexwebGame,
186    cell_size: Option<f32>,
187    win_message: Option<String>,
188    interactive: bool,
189    satisfied_color: Option<Color32>,
190    unsatisfied_color: Option<Color32>,
191}
192
193impl<'a> HexwebWidget<'a> {
194    pub fn new(game: &'a mut HexwebGame) -> Self {
195        Self {
196            game,
197            cell_size: None,
198            win_message: None,
199            interactive: true,
200            satisfied_color: None,
201            unsatisfied_color: None,
202        }
203    }
204
205    /// Override the size (in logical pixels) of each hexagon's
206    /// circumradius. When not set, the cell size is computed automatically
207    /// to fill the available space of the parent container.
208    pub fn cell_size(mut self, size: f32) -> Self {
209        self.cell_size = Some(size);
210        self
211    }
212
213    /// Message shown in the win banner drawn over the board once
214    /// [`GameStatus::Won`] is reached. Defaults to `"Solved!"`. The crate
215    /// ships no translations; embedding apps pass their own.
216    pub fn win_message(mut self, message: impl Into<String>) -> Self {
217        self.win_message = Some(message.into());
218        self
219    }
220
221    /// Color for arrows that point at an occupied node. Defaults to the
222    /// theme's strong text color.
223    pub fn satisfied_color(mut self, color: Color32) -> Self {
224        self.satisfied_color = Some(color);
225        self
226    }
227
228    /// Color for arrows that point at an empty node or off the board.
229    /// Defaults to the theme's error color — the accent the player reads
230    /// as "this piece still needs to move".
231    pub fn unsatisfied_color(mut self, color: Color32) -> Self {
232        self.unsatisfied_color = Some(color);
233        self
234    }
235
236    /// Whether the widget responds to input at all. Set to `false` to
237    /// render the board read-only. Defaults to `true`.
238    pub fn interactive(mut self, interactive: bool) -> Self {
239        self.interactive = interactive;
240        self
241    }
242}
243
244impl Widget for HexwebWidget<'_> {
245    fn ui(self, ui: &mut Ui) -> Response {
246        let Self {
247            game,
248            cell_size,
249            win_message,
250            interactive,
251            satisfied_color,
252            unsatisfied_color,
253        } = self;
254
255        let cell = cell_size.unwrap_or_else(|| fit_cell_size(game, ui.available_size()));
256        let total_size = content_size(game, cell);
257
258        let sense = if interactive {
259            Sense::click_and_drag()
260        } else {
261            Sense::hover()
262        };
263        let (response, painter) = ui.allocate_painter(total_size, sense);
264        let geometry = Geometry::new(response.rect.min, cell, game);
265
266        // ── Input ────────────────────────────────────────────────────────
267        // The dragged (or selected) piece is stashed in egui's per-widget
268        // temp memory so a gesture spans frames without the game holding
269        // any presentation state.
270        let drag_id = response.id.with("hexweb_drag_piece");
271        let select_id = response.id.with("hexweb_selected_piece");
272        let can_play = interactive && game.status() == GameStatus::InProgress;
273        let pointer = response.interact_pointer_pos();
274        let mut dragging: Option<PieceId> = ui.ctx().data(|d| d.get_temp(drag_id));
275
276        if can_play {
277            if response.clicked() {
278                // A stationary press+release never fires `drag_started()`,
279                // so the click path owns both selection and click-to-move.
280                let hit = pointer.and_then(|pos| geometry.node_at(game, pos));
281                let hit_piece = hit.and_then(|node| game.piece_at(node));
282                if let Some(piece) = hit_piece {
283                    let already =
284                        ui.ctx().data(|d| d.get_temp::<PieceId>(select_id)) == Some(piece);
285                    ui.ctx().data_mut(|d| {
286                        if already {
287                            d.remove_temp::<PieceId>(select_id);
288                        } else {
289                            d.insert_temp(select_id, piece);
290                        }
291                    });
292                } else if let Some(node) = hit {
293                    if let Some(selected) = ui.ctx().data(|d| d.get_temp::<PieceId>(select_id)) {
294                        if game.move_piece(selected, node) {
295                            ui.ctx().data_mut(|d| d.remove_temp::<PieceId>(select_id));
296                        }
297                    }
298                } else {
299                    ui.ctx().data_mut(|d| d.remove_temp::<PieceId>(select_id));
300                }
301            } else if response.drag_started() {
302                // Hit-test where the press started, not where the pointer
303                // is now: the first move event can already have crossed
304                // into a neighboring node's cell, and grabbing whatever
305                // piece happens to be under the *current* pointer would
306                // steal the wrong piece on a fast flick.
307                let grabbed = ui
308                    .input(|i| i.pointer.press_origin())
309                    .and_then(|pos| geometry.node_at(game, pos))
310                    .and_then(|node| game.piece_at(node));
311                if let Some(piece) = grabbed {
312                    ui.ctx().data_mut(|d| {
313                        d.insert_temp(drag_id, piece);
314                        d.remove_temp::<PieceId>(select_id);
315                    });
316                    dragging = Some(piece);
317                }
318            }
319        }
320
321        // Where the dragged piece would land right now, if anywhere.
322        let drag_target = if can_play {
323            dragging.and_then(|piece| {
324                pointer
325                    .and_then(|pos| geometry.node_at(game, pos))
326                    .filter(|&node| can_drop_at(game, piece, node))
327            })
328        } else {
329            None
330        };
331
332        if response.drag_stopped() {
333            if let Some(piece) = ui.ctx().data_mut(|d| d.remove_temp::<PieceId>(drag_id)) {
334                if let Some(node) = pointer
335                    .and_then(|pos| geometry.node_at(game, pos))
336                    .filter(|&node| can_drop_at(game, piece, node))
337                {
338                    match game.piece_at(node) {
339                        Some(other) => {
340                            game.swap_pieces(piece, other);
341                        }
342                        None => {
343                            game.move_piece(piece, node);
344                        }
345                    }
346                }
347            }
348            dragging = None;
349        }
350
351        if can_play {
352            if dragging.is_some() {
353                ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
354            } else if let Some(node) = response
355                .hover_pos()
356                .and_then(|pos| geometry.node_at(game, pos))
357            {
358                if game.piece_at(node).is_some() {
359                    ui.ctx().set_cursor_icon(CursorIcon::Grab);
360                } else if ui
361                    .ctx()
362                    .data(|d| d.get_temp::<PieceId>(select_id).is_some())
363                {
364                    ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
365                }
366            }
367        }
368
369        // ── Painting ─────────────────────────────────────────────────────
370        let visuals = ui.visuals();
371        let satisfied_color = satisfied_color.unwrap_or(visuals.strong_text_color());
372        let unsatisfied_color = unsatisfied_color.unwrap_or(visuals.error_fg_color);
373
374        let (piece_fill, piece_edge) = if visuals.dark_mode {
375            (Color32::from_gray(0x5C), Color32::from_gray(0x82))
376        } else {
377            (Color32::from_gray(0xC9), Color32::from_gray(0x8E))
378        };
379        let socket_fill = visuals.extreme_bg_color;
380        let socket_edge = visuals.widgets.noninteractive.bg_stroke.color;
381
382        // Sockets first: every node shows one, pieces draw over them.
383        for node in game.nodes() {
384            painter.add(Shape::convex_polygon(
385                hex_corners(geometry.center(node), cell),
386                socket_fill,
387                Stroke::new(1.0, socket_edge),
388            ));
389        }
390
391        // Selection ring for the click-to-select fallback.
392        if can_play && dragging.is_none() {
393            if let Some(selected) = ui.ctx().data(|d| d.get_temp::<PieceId>(select_id)) {
394                if let Some(node) = game.node_of(selected) {
395                    painter.circle_stroke(
396                        geometry.center(&game.nodes()[node]),
397                        cell * RING_RADIUS,
398                        Stroke::new(cell * 0.075, satisfied_color),
399                    );
400                }
401            }
402        }
403
404        // Pieces and their arrows, on top of everything.
405        for piece in 0..game.piece_count() {
406            let Some(node) = game.node_of(piece) else {
407                continue;
408            };
409            let center = geometry.center(&game.nodes()[node]);
410            let in_hand = dragging == Some(piece);
411            let alpha = if in_hand { 0.35 } else { 1.0 };
412            let arrows = game.pieces()[piece].arrows;
413            let unsatisfied = game.unsatisfied_arrows(piece);
414
415            painter.add(Shape::convex_polygon(
416                hex_corners(center, cell * PIECE_RADIUS),
417                piece_fill.gamma_multiply(alpha),
418                Stroke::new(cell * 0.05, piece_edge.gamma_multiply(alpha)),
419            ));
420            for dir in arrows.iter() {
421                let color = if unsatisfied.contains(dir) {
422                    unsatisfied_color
423                } else {
424                    satisfied_color
425                };
426                draw_arrow(
427                    &painter,
428                    center,
429                    dir,
430                    cell * PIECE_RADIUS,
431                    Stroke::new(cell * ARROW_STROKE, color.gamma_multiply(alpha)),
432                );
433            }
434        }
435
436        // Landing rings while a piece is in the air: a faint ring on every
437        // node the drop could land on (empty ones, and pieces that would be
438        // swapped), a strong one on the node it would land on right now.
439        // Painted over the pieces, so a ring on an occupied node stays
440        // visible; the radius clears the arrow tips.
441        if can_play {
442            if let Some(dragged) = dragging {
443                for (id, node) in game.nodes().iter().enumerate() {
444                    if !can_drop_at(game, dragged, id) {
445                        continue;
446                    }
447                    let faint = if game.piece_at(id).is_some() {
448                        piece_edge
449                    } else {
450                        piece_fill
451                    };
452                    let stroke = if drag_target == Some(id) {
453                        Stroke::new(cell * 0.10, satisfied_color)
454                    } else {
455                        Stroke::new(cell * 0.05, faint)
456                    };
457                    painter.circle_stroke(geometry.center(node), cell * RING_RADIUS, stroke);
458                }
459            }
460        }
461
462        // Win banner, drawn last so it sits on top of everything else.
463        if game.status() == GameStatus::Won {
464            let message = win_message.unwrap_or_else(|| "Solved!".to_owned());
465            let font = FontId::proportional((cell * 0.6).clamp(14.0, 40.0));
466            let galley = painter.layout_no_wrap(message, font, satisfied_color);
467            // Hugging the top edge rather than centred: the fully satisfied
468            // ring of arrows is the reward for solving the board, and a
469            // banner in the middle of it covers exactly the part worth
470            // looking at.
471            let size = galley.size() + Vec2::new(cell, cell * 0.6);
472            let banner = Rect::from_center_size(
473                Pos2::new(
474                    response.rect.center().x,
475                    response.rect.min.y + size.y * 0.5 + cell * 0.15,
476                ),
477                size,
478            );
479            painter.rect_filled(banner, CornerRadius::same(6), visuals.window_fill);
480            painter.rect_stroke(
481                banner,
482                CornerRadius::same(6),
483                Stroke::new(1.0, satisfied_color),
484                StrokeKind::Inside,
485            );
486            painter.galley(
487                banner.center() - galley.size() * 0.5,
488                galley,
489                Color32::PLACEHOLDER,
490            );
491        }
492
493        response
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::game::{build_nodes, random_symmetric_board, Arrows, Params, Piece};
501
502    /// Beginner-shaped board: 8 nodes, 6 pieces, deterministic per seed.
503    fn preset_game(nodes: usize, pieces: usize, seed: u64) -> HexwebGame {
504        HexwebGame::random(
505            Params {
506                nodes: random_symmetric_board(nodes, seed),
507                pieces,
508                min_arrows: 2,
509                max_arrows: 4,
510            },
511            seed,
512        )
513    }
514
515    #[test]
516    fn dir_angles_match_the_pixel_deltas() {
517        for dir in Dir::ALL {
518            let (dq, dr) = dir.delta();
519            let pixel = Vec2::new(1.5 * dq as f32, SQRT_3 * (dr as f32 + 0.5 * dq as f32));
520            let angled = Vec2::angled(dir_angle(dir));
521            assert!(
522                (pixel.normalized() - angled).length() < 1e-4,
523                "{dir:?} paints toward {angled:?}, lattice delta is {pixel:?}"
524            );
525        }
526    }
527
528    #[test]
529    fn content_size_and_fit_round_trip() {
530        let game = preset_game(12, 9, 7);
531        let size = content_size(&game, 10.0);
532        assert!((fit_cell_size(&game, size) - 10.0).abs() < 1e-4);
533        // The board is not a square: the fit is bounded by the tighter side.
534        assert!(size.x != size.y);
535        assert!(fit_cell_size(&game, Vec2::splat(20.0)) >= 4.0);
536    }
537
538    #[test]
539    fn node_at_round_trips_through_every_center() {
540        let game = preset_game(16, 12, 3);
541        let geometry = Geometry::new(Pos2::ZERO, 20.0, &game);
542        for (id, node) in game.nodes().iter().enumerate() {
543            assert_eq!(geometry.node_at(&game, geometry.center(node)), Some(id));
544            // A small offset stays within the node's cell.
545            let near = geometry.center(node) + Vec2::new(6.0, -4.0);
546            assert_eq!(geometry.node_at(&game, near), Some(id));
547        }
548        assert_eq!(
549            geometry.node_at(&game, Pos2::new(-999.0, -999.0)),
550            None,
551            "far away from the board there is nothing to hit"
552        );
553    }
554
555    const TEST_CELL: f32 = 24.0;
556
557    /// Runs the widget in a real `egui` pass so clicks and drags are
558    /// exercised end to end: the click/drag split, the temp-memory gesture
559    /// state, and the drop logic only behave correctly through egui's own
560    /// pointer machinery, which a plain method call can't check.
561    struct Harness {
562        ctx: egui::Context,
563        game: HexwebGame,
564        rect: Rect,
565        time: f64,
566    }
567
568    impl Harness {
569        fn new(game: HexwebGame) -> Self {
570            let mut harness = Self {
571                ctx: egui::Context::default(),
572                game,
573                rect: Rect::ZERO,
574                time: 0.0,
575            };
576            // The first pass places the board.
577            harness.pass(Vec::new());
578            harness
579        }
580
581        fn pass(&mut self, events: Vec<egui::Event>) -> Rect {
582            self.time += 1.0 / 60.0;
583            let input = egui::RawInput {
584                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))),
585                events,
586                time: Some(self.time),
587                ..Default::default()
588            };
589            let game = &mut self.game;
590            let rect = std::cell::Cell::new(Rect::ZERO);
591            let _ = self.ctx.run_ui(input, |ui| {
592                let response = ui.add(HexwebWidget::new(game).cell_size(TEST_CELL));
593                rect.set(response.rect);
594            });
595            self.rect = rect.get();
596            self.rect
597        }
598
599        fn geometry(&self) -> Geometry {
600            Geometry::new(self.rect.min, TEST_CELL, &self.game)
601        }
602
603        fn center(&self, node: NodeId) -> Pos2 {
604            self.geometry().center(&self.game.nodes()[node])
605        }
606
607        fn press(&mut self, pos: Pos2) {
608            self.pass(vec![
609                egui::Event::PointerMoved(pos),
610                egui::Event::PointerButton {
611                    pos,
612                    button: egui::PointerButton::Primary,
613                    pressed: true,
614                    modifiers: Default::default(),
615                },
616            ]);
617        }
618
619        fn drag(&mut self, pos: Pos2) {
620            self.pass(vec![egui::Event::PointerMoved(pos)]);
621        }
622
623        fn release(&mut self, pos: Pos2) {
624            self.pass(vec![
625                egui::Event::PointerMoved(pos),
626                egui::Event::PointerButton {
627                    pos,
628                    button: egui::PointerButton::Primary,
629                    pressed: false,
630                    modifiers: Default::default(),
631                },
632            ]);
633        }
634
635        fn click(&mut self, pos: Pos2) {
636            self.press(pos);
637            self.release(pos);
638        }
639    }
640
641    fn empty_node(game: &HexwebGame) -> NodeId {
642        (0..game.node_count())
643            .find(|&node| game.piece_at(node).is_none())
644            .expect("presets always leave empty nodes")
645    }
646
647    fn occupied_node(game: &HexwebGame) -> NodeId {
648        (0..game.node_count())
649            .find(|&node| game.piece_at(node).is_some())
650            .expect("presets always have pieces")
651    }
652
653    #[test]
654    fn click_piece_then_empty_node_moves_it() {
655        let mut harness = Harness::new(preset_game(8, 6, 11));
656        let from = occupied_node(&harness.game);
657        let piece = harness.game.piece_at(from).unwrap();
658        let target = empty_node(&harness.game);
659
660        harness.click(harness.center(from));
661        assert_eq!(harness.game.node_of(piece), Some(from), "select only");
662        harness.click(harness.center(target));
663
664        assert_eq!(harness.game.node_of(piece), Some(target));
665        assert_eq!(harness.game.moves(), 1);
666    }
667
668    #[test]
669    fn clicking_an_empty_node_without_a_selection_does_nothing() {
670        let mut harness = Harness::new(preset_game(8, 6, 11));
671        harness.click(harness.center(empty_node(&harness.game)));
672        assert_eq!(harness.game.moves(), 0);
673    }
674
675    #[test]
676    fn clicking_the_selected_piece_again_deselects_it() {
677        let mut harness = Harness::new(preset_game(8, 6, 11));
678        let from = occupied_node(&harness.game);
679        harness.click(harness.center(from));
680        harness.click(harness.center(from));
681        harness.click(harness.center(empty_node(&harness.game)));
682        assert_eq!(harness.game.moves(), 0);
683    }
684
685    #[test]
686    fn dragging_a_piece_to_an_empty_node_moves_it() {
687        let mut harness = Harness::new(preset_game(12, 9, 5));
688        let from = occupied_node(&harness.game);
689        let piece = harness.game.piece_at(from).unwrap();
690        let target = empty_node(&harness.game);
691
692        harness.press(harness.center(from));
693        // Cross egui's drag radius so this becomes a drag, not a click.
694        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
695        harness.drag(harness.center(target));
696        harness.release(harness.center(target));
697
698        assert_eq!(harness.game.node_of(piece), Some(target));
699        assert_eq!(harness.game.moves(), 1);
700    }
701
702    #[test]
703    fn dropping_off_the_board_snaps_back() {
704        let mut harness = Harness::new(preset_game(12, 9, 5));
705        let from = occupied_node(&harness.game);
706        let piece = harness.game.piece_at(from).unwrap();
707
708        harness.press(harness.center(from));
709        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
710        harness.release(Pos2::new(-100.0, -100.0));
711
712        assert_eq!(harness.game.node_of(piece), Some(from));
713        assert_eq!(harness.game.moves(), 0);
714    }
715
716    /// A node (other than `except`) holding a piece the given one may swap
717    /// with, if any.
718    fn swappable_node(game: &HexwebGame, piece: PieceId, except: NodeId) -> Option<NodeId> {
719        (0..game.node_count()).find(|&node| match game.piece_at(node) {
720            Some(other) => node != except && game.can_swap(piece, other),
721            None => false,
722        })
723    }
724
725    #[test]
726    fn dropping_on_an_occupied_node_swaps_the_pieces() {
727        let mut harness = Harness::new(preset_game(12, 9, 5));
728        let from = occupied_node(&harness.game);
729        let piece = harness.game.piece_at(from).unwrap();
730        let taken = swappable_node(&harness.game, piece, from)
731            .expect("some other piece carries different arrows");
732        let taken_piece = harness.game.piece_at(taken).unwrap();
733
734        harness.press(harness.center(from));
735        // Cross egui's drag radius so this becomes a drag, not a click.
736        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
737        harness.release(harness.center(taken));
738
739        assert_eq!(harness.game.node_of(piece), Some(taken));
740        assert_eq!(harness.game.node_of(taken_piece), Some(from));
741        assert_eq!(harness.game.moves(), 1);
742    }
743
744    #[test]
745    fn releasing_on_the_dragged_pieces_own_node_snaps_back() {
746        let mut harness = Harness::new(preset_game(12, 9, 5));
747        let from = occupied_node(&harness.game);
748        let piece = harness.game.piece_at(from).unwrap();
749
750        harness.press(harness.center(from));
751        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
752        harness.release(harness.center(from));
753
754        assert_eq!(harness.game.node_of(piece), Some(from));
755        assert_eq!(harness.game.moves(), 0);
756    }
757
758    /// Two identical pieces on a 4-node line, far enough apart that both
759    /// arrows point at empty nodes. Any seed does; this keeps the
760    /// identical-pair test independent of the presets.
761    fn identical_pair_game() -> HexwebGame {
762        let (nodes, _) = build_nodes(&[(0, 0), (0, -1), (0, 1), (0, 2)]);
763        let pieces = vec![
764            Piece {
765                arrows: Arrows::from_dir(Dir::Up),
766            },
767            Piece {
768                arrows: Arrows::from_dir(Dir::Up),
769            },
770        ];
771        let cell_piece = vec![Some(0), None, None, Some(1)];
772        HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece)
773    }
774
775    #[test]
776    fn dropping_on_an_identical_piece_is_free() {
777        let mut harness = Harness::new(identical_pair_game());
778        let from = occupied_node(&harness.game);
779        let piece = harness.game.piece_at(from).unwrap();
780        let taken = (0..harness.game.node_count())
781            .find(|&node| node != from && harness.game.piece_at(node).is_some())
782            .expect("the second piece exists");
783
784        harness.press(harness.center(from));
785        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
786        harness.release(harness.center(taken));
787
788        // The pieces trade places, but they are interchangeable, so the
789        // swap costs no move.
790        assert_eq!(harness.game.node_of(piece), Some(taken));
791        assert_eq!(harness.game.moves(), 0);
792    }
793
794    /// Plays the board's unique solution through legal moves. Pieces can
795    /// sit on each other's targets, so cycles are broken by parking a
796    /// piece on any node no solution needs; there is always at least one
797    /// empty node to park on.
798    fn play_solution(game: &mut HexwebGame) {
799        let Some(solution) = game.solution() else {
800            panic!("test boards always have a solution");
801        };
802        let mut placed: Vec<bool> = (0..game.piece_count())
803            .map(|piece| game.node_of(piece) == Some(solution[piece]))
804            .collect();
805        while placed.iter().any(|&done| !done) {
806            // An unplaced piece whose target is empty can move right away.
807            let step = (0..game.piece_count())
808                .filter(|&piece| !placed[piece])
809                .find_map(|piece| {
810                    game.piece_at(solution[piece])
811                        .is_none()
812                        .then_some((piece, solution[piece]))
813                })
814                .or_else(|| {
815                    // Otherwise every unplaced target is occupied: park any
816                    // unplaced piece on a node no piece targets.
817                    let piece = (0..game.piece_count()).find(|&piece| !placed[piece])?;
818                    let node =
819                        (0..game.node_count()).find(|&node| game.piece_at(node).is_none())?;
820                    Some((piece, node))
821                })
822                .expect("a solvable board is always finishable");
823            let (piece, node) = step;
824            assert!(game.move_piece(piece, node));
825            placed[piece] = game.node_of(piece) == Some(solution[piece]);
826        }
827    }
828
829    #[test]
830    fn a_won_board_ignores_input() {
831        let mut harness = Harness::new(preset_game(8, 6, 2));
832        play_solution(&mut harness.game);
833        assert_eq!(harness.game.status(), GameStatus::Won);
834        let moves = harness.game.moves();
835
836        let from = occupied_node(&harness.game);
837        let target = empty_node(&harness.game);
838        let taken = (0..harness.game.node_count())
839            .find(|&node| node != from && harness.game.piece_at(node).is_some())
840            .expect("a second occupied node exists");
841        // Clicks no longer select or move...
842        harness.click(harness.center(from));
843        harness.click(harness.center(target));
844        // ...and drags no longer pick anything up, neither onto an empty
845        // node nor onto another piece to swap.
846        harness.press(harness.center(from));
847        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
848        harness.release(harness.center(target));
849        harness.press(harness.center(from));
850        harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
851        harness.release(harness.center(taken));
852
853        assert_eq!(harness.game.moves(), moves);
854    }
855}