use std::f32::consts::TAU;
use egui::{
Color32, CornerRadius, CursorIcon, FontId, Pos2, Rect, Response, Sense, Shape, Stroke,
StrokeKind, Ui, Vec2, Widget,
};
use crate::game::{Dir, GameStatus, HexwebGame, Node, NodeId, PieceId};
const SQRT_3: f32 = 1.732_050_8;
const PIECE_RADIUS: f32 = 0.88;
const ARROW_TIP: f32 = 0.62;
const ARROW_BACK: f32 = 0.30;
const ARROW_SPREAD: f32 = 0.17;
const ARROW_STROKE: f32 = 0.085;
const RING_RADIUS: f32 = 0.80;
pub fn content_size(game: &HexwebGame, cell_size: f32) -> Vec2 {
let (min, max) = center_bounds(game, cell_size);
(max - min) + Vec2::new(2.0, SQRT_3) * cell_size
}
pub fn fit_cell_size(game: &HexwebGame, available: Vec2) -> f32 {
let unit = content_size(game, 1.0);
(available.x / unit.x).min(available.y / unit.y).max(4.0)
}
fn node_center(q: i32, r: i32, size: f32) -> Vec2 {
Vec2::new(
1.5 * size * q as f32,
SQRT_3 * size * (r as f32 + 0.5 * q as f32),
)
}
fn dir_angle(dir: Dir) -> f32 {
-TAU / 4.0 + TAU / 6.0 * dir as u8 as f32
}
fn center_bounds(game: &HexwebGame, size: f32) -> (Vec2, Vec2) {
let mut min = Vec2::splat(f32::INFINITY);
let mut max = Vec2::splat(f32::NEG_INFINITY);
for node in game.nodes() {
let c = node_center(node.q, node.r, size);
min = min.min(c);
max = max.max(c);
}
(min, max)
}
fn hex_corners(center: Pos2, radius: f32) -> Vec<Pos2> {
(0..6)
.map(|i| center + Vec2::angled(TAU / 6.0 * i as f32) * radius)
.collect()
}
fn draw_arrow(painter: &egui::Painter, center: Pos2, dir: Dir, radius: f32, stroke: Stroke) {
let v = Vec2::angled(dir_angle(dir));
let tip = center + v * (radius * ARROW_TIP);
let back = center + v * (radius * ARROW_BACK);
let spread = v.rot90() * (radius * ARROW_SPREAD);
painter.add(Shape::line(vec![back + spread, tip, back - spread], stroke));
}
fn can_drop_at(game: &HexwebGame, piece: PieceId, node: NodeId) -> bool {
match game.piece_at(node) {
Some(other) => other != piece && game.can_swap(piece, other),
None => game.can_move(piece, node),
}
}
#[derive(Clone, Copy)]
struct Geometry {
origin: Pos2,
cell: f32,
min: Vec2,
}
impl Geometry {
fn new(origin: Pos2, cell: f32, game: &HexwebGame) -> Self {
Self {
origin,
cell,
min: center_bounds(game, cell).0,
}
}
fn center(&self, node: &Node) -> Pos2 {
self.origin + node_center(node.q, node.r, self.cell) - self.min
+ Vec2::new(self.cell, SQRT_3 * 0.5 * self.cell)
}
fn node_at(&self, game: &HexwebGame, pos: Pos2) -> Option<NodeId> {
let mut best = None;
let mut best_d = f32::INFINITY;
for (id, node) in game.nodes().iter().enumerate() {
let d = pos.distance(self.center(node));
if d < best_d {
best = Some(id);
best_d = d;
}
}
match best {
Some(id) if best_d <= self.cell => Some(id),
_ => None,
}
}
}
pub struct HexwebWidget<'a> {
game: &'a mut HexwebGame,
cell_size: Option<f32>,
win_message: Option<String>,
interactive: bool,
satisfied_color: Option<Color32>,
unsatisfied_color: Option<Color32>,
}
impl<'a> HexwebWidget<'a> {
pub fn new(game: &'a mut HexwebGame) -> Self {
Self {
game,
cell_size: None,
win_message: None,
interactive: true,
satisfied_color: None,
unsatisfied_color: None,
}
}
pub fn cell_size(mut self, size: f32) -> Self {
self.cell_size = Some(size);
self
}
pub fn win_message(mut self, message: impl Into<String>) -> Self {
self.win_message = Some(message.into());
self
}
pub fn satisfied_color(mut self, color: Color32) -> Self {
self.satisfied_color = Some(color);
self
}
pub fn unsatisfied_color(mut self, color: Color32) -> Self {
self.unsatisfied_color = Some(color);
self
}
pub fn interactive(mut self, interactive: bool) -> Self {
self.interactive = interactive;
self
}
}
impl Widget for HexwebWidget<'_> {
fn ui(self, ui: &mut Ui) -> Response {
let Self {
game,
cell_size,
win_message,
interactive,
satisfied_color,
unsatisfied_color,
} = self;
let cell = cell_size.unwrap_or_else(|| fit_cell_size(game, ui.available_size()));
let total_size = content_size(game, cell);
let sense = if interactive {
Sense::drag()
} else {
Sense::hover()
};
let (response, painter) = ui.allocate_painter(total_size, sense);
let geometry = Geometry::new(response.rect.min, cell, game);
let drag_id = response.id.with("hexweb_drag_piece");
let can_play = interactive && game.status() == GameStatus::InProgress;
let pointer = response.interact_pointer_pos();
let mut dragging: Option<PieceId> = ui.ctx().data(|d| d.get_temp(drag_id));
if can_play && response.drag_started() {
let grabbed = ui
.input(|i| i.pointer.press_origin())
.and_then(|pos| geometry.node_at(game, pos))
.and_then(|node| game.piece_at(node));
if let Some(piece) = grabbed {
ui.ctx().data_mut(|d| d.insert_temp(drag_id, piece));
dragging = Some(piece);
}
}
let drag_target = if can_play {
dragging.and_then(|piece| {
pointer
.and_then(|pos| geometry.node_at(game, pos))
.filter(|&node| can_drop_at(game, piece, node))
})
} else {
None
};
if response.drag_stopped() {
if let Some(piece) = ui.ctx().data_mut(|d| d.remove_temp::<PieceId>(drag_id)) {
if let Some(node) = pointer
.and_then(|pos| geometry.node_at(game, pos))
.filter(|&node| can_drop_at(game, piece, node))
{
match game.piece_at(node) {
Some(other) => {
game.swap_pieces(piece, other);
}
None => {
game.move_piece(piece, node);
}
}
}
}
dragging = None;
}
if can_play {
if dragging.is_some() {
ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
} else if let Some(node) = response
.hover_pos()
.and_then(|pos| geometry.node_at(game, pos))
{
if game.piece_at(node).is_some() {
ui.ctx().set_cursor_icon(CursorIcon::Grab);
}
}
}
let visuals = ui.visuals();
let satisfied_color = satisfied_color.unwrap_or(visuals.strong_text_color());
let unsatisfied_color = unsatisfied_color.unwrap_or(visuals.error_fg_color);
let (piece_fill, piece_edge) = if visuals.dark_mode {
(Color32::from_gray(0x5C), Color32::from_gray(0x82))
} else {
(Color32::from_gray(0xC9), Color32::from_gray(0x8E))
};
let socket_fill = visuals.extreme_bg_color;
let socket_edge = visuals.widgets.noninteractive.bg_stroke.color;
for node in game.nodes() {
painter.add(Shape::convex_polygon(
hex_corners(geometry.center(node), cell),
socket_fill,
Stroke::new(1.0, socket_edge),
));
}
for piece in 0..game.piece_count() {
let Some(node) = game.node_of(piece) else {
continue;
};
let center = geometry.center(&game.nodes()[node]);
let in_hand = dragging == Some(piece);
let alpha = if in_hand { 0.35 } else { 1.0 };
let arrows = game.pieces()[piece].arrows;
let unsatisfied = game.unsatisfied_arrows(piece);
painter.add(Shape::convex_polygon(
hex_corners(center, cell * PIECE_RADIUS),
piece_fill.gamma_multiply(alpha),
Stroke::new(cell * 0.05, piece_edge.gamma_multiply(alpha)),
));
for dir in arrows.iter() {
let color = if unsatisfied.contains(dir) {
unsatisfied_color
} else {
satisfied_color
};
draw_arrow(
&painter,
center,
dir,
cell * PIECE_RADIUS,
Stroke::new(cell * ARROW_STROKE, color.gamma_multiply(alpha)),
);
}
}
if can_play {
if let Some(dragged) = dragging {
for (id, node) in game.nodes().iter().enumerate() {
if !can_drop_at(game, dragged, id) {
continue;
}
let faint = if game.piece_at(id).is_some() {
piece_edge
} else {
piece_fill
};
let stroke = if drag_target == Some(id) {
Stroke::new(cell * 0.10, satisfied_color)
} else {
Stroke::new(cell * 0.05, faint)
};
painter.circle_stroke(geometry.center(node), cell * RING_RADIUS, stroke);
}
}
}
if game.status() == GameStatus::Won {
let message = win_message.unwrap_or_else(|| "Solved!".to_owned());
let font = FontId::proportional((cell * 0.6).clamp(14.0, 40.0));
let galley = painter.layout_no_wrap(message, font, satisfied_color);
let size = galley.size() + Vec2::new(cell, cell * 0.6);
let banner = Rect::from_center_size(
Pos2::new(
response.rect.center().x,
response.rect.min.y + size.y * 0.5 + cell * 0.15,
),
size,
);
painter.rect_filled(banner, CornerRadius::same(6), visuals.window_fill);
painter.rect_stroke(
banner,
CornerRadius::same(6),
Stroke::new(1.0, satisfied_color),
StrokeKind::Inside,
);
painter.galley(
banner.center() - galley.size() * 0.5,
galley,
Color32::PLACEHOLDER,
);
}
response
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::{build_nodes, random_symmetric_board, Arrows, Params, Piece};
fn preset_game(nodes: usize, pieces: usize, seed: u64) -> HexwebGame {
HexwebGame::random(
Params {
nodes: random_symmetric_board(nodes, seed),
pieces,
min_arrows: 2,
max_arrows: 4,
},
seed,
)
}
#[test]
fn dir_angles_match_the_pixel_deltas() {
for dir in Dir::ALL {
let (dq, dr) = dir.delta();
let pixel = Vec2::new(1.5 * dq as f32, SQRT_3 * (dr as f32 + 0.5 * dq as f32));
let angled = Vec2::angled(dir_angle(dir));
assert!(
(pixel.normalized() - angled).length() < 1e-4,
"{dir:?} paints toward {angled:?}, lattice delta is {pixel:?}"
);
}
}
#[test]
fn content_size_and_fit_round_trip() {
let game = preset_game(12, 9, 7);
let size = content_size(&game, 10.0);
assert!((fit_cell_size(&game, size) - 10.0).abs() < 1e-4);
assert!(size.x != size.y);
assert!(fit_cell_size(&game, Vec2::splat(20.0)) >= 4.0);
}
#[test]
fn node_at_round_trips_through_every_center() {
let game = preset_game(16, 12, 3);
let geometry = Geometry::new(Pos2::ZERO, 20.0, &game);
for (id, node) in game.nodes().iter().enumerate() {
assert_eq!(geometry.node_at(&game, geometry.center(node)), Some(id));
let near = geometry.center(node) + Vec2::new(6.0, -4.0);
assert_eq!(geometry.node_at(&game, near), Some(id));
}
assert_eq!(
geometry.node_at(&game, Pos2::new(-999.0, -999.0)),
None,
"far away from the board there is nothing to hit"
);
}
const TEST_CELL: f32 = 24.0;
struct Harness {
ctx: egui::Context,
game: HexwebGame,
rect: Rect,
time: f64,
}
impl Harness {
fn new(game: HexwebGame) -> Self {
let mut harness = Self {
ctx: egui::Context::default(),
game,
rect: Rect::ZERO,
time: 0.0,
};
harness.pass(Vec::new());
harness
}
fn pass(&mut self, events: Vec<egui::Event>) -> Rect {
self.time += 1.0 / 60.0;
let input = egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))),
events,
time: Some(self.time),
..Default::default()
};
let game = &mut self.game;
let rect = std::cell::Cell::new(Rect::ZERO);
let mut output = self.ctx.run_ui(input, |ui| {
let response = ui.add(HexwebWidget::new(game).cell_size(TEST_CELL));
rect.set(response.rect);
});
output.textures_delta.clear();
self.rect = rect.get();
self.rect
}
fn geometry(&self) -> Geometry {
Geometry::new(self.rect.min, TEST_CELL, &self.game)
}
fn center(&self, node: NodeId) -> Pos2 {
self.geometry().center(&self.game.nodes()[node])
}
fn press(&mut self, pos: Pos2) {
self.pass(vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
},
]);
}
fn drag(&mut self, pos: Pos2) {
self.pass(vec![egui::Event::PointerMoved(pos)]);
}
fn release(&mut self, pos: Pos2) {
self.pass(vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
},
]);
}
}
fn empty_node(game: &HexwebGame) -> NodeId {
(0..game.node_count())
.find(|&node| game.piece_at(node).is_none())
.expect("presets always leave empty nodes")
}
fn occupied_node(game: &HexwebGame) -> NodeId {
(0..game.node_count())
.find(|&node| game.piece_at(node).is_some())
.expect("presets always have pieces")
}
#[test]
fn dragging_a_piece_to_an_empty_node_moves_it() {
let mut harness = Harness::new(preset_game(12, 9, 5));
let from = occupied_node(&harness.game);
let piece = harness.game.piece_at(from).unwrap();
let target = empty_node(&harness.game);
harness.press(harness.center(from));
harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
harness.drag(harness.center(target));
harness.release(harness.center(target));
assert_eq!(harness.game.node_of(piece), Some(target));
assert_eq!(harness.game.moves(), 1);
}
#[test]
fn dropping_off_the_board_snaps_back() {
let mut harness = Harness::new(preset_game(12, 9, 5));
let from = occupied_node(&harness.game);
let piece = harness.game.piece_at(from).unwrap();
harness.press(harness.center(from));
harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
harness.release(Pos2::new(-100.0, -100.0));
assert_eq!(harness.game.node_of(piece), Some(from));
assert_eq!(harness.game.moves(), 0);
}
fn swappable_node(game: &HexwebGame, piece: PieceId, except: NodeId) -> Option<NodeId> {
(0..game.node_count()).find(|&node| match game.piece_at(node) {
Some(other) => node != except && game.can_swap(piece, other),
None => false,
})
}
#[test]
fn dropping_on_an_occupied_node_swaps_the_pieces() {
let mut harness = Harness::new(preset_game(12, 9, 5));
let from = occupied_node(&harness.game);
let piece = harness.game.piece_at(from).unwrap();
let taken = swappable_node(&harness.game, piece, from)
.expect("some other piece carries different arrows");
let taken_piece = harness.game.piece_at(taken).unwrap();
harness.press(harness.center(from));
harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
harness.release(harness.center(taken));
assert_eq!(harness.game.node_of(piece), Some(taken));
assert_eq!(harness.game.node_of(taken_piece), Some(from));
assert_eq!(harness.game.moves(), 1);
}
#[test]
fn releasing_on_the_dragged_pieces_own_node_snaps_back() {
let mut harness = Harness::new(preset_game(12, 9, 5));
let from = occupied_node(&harness.game);
let piece = harness.game.piece_at(from).unwrap();
harness.press(harness.center(from));
harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
harness.release(harness.center(from));
assert_eq!(harness.game.node_of(piece), Some(from));
assert_eq!(harness.game.moves(), 0);
}
fn identical_pair_game() -> HexwebGame {
let (nodes, _) = build_nodes(&[(0, 0), (0, -1), (0, 1), (0, 2)]);
let pieces = vec![
Piece {
arrows: Arrows::from_dir(Dir::Up),
},
Piece {
arrows: Arrows::from_dir(Dir::Up),
},
];
let cell_piece = vec![Some(0), None, None, Some(1)];
HexwebGame::from_parts(nodes, pieces, cell_piece.clone(), cell_piece)
}
#[test]
fn dropping_on_an_identical_piece_is_free() {
let mut harness = Harness::new(identical_pair_game());
let from = occupied_node(&harness.game);
let piece = harness.game.piece_at(from).unwrap();
let taken = (0..harness.game.node_count())
.find(|&node| node != from && harness.game.piece_at(node).is_some())
.expect("the second piece exists");
harness.press(harness.center(from));
harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
harness.release(harness.center(taken));
assert_eq!(harness.game.node_of(piece), Some(taken));
assert_eq!(harness.game.moves(), 0);
}
fn play_solution(game: &mut HexwebGame) {
let Some(solution) = game.solution() else {
panic!("test boards always have a solution");
};
let mut placed: Vec<bool> = (0..game.piece_count())
.map(|piece| game.node_of(piece) == Some(solution[piece]))
.collect();
while placed.iter().any(|&done| !done) {
let step = (0..game.piece_count())
.filter(|&piece| !placed[piece])
.find_map(|piece| {
game.piece_at(solution[piece])
.is_none()
.then_some((piece, solution[piece]))
})
.or_else(|| {
let piece = (0..game.piece_count()).find(|&piece| !placed[piece])?;
let node =
(0..game.node_count()).find(|&node| game.piece_at(node).is_none())?;
Some((piece, node))
})
.expect("a solvable board is always finishable");
let (piece, node) = step;
assert!(game.move_piece(piece, node));
placed[piece] = game.node_of(piece) == Some(solution[piece]);
}
}
#[test]
fn a_won_board_ignores_input() {
let mut harness = Harness::new(preset_game(8, 6, 2));
play_solution(&mut harness.game);
assert_eq!(harness.game.status(), GameStatus::Won);
let moves = harness.game.moves();
let from = occupied_node(&harness.game);
let target = empty_node(&harness.game);
let taken = (0..harness.game.node_count())
.find(|&node| node != from && harness.game.piece_at(node).is_some())
.expect("a second occupied node exists");
harness.press(harness.center(from));
harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
harness.release(harness.center(target));
harness.press(harness.center(from));
harness.drag(harness.center(from) + Vec2::new(16.0, 16.0));
harness.release(harness.center(taken));
assert_eq!(harness.game.moves(), moves);
}
}