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 _ = 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 self.rect = rect.get();
547 self.rect
548 }
549
550 fn geometry(&self) -> Geometry {
551 Geometry::new(self.rect.min, TEST_CELL, &self.game)
552 }
553
554 fn center(&self, node: NodeId) -> Pos2 {
555 self.geometry().center(&self.game.nodes()[node])
556 }
557
558 fn press(&mut self, pos: Pos2) {
559 self.pass(vec![
560 egui::Event::PointerMoved(pos),
561 egui::Event::PointerButton {
562 pos,
563 button: egui::PointerButton::Primary,
564 pressed: true,
565 modifiers: Default::default(),
566 },
567 ]);
568 }
569
570 fn drag(&mut self, pos: Pos2) {
571 self.pass(vec![egui::Event::PointerMoved(pos)]);
572 }
573
574 fn release(&mut self, pos: Pos2) {
575 self.pass(vec![
576 egui::Event::PointerMoved(pos),
577 egui::Event::PointerButton {
578 pos,
579 button: egui::PointerButton::Primary,
580 pressed: false,
581 modifiers: Default::default(),
582 },
583 ]);
584 }
585 }
586
587 fn empty_node(game: &HexwebGame) -> NodeId {
588 (0..game.node_count())
589 .find(|&node| game.piece_at(node).is_none())
590 .expect("presets always leave empty nodes")
591 }
592
593 fn occupied_node(game: &HexwebGame) -> NodeId {
594 (0..game.node_count())
595 .find(|&node| game.piece_at(node).is_some())
596 .expect("presets always have pieces")
597 }
598
599 #[test]
600 fn dragging_a_piece_to_an_empty_node_moves_it() {
601 let mut harness = Harness::new(preset_game(12, 9, 5));
602 let from = occupied_node(&harness.game);
603 let piece = harness.game.piece_at(from).unwrap();
604 let target = empty_node(&harness.game);
605
606 harness.press(harness.center(from));
607 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
609 harness.drag(harness.center(target));
610 harness.release(harness.center(target));
611
612 assert_eq!(harness.game.node_of(piece), Some(target));
613 assert_eq!(harness.game.moves(), 1);
614 }
615
616 #[test]
617 fn dropping_off_the_board_snaps_back() {
618 let mut harness = Harness::new(preset_game(12, 9, 5));
619 let from = occupied_node(&harness.game);
620 let piece = harness.game.piece_at(from).unwrap();
621
622 harness.press(harness.center(from));
623 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
624 harness.release(Pos2::new(-100.0, -100.0));
625
626 assert_eq!(harness.game.node_of(piece), Some(from));
627 assert_eq!(harness.game.moves(), 0);
628 }
629
630 fn swappable_node(game: &HexwebGame, piece: PieceId, except: NodeId) -> Option<NodeId> {
633 (0..game.node_count()).find(|&node| match game.piece_at(node) {
634 Some(other) => node != except && game.can_swap(piece, other),
635 None => false,
636 })
637 }
638
639 #[test]
640 fn dropping_on_an_occupied_node_swaps_the_pieces() {
641 let mut harness = Harness::new(preset_game(12, 9, 5));
642 let from = occupied_node(&harness.game);
643 let piece = harness.game.piece_at(from).unwrap();
644 let taken = swappable_node(&harness.game, piece, from)
645 .expect("some other piece carries different arrows");
646 let taken_piece = harness.game.piece_at(taken).unwrap();
647
648 harness.press(harness.center(from));
649 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
651 harness.release(harness.center(taken));
652
653 assert_eq!(harness.game.node_of(piece), Some(taken));
654 assert_eq!(harness.game.node_of(taken_piece), Some(from));
655 assert_eq!(harness.game.moves(), 1);
656 }
657
658 #[test]
659 fn releasing_on_the_dragged_pieces_own_node_snaps_back() {
660 let mut harness = Harness::new(preset_game(12, 9, 5));
661 let from = occupied_node(&harness.game);
662 let piece = harness.game.piece_at(from).unwrap();
663
664 harness.press(harness.center(from));
665 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
666 harness.release(harness.center(from));
667
668 assert_eq!(harness.game.node_of(piece), Some(from));
669 assert_eq!(harness.game.moves(), 0);
670 }
671
672 fn identical_pair_game() -> HexwebGame {
676 let (nodes, _) = build_nodes(&[(0, 0), (0, -1), (0, 1), (0, 2)]);
677 let pieces = vec![
678 Piece {
679 arrows: Arrows::from_dir(Dir::Up),
680 },
681 Piece {
682 arrows: Arrows::from_dir(Dir::Up),
683 },
684 ];
685 let cell_piece = vec![Some(0), None, None, Some(1)];
686 HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece)
687 }
688
689 #[test]
690 fn dropping_on_an_identical_piece_is_free() {
691 let mut harness = Harness::new(identical_pair_game());
692 let from = occupied_node(&harness.game);
693 let piece = harness.game.piece_at(from).unwrap();
694 let taken = (0..harness.game.node_count())
695 .find(|&node| node != from && harness.game.piece_at(node).is_some())
696 .expect("the second piece exists");
697
698 harness.press(harness.center(from));
699 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
700 harness.release(harness.center(taken));
701
702 assert_eq!(harness.game.node_of(piece), Some(taken));
705 assert_eq!(harness.game.moves(), 0);
706 }
707
708 fn play_solution(game: &mut HexwebGame) {
713 let Some(solution) = game.solution() else {
714 panic!("test boards always have a solution");
715 };
716 let mut placed: Vec<bool> = (0..game.piece_count())
717 .map(|piece| game.node_of(piece) == Some(solution[piece]))
718 .collect();
719 while placed.iter().any(|&done| !done) {
720 let step = (0..game.piece_count())
722 .filter(|&piece| !placed[piece])
723 .find_map(|piece| {
724 game.piece_at(solution[piece])
725 .is_none()
726 .then_some((piece, solution[piece]))
727 })
728 .or_else(|| {
729 let piece = (0..game.piece_count()).find(|&piece| !placed[piece])?;
732 let node =
733 (0..game.node_count()).find(|&node| game.piece_at(node).is_none())?;
734 Some((piece, node))
735 })
736 .expect("a solvable board is always finishable");
737 let (piece, node) = step;
738 assert!(game.move_piece(piece, node));
739 placed[piece] = game.node_of(piece) == Some(solution[piece]);
740 }
741 }
742
743 #[test]
744 fn a_won_board_ignores_input() {
745 let mut harness = Harness::new(preset_game(8, 6, 2));
746 play_solution(&mut harness.game);
747 assert_eq!(harness.game.status(), GameStatus::Won);
748 let moves = harness.game.moves();
749
750 let from = occupied_node(&harness.game);
751 let target = empty_node(&harness.game);
752 let taken = (0..harness.game.node_count())
753 .find(|&node| node != from && harness.game.piece_at(node).is_some())
754 .expect("a second occupied node exists");
755 harness.press(harness.center(from));
758 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
759 harness.release(harness.center(target));
760 harness.press(harness.center(from));
761 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
762 harness.release(harness.center(taken));
763
764 assert_eq!(harness.game.moves(), moves);
765 }
766}