From 80688d7a1694950d5745078ccaafb126500e4dd2 Mon Sep 17 00:00:00 2001 From: Dane Johnson Date: Thu, 9 Jun 2022 12:20:35 -0500 Subject: [PATCH] Documentation pass --- gamenite-gui/src/main.rs | 10 +- samples/risk.board | Bin 512846 -> 512845 bytes src/io.rs | 24 +++++ src/lib.rs | 209 +++++++++++++++++++++++++++++++++++---- 4 files changed, 215 insertions(+), 28 deletions(-) diff --git a/gamenite-gui/src/main.rs b/gamenite-gui/src/main.rs index 7b74200..5adde26 100644 --- a/gamenite-gui/src/main.rs +++ b/gamenite-gui/src/main.rs @@ -176,9 +176,7 @@ impl BoardBuilderApp { for edge in &node.edges { let other_node = &board.nodes[edge]; if board.config.horizontal_wrapping && node.should_wrap_horizontal(other_node) { - let mut nodes = [node, other_node]; - nodes.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap()); - let [left_node, right_node] = nodes; + let [left_node, right_node] = node.horizontal_order(other_node); let y_mid = left_node.y_mid(right_node); painter.line_segment( [view.inv_xform(0.0, y_mid), view.inv_xform(left_node.x, left_node.y)], @@ -248,11 +246,7 @@ impl CreateNodeDialog { self.open = true; self.board = board; - self.node = Node { - x, - y, - ..Node::default() - } + self.node = Node::new(x, y); } fn ui(&mut self, ctx: &Context) { diff --git a/samples/risk.board b/samples/risk.board index 3832c0931c86ca0b98b682efd5a02fbf25e82c52..50042c25de70e686ca264eeaffd24e80f8fb8270 100644 GIT binary patch delta 1394 zcmX@NO#bXL`T78FW)=|!1_lm>1q)AvEV5o#@qmSaAySZmfs28GA-yQEAVV*!I6rS{ z_}jeOZUQx;jr;kJzP)>o{jWb~lY;IfvC_+nIGP?Q%}qUIlo=2bYyDw&x&O7=fNfr8 zVo9gP@^)BnHm<(EdhPFL+jqZuRQ+}JeUsSe*qW8o!fQ7diLS1{_xAU@bC>x~|IK@G zcN^#an0I{l?MnX^?2Wkh>v6jN?%I$4UJI|jmG_oky_xI(|KQjU@*Adw6{<#l6}Z>5 zaaP-O=*YzMf=}uE0ZR3bl1FY``6}q8>2h-E$*V2%DwtwqW>0H4Be~$N;9(UG zMV|GJ$BSB~xrsmhbBAwT{;o$kDYI5y@0TwYD_{^3kXSSEcvJwV_D&Cr{95U#00Fs= zB8P8g%s3t7rhW3OpkMUrDU+-o{(bbjb|u3fmUg1q@g)1G);mGWJ}w3BN+%eQ6a{k}If9lqKBIN^}^L3GxMofDUtWyE>C zdF7J${8!o3?uRqKSQ!XQvtP@~3z_S_>8IQI`mlL@N3170-(9W#Baqd$eSL@1+n|I) zw}mJFykpiJudl~$E)dpeS)L@o744k2_}-Ff61!#!)R&a6(-lzj65J_vHuF=Z&5cFd zT!Ni9`y_Y$TXzN<{8g{tU$H+w%KlPQlCk-wIX}0A zN(8<(S-^B5<>KF$dEXAddscts?{XiuPaionT78}?yLv}t#tVFLDrhn`)U+~SouJz8 zzfFhFs^QG}_Nn%zjHh*&P1n9zW9GWs;`+t2BK9G{5e}Q<^8YHm*nLDXGFt4_%&4T| z+Znu%=a}8xa^#v-!rl|7x362eL$mdm$mZgs(|Q}t(nOA$Hk&zi&FR^GXV={m^-DUP z&L-$hI8nD&*W77|w8IAZ)KfPk-T8d-8U&3MxhL2`*OG zbr*MQNMV_<;^1Me;B8%E%MwMHKTEgn=}6~dnJ2m+!&CcO5C7knDSo`EdW^lMS5}Io zif}24AB!u?(^@!B?zE4&u9uWSY9q^p()x=!Ug_pK_a?;eT6QN*<3ReIM8$IN3HDhN zFHK&zq2%@H?cJ&S7p7L$9G0id$Y_Qp1&S@ z-=?fYp7H*_()+u2?_Rbvp8xMXd)vRPx# delta 1395 zcmX@RO#a+5`T78FW)=|!1_lm>gz3jaY}^krJ!D~E_$k1^z{SA8kY1EnkfE1VoS!!} zqCWq&nZRGIw*P8ulgj^0{eNkJ#;%2TH>{CvTO<)NyRgL~Na6J9NlN!?-LEQX9`C#o zQIMnl=<(k2@9$pZpI-Z4YTNFoH@821pBQZV_0yZrpOP<^UGM%}fBQn1_WqR9r_=1K z{+Hc1D{qXO6EACLdH?;cYx%4HeeJ8OtxP(-E3&;VRe1it=y@OKFEP2p*_-q8!v3oX zTSULC|7WIie%k(PbMsfXiE+O6-Sd5O+KDUTWl6K$ch@L6XbMdbVhG#K+;_>(TQ8KU zYIo(1*IW{sGq3c_`;ocEd4XU(OAM1?l;q1Vj4ll#83u`e-sXS1`)-*7)pH7$aBj4Af(c z4U2!dZIZsiAsOZw=Tuf2af!42ROoWebyQTD!e95+yl(ra+?n1Mv0O6^xmynUYOy*O zX3iF<`?!5|_Wj-|?uN3?e2z)~C1xlUPN_FYb^RSHeyi%^%5}Ro#Kn~DQ=Y<R+s#=TepS`;`0A4} zTfLYj&E>nj*(+eutT|TqeX9#zsA&6~>bmKs>zdQPhmlvrKIL5fxAJ{;iJR4r968gn z@Jn!J{p(3tk<&SU?z7!?u5&44x6c$SQwiD48wxh{Kdk$h=fBKSOY`UhR`yf1Yo{BT z8;MLi;i0OUT+TT0X8nFH_kRg97kK;r+N2}JU=<}YSwDM1-=2&qpVdXuLd1k_^e~A9 zToKRE3|!imdGXVl6Z04H^(XF^jWjoM%=@C_y7^4T!Nz){W~qvTmxX=DR{07aeO)DP z;&`vBB%s7~n%lIZnROYjd^gL^)F^5TzN?Tv<-&b=W(L-SjGKAtTd%K8FDpL1r%_1n zXUj54huMcJ__oV&+sEEq)NxW`(w(JtcmK~YJYvhb?T7M~+^eQUq!m`1-6{;l;w4spOosbc)2cnL+0-`4*`yS6_Cg_tC0V62(1N zOHvn{78SL({JDQ|K$=ggLYzmo&6y3Hj~-Rs(R8>JV&EE@cf*7)qw!?414n_-Y0l&2 z-R4VddS|3^Y)Tu+csd{OkOy6RnFS zOHRaUiDxccGtF^+Nr!BL$ZUJ5S@lT`j8;4N5+yZHY&ac~{i+}(c^2b2uNOY^l=>f5 zUGVsL?_Afhu(j83Z|E=$Nn`q2e|phEU&-`uzE&QEe(9f0`YmNQ&YSwOb^Xf~22xpP z_+`!;*Irrt@_=TW&HH}aoeSs1=dP=beY@*oiKqH}i5baD@7=mpFCVYF+rE7He@2udv00S0U6hrvU6hrnU6hr%U6hrjU6d6h#. +//! Module to allow reading and writing to zipped board files +//! +//! This modules utilizes [serde] to write and read the board as a JSON object to a compressed file. +//! It also allows an image to stored, using [image] to convert to a png and saving it alongside the +//! graph json object. use crate::Board; use std::io; @@ -8,6 +28,7 @@ use std::path::Path; use image::io::Reader as ImageReader; use image::DynamicImage; +/// Writes the board and optionally an image to the file indicated by `path` pub fn write_board_to_file(board: &Board, image: Option<&DynamicImage>, path: &Path) -> io::Result<()> { let file = File::create(path)?; let mut ar = zip::ZipWriter::new(file); @@ -25,6 +46,7 @@ pub fn write_board_to_file(board: &Board, image: Option<&DynamicImage>, path: &P Ok(()) } +/// Reads the board and possibly an image from the file indicated by `path` pub fn read_board_from_file(path: &Path) -> io::Result<(Board, Option)> { let file = File::open(path)?; let mut ar = zip::ZipArchive::new(file)?; @@ -42,12 +64,14 @@ pub fn read_board_from_file(path: &Path) -> io::Result<(Board, Option Vec { let mut cursor = Cursor::new(Vec::new()); image.write_to(&mut cursor, image::ImageOutputFormat::Png).unwrap(); cursor.into_inner() } +/// Converts a PNG image to a [DynamicImage] pub fn decode_png(buf: &[u8]) -> DynamicImage { let cursor = Cursor::new(buf); let mut reader = ImageReader::new(cursor); diff --git a/src/lib.rs b/src/lib.rs index 181db74..a47dbde 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,29 @@ +// gamenite - A graph library for board games +// Copyright (C) 2022 Dane Johnson + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! A graph library for board games +//! +//! When writing programs revolving around tabletop board games, often the graph is the preferred data +//! structure for representing the game board, especially in games that are neither linear nor grid-based, +//! i.e. _Risk_. This crate provides utilities for building and interacting with graph-based board states. +//! +//! More specifically, we provide [Board], a [HashMap] based graph with configurable labels and options. +//! With it we provide [Node], representing a location on the board, with connections to other Nodes. +//! Finally, we provide an [io] package to facilitate loading and saving these boards, and a related image to +//! a compressed file. pub mod io; use serde::{ Serialize, Deserialize }; @@ -5,7 +31,9 @@ use std::collections::{ HashMap, HashSet }; #[derive(Serialize, Deserialize, Debug)] #[serde(default)] +/// Configuration options for a [Board] pub struct Config { + /// Whether the board wraps around the horizontal edges. Games like _Risk_ make use of this pub horizontal_wrapping: bool, } impl Default for Config { @@ -16,55 +44,134 @@ impl Default for Config { } } -#[derive(Serialize, Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)] #[serde(default)] +/// A space or connection on a [Board] +/// +/// Nodes are [serde] [Serialize] and [Deserialize] pub struct Node { + /// A [0, 1] based horizontal node position pub x: f32, + /// A [0, 1] based vertical node position pub y: f32, + /// A human-readable identifier for this node. It need not be unique pub name: String, + /// A set of edges connecting this node to other nodes on a [Board] pub edges: HashSet, + /// A [HashMap] of label names to values, indicating which labels apply to this node pub labels: HashMap, } impl Node { + /// Constructs a new anonymous, unconnected and unlabeled node at the given xy coordinate + /// # Panics + /// This function panics if x and y are not in the range [0, 1] + pub fn new(x: f32, y: f32) -> Self { + if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) { + panic!("Node coordinates are not in range [0,1]"); + } + Node {x, y, ..Default::default()} + } + /// Tests if the horizontal distance from this node to another is shorter by wrapping + /// over the board edge + /// # Examples + /// ``` + /// use gamenite::Node; + /// + /// let node1 = Node::new(0.1, 0.0); + /// let node2 = Node::new(0.2, 0.0); + /// let node3 = Node::new(0.9, 0.0); + /// assert!(!node1.should_wrap_horizontal(&node1)); + /// assert!(node1.should_wrap_horizontal(&node3)); + /// ``` pub fn should_wrap_horizontal(&self, other: &Node) -> bool { - let mut xs = [self.x, other.x]; - xs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let xs = self.horizontal_order(other).map(|n| n.x); xs[0] + (1.0 - xs[1]) < xs[1] - xs[0] } - + /// Finds the vertical position whereby a line drawn from this node to the nearest edge + /// will have the same slope as a line drawn from the other node to the other edge at the + /// same position + /// # Examples + /// ``` + /// use gamenite::Node; + /// + /// let node1 = Node::new(0.2, 0.3); + /// let node2 = Node::new(0.7, 0.1); + /// let y_mid = node1.y_mid(&node2); + /// assert!(y_mid >= 0.21 && y_mid <= 0.23); + /// ``` pub fn y_mid(&self, other: &Node) -> f32 { - let mut coords = [(self.x, self.y), (other.x, other.y)]; - coords.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); - let [(x1, y1), (x2, y2)] = coords; + let [(x1, y1), (x2, y2)] = self.horizontal_order(other).map(Node::as_coords); y1 - x1 * (y1 - y2) / (x1 + 1.0 - x2) } + /// Gets the xy coordinates of the node a pair of [f32s](f32) + pub fn as_coords(&self) -> (f32, f32) { + (self.x, self.y) + } + /// Returns this node and another ordered by x coordinate + /// # Examples + /// ``` + /// use gamenite::Node; + /// + /// let node1 = Node::new(0.1, 0.0); + /// let node2 = Node::new(0.2, 0.0); + /// assert_eq!(node1.horizontal_order(&node2), [&node1, &node2]); + /// assert_eq!(node2.horizontal_order(&node1), [&node1, &node2]); + /// ``` + pub fn horizontal_order<'a>(&'a self, other: &'a Node) -> [&'a Node; 2] { + if self.x < other.x { + [self, other] + } else { + [other, self] + } + } } #[derive(Serialize, Deserialize, Debug, Default)] #[serde(default)] +/// A graph of [Nodes](Node) and related information +/// +/// The graph is [serde] [Serialize] and [Deserialize]. +/// +/// Graphs might have labels, which are arbitrarily named keys, each with one of +/// several possible values. Each [Node] then defines it's value for each label. +/// +/// As an example, in the game _Risk_ each territory is assigned to a continent. Therefore +/// the board might have a label key "Continent" with values "North America", "Asia", "Australia" etc. +/// and each node would define it's "Continent" label, for example the node named "Alaska" would +/// map "Continent" to "North America". pub struct Board { + /// A mapping of labels to their available values pub labels: HashMap>, + /// The [Nodes](Node) on this board, keyed by id pub nodes: HashMap, + /// The board configuration pub config: Config, } impl Board { - pub fn add_node(&mut self, x: f32, y: f32, name: String) { - self.nodes.insert(self.next_id(), Node { - x, - y, - name, - edges: HashSet::new(), - labels: HashMap::new(), - }); - } - + /// Insert a [Node] into the graph, using the next available id. + /// Returns the id this node was assigned to pub fn insert_node(&mut self, node: Node) -> usize { let id = self.next_id(); self.nodes.insert(id, node); id } - + /// Remove a [Node] from the graph, breaking all edges referring to + /// it and marking its id for reuse + /// # Examples + /// ``` + /// use gamenite::{ Board, Node }; + /// + /// let mut board = Board::default(); + /// + /// let id1 = board.insert_node(Node::new(0.0, 0.0)); + /// let id2 = board.insert_node(Node::new(0.0, 0.0)); + /// board.add_edge(id2, id1); + /// + /// board.remove_node(id1); + /// assert!(!board.nodes.contains_key(&id1)); + /// assert!(!board.nodes[&id2].edges.contains(&id1)); + /// ``` pub fn remove_node(&mut self, id: usize) { // We remove this node from the graph, then drop it from each // other nodes edge. @@ -73,22 +180,60 @@ impl Board { node.edges.remove(&id); } } - + /// Add a label with specified keys and values + pub fn insert_label(&mut self, key: &str, values: &[&str]) { + self.labels.insert( + key.to_string(), + values.iter().map(|s| s.to_string()).collect(), + ); + } + /// Remove a label key from use on this board, and remove it from any nodes + /// # Examples + /// ``` + /// use gamenite::{ Board, Node }; + /// + /// let mut board = Board::default(); + /// let mut node = Node::new(0.0, 0.0); + /// + /// board.insert_label("Fruit", &["Apples", "Pears"]); + /// node.labels.insert("Fruit".to_string(), "Apples".to_string()); + /// let id = board.insert_node(node); + /// + /// board.remove_label_key("Fruit"); + /// assert!(!board.labels.contains_key("&Fruit")); + /// assert!(!board.nodes[&id].labels.contains_key("&Fruit")); + /// ``` pub fn remove_label_key(&mut self, key: &str) { self.labels.remove(key); + for node in self.nodes.values_mut() { + node.labels.remove(key); + } } - + /// Removes a value from the label keyed by key pub fn remove_label_value(&mut self, key: &str, value: &str) { if let Some(l) = self.labels.get_mut(key) { l.remove(value); } } + /// Adds an edge from a node to another by ids + /// # Examples + /// ``` + /// use gamenite::{ Board, Node }; + /// + /// let mut board = Board::default(); + /// let id1 = board.insert_node(Node::new(0.0, 0.0)); + /// let id2 = board.insert_node(Node::new(0.0, 0.0)); + /// + /// board.add_edge(id1, id2); + /// assert!(board.nodes[&id1].edges.contains(&id2)); + /// assert!(!board.nodes[&id2].edges.contains(&id1)); pub fn add_edge(&mut self, from: usize, to: usize) { let node = self.nodes.get_mut(&from).expect("Could not find node"); node.edges.insert(to); } + /// Finds the nearest node to a given position pub fn nearest_node(&self, x: f32, y: f32) -> Option { let f = |n: &Node| dist_sq((n.x, n.y), (x, y)); let mut iter = self.nodes.iter(); @@ -130,9 +275,32 @@ fn inv_lerp(v0: f32, v1: f32, a: f32) -> f32 { (a - v0) / (v1 - v0) } +/// Trait to allow transformations from the (\[0,1\], \[0,1\]) coordinate space of the +/// graph to another coordinate space +/// # Examples +/// ``` +/// use gamenite::{ CoordTransformer, Node }; +/// use std::ops::Range; +/// +/// // Convert to Cartesian coordinates +/// struct CartesianCoords { domain: Range, range: Range}; +/// impl CoordTransformer<(f32, f32)> for CartesianCoords { +/// fn origin(&self) -> (f32, f32) { (self.domain.start, self.range.start) } +/// fn extremes(&self) -> (f32, f32) { (self.domain.end, self.range.end) } +/// } +/// +/// // Cartesian coordinates from ([-50, 30], [-10, 100]) +/// let graph = CartesianCoords { domain: -50.0..30.0, range: -10.0..100.0 }; +/// let node = Node::new(0.5, 0.5); +/// assert_eq!(graph.inv_xform(node.x, node.y), (-10.0, 45.0)); +/// assert_eq!(graph.xform((-10.0, 45.0)), (node.x, node.y)); +/// ``` pub trait CoordTransformer + From<(f32, f32)>> { + /// Returns the smallest values that a coordinate can take on fn origin(&self) -> I; + /// Returns the largest values that a coordinate can take on fn extremes(&self) -> I; + /// Returns the coordinate mapped into the [Board] standard (\[0, 1\], \[0, 1\]) coordinate space fn xform(&self, pos: I) -> (f32, f32) { let (sx, sy) = self.origin().into(); let (ex, ey) = self.extremes().into(); @@ -142,6 +310,7 @@ pub trait CoordTransformer + From<(f32, f32)>> { inv_lerp(sy, ey, y), ) } + /// Returns the board coordinate mapped into this space fn inv_xform(&self, x: f32, y: f32) -> I { let (sx, sy) = self.origin().into(); let (ex, ey) = self.extremes().into();