1use 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
25const SQRT_3: f32 = 1.732_050_8;
28
29const PIECE_RADIUS: f32 = 0.88;
32const ARROW_TIP: f32 = 0.62;
36const ARROW_BACK: f32 = 0.30;
37const ARROW_SPREAD: f32 = 0.17;
38const ARROW_STROKE: f32 = 0.085;
40const RING_RADIUS: f32 = 0.80;
44
45pub 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
55pub 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
65fn 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
75fn dir_angle(dir: Dir) -> f32 {
79 -TAU / 4.0 + TAU / 6.0 * dir as u8 as f32
80}
81
82fn 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
94fn 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
101fn 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
110fn 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#[derive(Clone, Copy)]
125struct Geometry {
126 origin: Pos2,
128 cell: f32,
130 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 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 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
171pub 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 pub fn cell_size(mut self, size: f32) -> Self {
209 self.cell_size = Some(size);
210 self
211 }
212
213 pub fn win_message(mut self, message: impl Into<String>) -> Self {
217 self.win_message = Some(message.into());
218 self
219 }
220
221 pub fn satisfied_color(mut self, color: Color32) -> Self {
224 self.satisfied_color = Some(color);
225 self
226 }
227
228 pub fn unsatisfied_color(mut self, color: Color32) -> Self {
233 self.unsatisfied_color = Some(color);
234 self
235 }
236
237 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(harness.game.node_of(piece), Some(taken));
710 assert_eq!(harness.game.moves(), 0);
711 }
712
713 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 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 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 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}