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