1use std::f32::consts::TAU;
15
16use egui::{
17 Color32, CornerRadius, CursorIcon, FontId, Pos2, Rect, Response, Sense, Shape, Stroke,
18 StrokeKind, Ui, Vec2, Widget,
19};
20
21use crate::game::{Dir, GameStatus, HexwebGame, Node, NodeId, PieceId};
22
23const SQRT_3: f32 = 1.732_050_8;
26
27const PIECE_RADIUS: f32 = 0.88;
30const ARROW_TIP: f32 = 0.62;
34const ARROW_BACK: f32 = 0.30;
35const ARROW_SPREAD: f32 = 0.17;
36const ARROW_STROKE: f32 = 0.085;
38const RING_RADIUS: f32 = 0.80;
42
43pub fn content_size(game: &HexwebGame, cell_size: f32) -> Vec2 {
49 let (min, max) = center_bounds(game, cell_size);
50 (max - min) + Vec2::new(2.0, SQRT_3) * cell_size
51}
52
53pub fn fit_cell_size(game: &HexwebGame, available: Vec2) -> f32 {
59 let unit = content_size(game, 1.0);
60 (available.x / unit.x).min(available.y / unit.y).max(4.0)
61}
62
63fn node_center(q: i32, r: i32, size: f32) -> Vec2 {
67 Vec2::new(
68 1.5 * size * q as f32,
69 SQRT_3 * size * (r as f32 + 0.5 * q as f32),
70 )
71}
72
73fn dir_angle(dir: Dir) -> f32 {
77 -TAU / 4.0 + TAU / 6.0 * dir as u8 as f32
78}
79
80fn center_bounds(game: &HexwebGame, size: f32) -> (Vec2, Vec2) {
82 let mut min = Vec2::splat(f32::INFINITY);
83 let mut max = Vec2::splat(f32::NEG_INFINITY);
84 for node in game.nodes() {
85 let c = node_center(node.q, node.r, size);
86 min = min.min(c);
87 max = max.max(c);
88 }
89 (min, max)
90}
91
92fn hex_corners(center: Pos2, radius: f32) -> Vec<Pos2> {
94 (0..6)
95 .map(|i| center + Vec2::angled(TAU / 6.0 * i as f32) * radius)
96 .collect()
97}
98
99fn draw_arrow(painter: &egui::Painter, center: Pos2, dir: Dir, radius: f32, stroke: Stroke) {
101 let v = Vec2::angled(dir_angle(dir));
102 let tip = center + v * (radius * ARROW_TIP);
103 let back = center + v * (radius * ARROW_BACK);
104 let spread = v.rot90() * (radius * ARROW_SPREAD);
105 painter.add(Shape::line(vec![back + spread, tip, back - spread], stroke));
106}
107
108#[derive(Clone, Copy)]
112struct Geometry {
113 origin: Pos2,
115 cell: f32,
117 min: Vec2,
120}
121
122impl Geometry {
123 fn new(origin: Pos2, cell: f32, game: &HexwebGame) -> Self {
124 Self {
125 origin,
126 cell,
127 min: center_bounds(game, cell).0,
128 }
129 }
130
131 fn center(&self, node: &Node) -> Pos2 {
133 self.origin + node_center(node.q, node.r, self.cell) - self.min
134 + Vec2::new(self.cell, SQRT_3 * 0.5 * self.cell)
135 }
136
137 fn node_at(&self, game: &HexwebGame, pos: Pos2) -> Option<NodeId> {
142 let mut best = None;
143 let mut best_d = f32::INFINITY;
144 for (id, node) in game.nodes().iter().enumerate() {
145 let d = pos.distance(self.center(node));
146 if d < best_d {
147 best = Some(id);
148 best_d = d;
149 }
150 }
151 match best {
152 Some(id) if best_d <= self.cell => Some(id),
153 _ => None,
154 }
155 }
156}
157
158pub struct HexwebWidget<'a> {
170 game: &'a mut HexwebGame,
171 cell_size: Option<f32>,
172 win_message: Option<String>,
173 interactive: bool,
174 satisfied_color: Option<Color32>,
175 unsatisfied_color: Option<Color32>,
176}
177
178impl<'a> HexwebWidget<'a> {
179 pub fn new(game: &'a mut HexwebGame) -> Self {
180 Self {
181 game,
182 cell_size: None,
183 win_message: None,
184 interactive: true,
185 satisfied_color: None,
186 unsatisfied_color: None,
187 }
188 }
189
190 pub fn cell_size(mut self, size: f32) -> Self {
194 self.cell_size = Some(size);
195 self
196 }
197
198 pub fn win_message(mut self, message: impl Into<String>) -> Self {
202 self.win_message = Some(message.into());
203 self
204 }
205
206 pub fn satisfied_color(mut self, color: Color32) -> Self {
209 self.satisfied_color = Some(color);
210 self
211 }
212
213 pub fn unsatisfied_color(mut self, color: Color32) -> Self {
217 self.unsatisfied_color = Some(color);
218 self
219 }
220
221 pub fn interactive(mut self, interactive: bool) -> Self {
224 self.interactive = interactive;
225 self
226 }
227}
228
229impl Widget for HexwebWidget<'_> {
230 fn ui(self, ui: &mut Ui) -> Response {
231 let Self {
232 game,
233 cell_size,
234 win_message,
235 interactive,
236 satisfied_color,
237 unsatisfied_color,
238 } = self;
239
240 let cell = cell_size.unwrap_or_else(|| fit_cell_size(game, ui.available_size()));
241 let total_size = content_size(game, cell);
242
243 let sense = if interactive {
244 Sense::click_and_drag()
245 } else {
246 Sense::hover()
247 };
248 let (response, painter) = ui.allocate_painter(total_size, sense);
249 let geometry = Geometry::new(response.rect.min, cell, game);
250
251 let drag_id = response.id.with("hexweb_drag_piece");
256 let select_id = response.id.with("hexweb_selected_piece");
257 let can_play = interactive && game.status() == GameStatus::InProgress;
258 let pointer = response.interact_pointer_pos();
259 let mut dragging: Option<PieceId> = ui.ctx().data(|d| d.get_temp(drag_id));
260
261 if can_play {
262 if response.clicked() {
263 let hit = pointer.and_then(|pos| geometry.node_at(game, pos));
266 let hit_piece = hit.and_then(|node| game.piece_at(node));
267 if let Some(piece) = hit_piece {
268 let already =
269 ui.ctx().data(|d| d.get_temp::<PieceId>(select_id)) == Some(piece);
270 ui.ctx().data_mut(|d| {
271 if already {
272 d.remove_temp::<PieceId>(select_id);
273 } else {
274 d.insert_temp(select_id, piece);
275 }
276 });
277 } else if let Some(node) = hit {
278 if let Some(selected) = ui.ctx().data(|d| d.get_temp::<PieceId>(select_id)) {
279 if game.move_piece(selected, node) {
280 ui.ctx().data_mut(|d| d.remove_temp::<PieceId>(select_id));
281 }
282 }
283 } else {
284 ui.ctx().data_mut(|d| d.remove_temp::<PieceId>(select_id));
285 }
286 } else if response.drag_started() {
287 let grabbed = ui
293 .input(|i| i.pointer.press_origin())
294 .and_then(|pos| geometry.node_at(game, pos))
295 .and_then(|node| game.piece_at(node));
296 if let Some(piece) = grabbed {
297 ui.ctx().data_mut(|d| {
298 d.insert_temp(drag_id, piece);
299 d.remove_temp::<PieceId>(select_id);
300 });
301 dragging = Some(piece);
302 }
303 }
304 }
305
306 let drag_target = if can_play {
308 dragging.and_then(|piece| {
309 pointer
310 .and_then(|pos| geometry.node_at(game, pos))
311 .filter(|&node| game.can_move(piece, node))
312 })
313 } else {
314 None
315 };
316
317 if response.drag_stopped() {
318 if let Some(piece) = ui.ctx().data_mut(|d| d.remove_temp::<PieceId>(drag_id)) {
319 if let Some(node) = pointer
320 .and_then(|pos| geometry.node_at(game, pos))
321 .filter(|&node| game.can_move(piece, node))
322 {
323 game.move_piece(piece, node);
324 }
325 }
326 dragging = None;
327 }
328
329 if can_play {
330 if dragging.is_some() {
331 ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
332 } else if let Some(node) = response
333 .hover_pos()
334 .and_then(|pos| geometry.node_at(game, pos))
335 {
336 if game.piece_at(node).is_some() {
337 ui.ctx().set_cursor_icon(CursorIcon::Grab);
338 } else if ui
339 .ctx()
340 .data(|d| d.get_temp::<PieceId>(select_id).is_some())
341 {
342 ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
343 }
344 }
345 }
346
347 let visuals = ui.visuals();
349 let satisfied_color = satisfied_color.unwrap_or(visuals.strong_text_color());
350 let unsatisfied_color = unsatisfied_color.unwrap_or(visuals.error_fg_color);
351
352 let (piece_fill, piece_edge) = if visuals.dark_mode {
353 (Color32::from_gray(0x5C), Color32::from_gray(0x82))
354 } else {
355 (Color32::from_gray(0xC9), Color32::from_gray(0x8E))
356 };
357 let socket_fill = visuals.extreme_bg_color;
358 let socket_edge = visuals.widgets.noninteractive.bg_stroke.color;
359
360 for node in game.nodes() {
362 painter.add(Shape::convex_polygon(
363 hex_corners(geometry.center(node), cell),
364 socket_fill,
365 Stroke::new(1.0, socket_edge),
366 ));
367 }
368
369 if can_play && dragging.is_some() {
372 for (id, node) in game.nodes().iter().enumerate() {
373 if game.piece_at(id).is_some() {
374 continue;
375 }
376 let stroke = if drag_target == Some(id) {
377 Stroke::new(cell * 0.10, satisfied_color)
378 } else {
379 Stroke::new(cell * 0.05, piece_fill)
380 };
381 painter.circle_stroke(geometry.center(node), cell * RING_RADIUS, stroke);
382 }
383 }
384
385 if can_play && dragging.is_none() {
387 if let Some(selected) = ui.ctx().data(|d| d.get_temp::<PieceId>(select_id)) {
388 if let Some(node) = game.node_of(selected) {
389 painter.circle_stroke(
390 geometry.center(&game.nodes()[node]),
391 cell * RING_RADIUS,
392 Stroke::new(cell * 0.075, satisfied_color),
393 );
394 }
395 }
396 }
397
398 for piece in 0..game.piece_count() {
400 let Some(node) = game.node_of(piece) else {
401 continue;
402 };
403 let center = geometry.center(&game.nodes()[node]);
404 let in_hand = dragging == Some(piece);
405 let alpha = if in_hand { 0.35 } else { 1.0 };
406 let arrows = game.pieces()[piece].arrows;
407 let unsatisfied = game.unsatisfied_arrows(piece);
408
409 painter.add(Shape::convex_polygon(
410 hex_corners(center, cell * PIECE_RADIUS),
411 piece_fill.gamma_multiply(alpha),
412 Stroke::new(cell * 0.05, piece_edge.gamma_multiply(alpha)),
413 ));
414 for dir in arrows.iter() {
415 let color = if unsatisfied.contains(dir) {
416 unsatisfied_color
417 } else {
418 satisfied_color
419 };
420 draw_arrow(
421 &painter,
422 center,
423 dir,
424 cell * PIECE_RADIUS,
425 Stroke::new(cell * ARROW_STROKE, color.gamma_multiply(alpha)),
426 );
427 }
428 }
429
430 if game.status() == GameStatus::Won {
432 let message = win_message.unwrap_or_else(|| "Solved!".to_owned());
433 let font = FontId::proportional((cell * 0.6).clamp(14.0, 40.0));
434 let galley = painter.layout_no_wrap(message, font, satisfied_color);
435 let size = galley.size() + Vec2::new(cell, cell * 0.6);
440 let banner = Rect::from_center_size(
441 Pos2::new(
442 response.rect.center().x,
443 response.rect.min.y + size.y * 0.5 + cell * 0.15,
444 ),
445 size,
446 );
447 painter.rect_filled(banner, CornerRadius::same(6), visuals.window_fill);
448 painter.rect_stroke(
449 banner,
450 CornerRadius::same(6),
451 Stroke::new(1.0, satisfied_color),
452 StrokeKind::Inside,
453 );
454 painter.galley(
455 banner.center() - galley.size() * 0.5,
456 galley,
457 Color32::PLACEHOLDER,
458 );
459 }
460
461 response
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use crate::game::{random_symmetric_board, Params};
469
470 fn preset_game(nodes: usize, pieces: usize, seed: u64) -> HexwebGame {
472 HexwebGame::random(
473 Params {
474 nodes: random_symmetric_board(nodes, seed),
475 pieces,
476 min_arrows: 2,
477 max_arrows: 4,
478 },
479 seed,
480 )
481 }
482
483 #[test]
484 fn dir_angles_match_the_pixel_deltas() {
485 for dir in Dir::ALL {
486 let (dq, dr) = dir.delta();
487 let pixel = Vec2::new(1.5 * dq as f32, SQRT_3 * (dr as f32 + 0.5 * dq as f32));
488 let angled = Vec2::angled(dir_angle(dir));
489 assert!(
490 (pixel.normalized() - angled).length() < 1e-4,
491 "{dir:?} paints toward {angled:?}, lattice delta is {pixel:?}"
492 );
493 }
494 }
495
496 #[test]
497 fn content_size_and_fit_round_trip() {
498 let game = preset_game(12, 9, 7);
499 let size = content_size(&game, 10.0);
500 assert!((fit_cell_size(&game, size) - 10.0).abs() < 1e-4);
501 assert!(size.x != size.y);
503 assert!(fit_cell_size(&game, Vec2::splat(20.0)) >= 4.0);
504 }
505
506 #[test]
507 fn node_at_round_trips_through_every_center() {
508 let game = preset_game(16, 12, 3);
509 let geometry = Geometry::new(Pos2::ZERO, 20.0, &game);
510 for (id, node) in game.nodes().iter().enumerate() {
511 assert_eq!(geometry.node_at(&game, geometry.center(node)), Some(id));
512 let near = geometry.center(node) + Vec2::new(6.0, -4.0);
514 assert_eq!(geometry.node_at(&game, near), Some(id));
515 }
516 assert_eq!(
517 geometry.node_at(&game, Pos2::new(-999.0, -999.0)),
518 None,
519 "far away from the board there is nothing to hit"
520 );
521 }
522
523 const TEST_CELL: f32 = 24.0;
524
525 struct Harness {
530 ctx: egui::Context,
531 game: HexwebGame,
532 rect: Rect,
533 time: f64,
534 }
535
536 impl Harness {
537 fn new(game: HexwebGame) -> Self {
538 let mut harness = Self {
539 ctx: egui::Context::default(),
540 game,
541 rect: Rect::ZERO,
542 time: 0.0,
543 };
544 harness.pass(Vec::new());
546 harness
547 }
548
549 fn pass(&mut self, events: Vec<egui::Event>) -> Rect {
550 self.time += 1.0 / 60.0;
551 let input = egui::RawInput {
552 screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))),
553 events,
554 time: Some(self.time),
555 ..Default::default()
556 };
557 let game = &mut self.game;
558 let rect = std::cell::Cell::new(Rect::ZERO);
559 let _ = self.ctx.run_ui(input, |ui| {
560 let response = ui.add(HexwebWidget::new(game).cell_size(TEST_CELL));
561 rect.set(response.rect);
562 });
563 self.rect = rect.get();
564 self.rect
565 }
566
567 fn geometry(&self) -> Geometry {
568 Geometry::new(self.rect.min, TEST_CELL, &self.game)
569 }
570
571 fn center(&self, node: NodeId) -> Pos2 {
572 self.geometry().center(&self.game.nodes()[node])
573 }
574
575 fn press(&mut self, pos: Pos2) {
576 self.pass(vec![
577 egui::Event::PointerMoved(pos),
578 egui::Event::PointerButton {
579 pos,
580 button: egui::PointerButton::Primary,
581 pressed: true,
582 modifiers: Default::default(),
583 },
584 ]);
585 }
586
587 fn drag(&mut self, pos: Pos2) {
588 self.pass(vec![egui::Event::PointerMoved(pos)]);
589 }
590
591 fn release(&mut self, pos: Pos2) {
592 self.pass(vec![
593 egui::Event::PointerMoved(pos),
594 egui::Event::PointerButton {
595 pos,
596 button: egui::PointerButton::Primary,
597 pressed: false,
598 modifiers: Default::default(),
599 },
600 ]);
601 }
602
603 fn click(&mut self, pos: Pos2) {
604 self.press(pos);
605 self.release(pos);
606 }
607 }
608
609 fn empty_node(game: &HexwebGame) -> NodeId {
610 (0..game.node_count())
611 .find(|&node| game.piece_at(node).is_none())
612 .expect("presets always leave empty nodes")
613 }
614
615 fn occupied_node(game: &HexwebGame) -> NodeId {
616 (0..game.node_count())
617 .find(|&node| game.piece_at(node).is_some())
618 .expect("presets always have pieces")
619 }
620
621 #[test]
622 fn click_piece_then_empty_node_moves_it() {
623 let mut harness = Harness::new(preset_game(8, 6, 11));
624 let from = occupied_node(&harness.game);
625 let piece = harness.game.piece_at(from).unwrap();
626 let target = empty_node(&harness.game);
627
628 harness.click(harness.center(from));
629 assert_eq!(harness.game.node_of(piece), Some(from), "select only");
630 harness.click(harness.center(target));
631
632 assert_eq!(harness.game.node_of(piece), Some(target));
633 assert_eq!(harness.game.moves(), 1);
634 }
635
636 #[test]
637 fn clicking_an_empty_node_without_a_selection_does_nothing() {
638 let mut harness = Harness::new(preset_game(8, 6, 11));
639 harness.click(harness.center(empty_node(&harness.game)));
640 assert_eq!(harness.game.moves(), 0);
641 }
642
643 #[test]
644 fn clicking_the_selected_piece_again_deselects_it() {
645 let mut harness = Harness::new(preset_game(8, 6, 11));
646 let from = occupied_node(&harness.game);
647 harness.click(harness.center(from));
648 harness.click(harness.center(from));
649 harness.click(harness.center(empty_node(&harness.game)));
650 assert_eq!(harness.game.moves(), 0);
651 }
652
653 #[test]
654 fn dragging_a_piece_to_an_empty_node_moves_it() {
655 let mut harness = Harness::new(preset_game(12, 9, 5));
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.press(harness.center(from));
661 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
663 harness.drag(harness.center(target));
664 harness.release(harness.center(target));
665
666 assert_eq!(harness.game.node_of(piece), Some(target));
667 assert_eq!(harness.game.moves(), 1);
668 }
669
670 #[test]
671 fn dropping_off_the_board_snaps_back() {
672 let mut harness = Harness::new(preset_game(12, 9, 5));
673 let from = occupied_node(&harness.game);
674 let piece = harness.game.piece_at(from).unwrap();
675
676 harness.press(harness.center(from));
677 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
678 harness.release(Pos2::new(-100.0, -100.0));
679
680 assert_eq!(harness.game.node_of(piece), Some(from));
681 assert_eq!(harness.game.moves(), 0);
682 }
683
684 #[test]
685 fn dropping_on_an_occupied_node_snaps_back() {
686 let mut harness = Harness::new(preset_game(12, 9, 5));
687 let from = occupied_node(&harness.game);
688 let piece = harness.game.piece_at(from).unwrap();
689 let taken = (0..harness.game.node_count())
690 .find(|&node| node != from && harness.game.piece_at(node).is_some())
691 .expect("a second occupied node exists");
692
693 harness.press(harness.center(from));
694 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
695 harness.release(harness.center(taken));
696
697 assert_eq!(harness.game.node_of(piece), Some(from));
698 assert_eq!(harness.game.moves(), 0);
699 }
700
701 fn play_solution(game: &mut HexwebGame) {
706 let Some(solution) = game.solution() else {
707 panic!("test boards always have a solution");
708 };
709 let mut placed: Vec<bool> = (0..game.piece_count())
710 .map(|piece| game.node_of(piece) == Some(solution[piece]))
711 .collect();
712 while placed.iter().any(|&done| !done) {
713 let step = (0..game.piece_count())
715 .filter(|&piece| !placed[piece])
716 .find_map(|piece| {
717 game.piece_at(solution[piece])
718 .is_none()
719 .then_some((piece, solution[piece]))
720 })
721 .or_else(|| {
722 let piece = (0..game.piece_count()).find(|&piece| !placed[piece])?;
725 let node =
726 (0..game.node_count()).find(|&node| game.piece_at(node).is_none())?;
727 Some((piece, node))
728 })
729 .expect("a solvable board is always finishable");
730 let (piece, node) = step;
731 assert!(game.move_piece(piece, node));
732 placed[piece] = game.node_of(piece) == Some(solution[piece]);
733 }
734 }
735
736 #[test]
737 fn a_won_board_ignores_input() {
738 let mut harness = Harness::new(preset_game(8, 6, 2));
739 play_solution(&mut harness.game);
740 assert_eq!(harness.game.status(), GameStatus::Won);
741 let moves = harness.game.moves();
742
743 let from = occupied_node(&harness.game);
744 let target = empty_node(&harness.game);
745 harness.click(harness.center(from));
747 harness.click(harness.center(target));
748 harness.press(harness.center(from));
750 harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
751 harness.release(harness.center(target));
752
753 assert_eq!(harness.game.moves(), moves);
754 }
755}