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> {
184 game: &'a mut HexwebGame,
185 cell_size: Option<f32>,
186 win_message: Option<String>,
187 interactive: bool,
188 satisfied_color: Option<Color32>,
189 unsatisfied_color: Option<Color32>,
190}
191
192impl<'a> HexwebWidget<'a> {
193 pub fn new(game: &'a mut HexwebGame) -> Self {
194 Self {
195 game,
196 cell_size: None,
197 win_message: None,
198 interactive: true,
199 satisfied_color: None,
200 unsatisfied_color: None,
201 }
202 }
203
204 pub fn cell_size(mut self, size: f32) -> Self {
208 self.cell_size = Some(size);
209 self
210 }
211
212 pub fn win_message(mut self, message: impl Into<String>) -> Self {
216 self.win_message = Some(message.into());
217 self
218 }
219
220 pub fn satisfied_color(mut self, color: Color32) -> Self {
223 self.satisfied_color = Some(color);
224 self
225 }
226
227 pub fn unsatisfied_color(mut self, color: Color32) -> Self {
231 self.unsatisfied_color = Some(color);
232 self
233 }
234
235 pub fn interactive(mut self, interactive: bool) -> Self {
238 self.interactive = interactive;
239 self
240 }
241}
242
243impl Widget for HexwebWidget<'_> {
244 fn ui(self, ui: &mut Ui) -> Response {
245 let Self {
246 game,
247 cell_size,
248 win_message,
249 interactive,
250 satisfied_color,
251 unsatisfied_color,
252 } = self;
253
254 let cell = cell_size.unwrap_or_else(|| fit_cell_size(game, ui.available_size()));
255 let total_size = content_size(game, cell);
256
257 let sense = if interactive {
258 Sense::drag()
259 } else {
260 Sense::hover()
261 };
262 let (response, painter) = ui.allocate_painter(total_size, sense);
263 let geometry = Geometry::new(response.rect.min, cell, game);
264
265 let drag_id = response.id.with("hexweb_drag_piece");
270 let can_play = interactive && game.status() == GameStatus::InProgress;
271 let pointer = response.interact_pointer_pos();
272 let mut dragging: Option<PieceId> = ui.ctx().data(|d| d.get_temp(drag_id));
273
274 if can_play && response.drag_started() {
275 let grabbed = ui
281 .input(|i| i.pointer.press_origin())
282 .and_then(|pos| geometry.node_at(game, pos))
283 .and_then(|node| game.piece_at(node));
284 if let Some(piece) = grabbed {
285 ui.ctx().data_mut(|d| d.insert_temp(drag_id, piece));
286 dragging = Some(piece);
287 }
288 }
289
290 let drag_target = if can_play {
292 dragging.and_then(|piece| {
293 pointer
294 .and_then(|pos| geometry.node_at(game, pos))
295 .filter(|&node| can_drop_at(game, piece, node))
296 })
297 } else {
298 None
299 };
300
301 if response.drag_stopped() {
302 if let Some(piece) = ui.ctx().data_mut(|d| d.remove_temp::<PieceId>(drag_id)) {
303 if let Some(node) = pointer
304 .and_then(|pos| geometry.node_at(game, pos))
305 .filter(|&node| can_drop_at(game, piece, node))
306 {
307 match game.piece_at(node) {
308 Some(other) => {
309 game.swap_pieces(piece, other);
310 }
311 None => {
312 game.move_piece(piece, node);
313 }
314 }
315 }
316 }
317 dragging = None;
318 }
319
320 if can_play {
321 if dragging.is_some() {
322 ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
323 } else if let Some(node) = response
324 .hover_pos()
325 .and_then(|pos| geometry.node_at(game, pos))
326 {
327 if game.piece_at(node).is_some() {
328 ui.ctx().set_cursor_icon(CursorIcon::Grab);
329 }
330 }
331 }
332
333 let visuals = ui.visuals();
335 let satisfied_color = satisfied_color.unwrap_or(visuals.strong_text_color());
336 let unsatisfied_color = unsatisfied_color.unwrap_or(visuals.error_fg_color);
337
338 let (piece_fill, piece_edge) = if visuals.dark_mode {
339 (Color32::from_gray(0x5C), Color32::from_gray(0x82))
340 } else {
341 (Color32::from_gray(0xC9), Color32::from_gray(0x8E))
342 };
343 let socket_fill = visuals.extreme_bg_color;
344 let socket_edge = visuals.widgets.noninteractive.bg_stroke.color;
345
346 for node in game.nodes() {
348 painter.add(Shape::convex_polygon(
349 hex_corners(geometry.center(node), cell),
350 socket_fill,
351 Stroke::new(1.0, socket_edge),
352 ));
353 }
354
355 for piece in 0..game.piece_count() {
357 let Some(node) = game.node_of(piece) else {
358 continue;
359 };
360 let center = geometry.center(&game.nodes()[node]);
361 let in_hand = dragging == Some(piece);
362 let alpha = if in_hand { 0.35 } else { 1.0 };
363 let arrows = game.pieces()[piece].arrows;
364 let unsatisfied = game.unsatisfied_arrows(piece);
365
366 painter.add(Shape::convex_polygon(
367 hex_corners(center, cell * PIECE_RADIUS),
368 piece_fill.gamma_multiply(alpha),
369 Stroke::new(cell * 0.05, piece_edge.gamma_multiply(alpha)),
370 ));
371 for dir in arrows.iter() {
372 let color = if unsatisfied.contains(dir) {
373 unsatisfied_color
374 } else {
375 satisfied_color
376 };
377 draw_arrow(
378 &painter,
379 center,
380 dir,
381 cell * PIECE_RADIUS,
382 Stroke::new(cell * ARROW_STROKE, color.gamma_multiply(alpha)),
383 );
384 }
385 }
386
387 if can_play {
393 if let Some(dragged) = dragging {
394 for (id, node) in game.nodes().iter().enumerate() {
395 if !can_drop_at(game, dragged, id) {
396 continue;
397 }
398 let faint = if game.piece_at(id).is_some() {
399 piece_edge
400 } else {
401 piece_fill
402 };
403 let stroke = if drag_target == Some(id) {
404 Stroke::new(cell * 0.10, satisfied_color)
405 } else {
406 Stroke::new(cell * 0.05, faint)
407 };
408 painter.circle_stroke(geometry.center(node), cell * RING_RADIUS, stroke);
409 }
410 }
411 }
412
413 if game.status() == GameStatus::Won {
415 let message = win_message.unwrap_or_else(|| "Solved!".to_owned());
416 let font = FontId::proportional((cell * 0.6).clamp(14.0, 40.0));
417 let galley = painter.layout_no_wrap(message, font, satisfied_color);
418 let size = galley.size() + Vec2::new(cell, cell * 0.6);
423 let banner = Rect::from_center_size(
424 Pos2::new(
425 response.rect.center().x,
426 response.rect.min.y + size.y * 0.5 + cell * 0.15,
427 ),
428 size,
429 );
430 painter.rect_filled(banner, CornerRadius::same(6), visuals.window_fill);
431 painter.rect_stroke(
432 banner,
433 CornerRadius::same(6),
434 Stroke::new(1.0, satisfied_color),
435 StrokeKind::Inside,
436 );
437 painter.galley(
438 banner.center() - galley.size() * 0.5,
439 galley,
440 Color32::PLACEHOLDER,
441 );
442 }
443
444 response
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use crate::game::{build_nodes, random_symmetric_board, Arrows, Params, Piece};
452
453 fn preset_game(nodes: usize, pieces: usize, seed: u64) -> HexwebGame {
455 HexwebGame::random(
456 Params {
457 nodes: random_symmetric_board(nodes, seed),
458 pieces,
459 min_arrows: 2,
460 max_arrows: 4,
461 },
462 seed,
463 )
464 }
465
466 #[test]
467 fn dir_angles_match_the_pixel_deltas() {
468 for dir in Dir::ALL {
469 let (dq, dr) = dir.delta();
470 let pixel = Vec2::new(1.5 * dq as f32, SQRT_3 * (dr as f32 + 0.5 * dq as f32));
471 let angled = Vec2::angled(dir_angle(dir));
472 assert!(
473 (pixel.normalized() - angled).length() < 1e-4,
474 "{dir:?} paints toward {angled:?}, lattice delta is {pixel:?}"
475 );
476 }
477 }
478
479 #[test]
480 fn content_size_and_fit_round_trip() {
481 let game = preset_game(12, 9, 7);
482 let size = content_size(&game, 10.0);
483 assert!((fit_cell_size(&game, size) - 10.0).abs() < 1e-4);
484 assert!(size.x != size.y);
486 assert!(fit_cell_size(&game, Vec2::splat(20.0)) >= 4.0);
487 }
488
489 #[test]
490 fn node_at_round_trips_through_every_center() {
491 let game = preset_game(16, 12, 3);
492 let geometry = Geometry::new(Pos2::ZERO, 20.0, &game);
493 for (id, node) in game.nodes().iter().enumerate() {
494 assert_eq!(geometry.node_at(&game, geometry.center(node)), Some(id));
495 let near = geometry.center(node) + Vec2::new(6.0, -4.0);
497 assert_eq!(geometry.node_at(&game, near), Some(id));
498 }
499 assert_eq!(
500 geometry.node_at(&game, Pos2::new(-999.0, -999.0)),
501 None,
502 "far away from the board there is nothing to hit"
503 );
504 }
505
506 const TEST_CELL: f32 = 24.0;
507
508 struct Harness {
513 ctx: egui::Context,
514 game: HexwebGame,
515 rect: Rect,
516 time: f64,
517 }
518
519 impl Harness {
520 fn new(game: HexwebGame) -> Self {
521 let mut harness = Self {
522 ctx: egui::Context::default(),
523 game,
524 rect: Rect::ZERO,
525 time: 0.0,
526 };
527 harness.pass(Vec::new());
529 harness
530 }
531
532 fn pass(&mut self, events: Vec<egui::Event>) -> Rect {
533 self.time += 1.0 / 60.0;
534 let input = egui::RawInput {
535 screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))),
536 events,
537 time: Some(self.time),
538 ..Default::default()
539 };
540 let game = &mut self.game;
541 let rect = std::cell::Cell::new(Rect::ZERO);
542 let mut output = self.ctx.run_ui(input, |ui| {
543 let response = ui.add(HexwebWidget::new(game).cell_size(TEST_CELL));
544 rect.set(response.rect);
545 });
546 output.textures_delta.clear();
549 self.rect = rect.get();
550 self.rect
551 }
552
553 fn geometry(&self) -> Geometry {
554 Geometry::new(self.rect.min, TEST_CELL, &self.game)
555 }
556
557 fn center(&self, node: NodeId) -> Pos2 {
558 self.geometry().center(&self.game.nodes()[node])
559 }
560
561 fn press(&mut self, pos: Pos2) {
562 self.pass(vec![
563 egui::Event::PointerMoved(pos),
564 egui::Event::PointerButton {
565 pos,
566 button: egui::PointerButton::Primary,
567 pressed: true,
568 modifiers: Default::default(),
569 },
570 ]);
571 }
572
573 fn drag(&mut self, pos: Pos2) {
574 self.pass(vec![egui::Event::PointerMoved(pos)]);
575 }
576
577 fn release(&mut self, pos: Pos2) {
578 self.pass(vec![
579 egui::Event::PointerMoved(pos),
580 egui::Event::PointerButton {
581 pos,
582 button: egui::PointerButton::Primary,
583 pressed: false,
584 modifiers: Default::default(),
585 },
586 ]);
587 }
588 }
589
590 fn empty_node(game: &HexwebGame) -> NodeId {
591 (0..game.node_count())
592 .find(|&node| game.piece_at(node).is_none())
593 .expect("presets always leave empty nodes")
594 }
595
596 fn occupied_node(game: &HexwebGame) -> NodeId {
597 (0..game.node_count())
598 .find(|&node| game.piece_at(node).is_some())
599 .expect("presets always have pieces")
600 }
601
602 #[test]
603 fn dragging_a_piece_to_an_empty_node_moves_it() {
604 let mut harness = Harness::new(preset_game(12, 9, 5));
605 let from = occupied_node(&harness.game);
606 let piece = harness.game.piece_at(from).unwrap();
607 let target = empty_node(&harness.game);
608
609 harness.press(harness.center(from));
610 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
612 harness.drag(harness.center(target));
613 harness.release(harness.center(target));
614
615 assert_eq!(harness.game.node_of(piece), Some(target));
616 assert_eq!(harness.game.moves(), 1);
617 }
618
619 #[test]
620 fn dropping_off_the_board_snaps_back() {
621 let mut harness = Harness::new(preset_game(12, 9, 5));
622 let from = occupied_node(&harness.game);
623 let piece = harness.game.piece_at(from).unwrap();
624
625 harness.press(harness.center(from));
626 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
627 harness.release(Pos2::new(-100.0, -100.0));
628
629 assert_eq!(harness.game.node_of(piece), Some(from));
630 assert_eq!(harness.game.moves(), 0);
631 }
632
633 fn swappable_node(game: &HexwebGame, piece: PieceId, except: NodeId) -> Option<NodeId> {
636 (0..game.node_count()).find(|&node| match game.piece_at(node) {
637 Some(other) => node != except && game.can_swap(piece, other),
638 None => false,
639 })
640 }
641
642 #[test]
643 fn dropping_on_an_occupied_node_swaps_the_pieces() {
644 let mut harness = Harness::new(preset_game(12, 9, 5));
645 let from = occupied_node(&harness.game);
646 let piece = harness.game.piece_at(from).unwrap();
647 let taken = swappable_node(&harness.game, piece, from)
648 .expect("some other piece carries different arrows");
649 let taken_piece = harness.game.piece_at(taken).unwrap();
650
651 harness.press(harness.center(from));
652 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
654 harness.release(harness.center(taken));
655
656 assert_eq!(harness.game.node_of(piece), Some(taken));
657 assert_eq!(harness.game.node_of(taken_piece), Some(from));
658 assert_eq!(harness.game.moves(), 1);
659 }
660
661 #[test]
662 fn releasing_on_the_dragged_pieces_own_node_snaps_back() {
663 let mut harness = Harness::new(preset_game(12, 9, 5));
664 let from = occupied_node(&harness.game);
665 let piece = harness.game.piece_at(from).unwrap();
666
667 harness.press(harness.center(from));
668 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
669 harness.release(harness.center(from));
670
671 assert_eq!(harness.game.node_of(piece), Some(from));
672 assert_eq!(harness.game.moves(), 0);
673 }
674
675 fn identical_pair_game() -> HexwebGame {
679 let (nodes, _) = build_nodes(&[(0, 0), (0, -1), (0, 1), (0, 2)]);
680 let pieces = vec![
681 Piece {
682 arrows: Arrows::from_dir(Dir::Up),
683 },
684 Piece {
685 arrows: Arrows::from_dir(Dir::Up),
686 },
687 ];
688 let cell_piece = vec![Some(0), None, None, Some(1)];
689 HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece)
690 }
691
692 #[test]
693 fn dropping_on_an_identical_piece_is_free() {
694 let mut harness = Harness::new(identical_pair_game());
695 let from = occupied_node(&harness.game);
696 let piece = harness.game.piece_at(from).unwrap();
697 let taken = (0..harness.game.node_count())
698 .find(|&node| node != from && harness.game.piece_at(node).is_some())
699 .expect("the second piece exists");
700
701 harness.press(harness.center(from));
702 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
703 harness.release(harness.center(taken));
704
705 assert_eq!(harness.game.node_of(piece), Some(taken));
708 assert_eq!(harness.game.moves(), 0);
709 }
710
711 fn play_solution(game: &mut HexwebGame) {
716 let Some(solution) = game.solution() else {
717 panic!("test boards always have a solution");
718 };
719 let mut placed: Vec<bool> = (0..game.piece_count())
720 .map(|piece| game.node_of(piece) == Some(solution[piece]))
721 .collect();
722 while placed.iter().any(|&done| !done) {
723 let step = (0..game.piece_count())
725 .filter(|&piece| !placed[piece])
726 .find_map(|piece| {
727 game.piece_at(solution[piece])
728 .is_none()
729 .then_some((piece, solution[piece]))
730 })
731 .or_else(|| {
732 let piece = (0..game.piece_count()).find(|&piece| !placed[piece])?;
735 let node =
736 (0..game.node_count()).find(|&node| game.piece_at(node).is_none())?;
737 Some((piece, node))
738 })
739 .expect("a solvable board is always finishable");
740 let (piece, node) = step;
741 assert!(game.move_piece(piece, node));
742 placed[piece] = game.node_of(piece) == Some(solution[piece]);
743 }
744 }
745
746 #[test]
747 fn a_won_board_ignores_input() {
748 let mut harness = Harness::new(preset_game(8, 6, 2));
749 play_solution(&mut harness.game);
750 assert_eq!(harness.game.status(), GameStatus::Won);
751 let moves = harness.game.moves();
752
753 let from = occupied_node(&harness.game);
754 let target = empty_node(&harness.game);
755 let taken = (0..harness.game.node_count())
756 .find(|&node| node != from && harness.game.piece_at(node).is_some())
757 .expect("a second occupied node exists");
758 harness.press(harness.center(from));
761 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
762 harness.release(harness.center(target));
763 harness.press(harness.center(from));
764 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
765 harness.release(harness.center(taken));
766
767 assert_eq!(harness.game.moves(), moves);
768 }
769}