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 zcmV-&1rXS6wAAe9w0|XQR000O8prOcAqC2j5-~<2wSrGsL3IG5AXL4b1XfA4V zZ*G-W?QYvf5PTJZzYoRj-QEZNPYHq`Es_>>)1nB1;v$`8!ZK-4R9!ppyLV64eNeVa zHWp&b7H+sZvom|&tF8Oyx4Y`%d-bc|G+kR=e5saKeY0{EtAF0@`|i%u56k^-=-svn zzg+GP-#&HyaJ^XG`r~IWyM6Kg>l3TmZtV{zg9`uuQ(f>Mu$ESFC0X?l-h#23UY0)W zZhdu8U9McaZd&*E0mH91;e;=szp_`JG(O)Gfys%(LzFa=XQhd+eKMY z#F1xk@@EfFB7Z>{#M;#L5lSgU$)(BFgr0Z;TpXL0fXo@7-4Vki2q6rwLC120mPQxl z{M-(%Z@S`aWSXhhj~{auZ~zhz7_5=UT2Kiqxkxx~eH&U(5FYUq!`Wz<%Th)w$@LLV zTdS0kI^q4|`+cbZ{yN53bpb=v2}3I?!r0h3C8>=nLw|gxdP+_>lQ2Pxgo1jdNzJKU zy4IrX*EOlQJY6y=CQb*L+k;XIfhsCXlZLrWr~mF-|LFJI(U1U>3~+9rGM3~@)f!Et z0=WvW1oozPk4@PrK=wcQ$UtH66`IJok)}3iUP|ohL}BOpc9o0ane;j^6B`HCYHn1W zN3!`w&wo~)j>J2WL*1(<_)!Hthp&i2?NVUE+Y^)d+%|(>FD?f+5LSUXcVZ9T5GG0yxfac7@_9ViqP9d*mfA;}9SH zKuI3M<<;iewasoAJ-?^}CBjY(WJWP$9~Pod8h-%_GVLGQb=Ue66V^=PEW@{%1WE1U zaa7=-;3B?$a<{8%H{6W$1q}H_85uwdA%Uok2%v3kA@lsc_rBdyNS5=+%v?Q5dB(s< zP@)FNQ~$ARH{JGhQgF&4b2g$R0&H|&4TfU5aFye;ozA+P7FM2hw6d1UM9%SO@hAC9 zbAOl?ng1S-VxJJX5QR0Jiz%v}JEcd%{)%ZyCKy@5j8Ty~lM+tGl=e1cipsQ=UT^&(=)1%rSz8wB znOb6V+h`5roHp6C#MU}sy~xYAuBEssg~k-KbH$d8fi`9o#WjOAL5iG=x7@nj$bY1X zLd{?8$hrhWy;tYM-EJHfg%s70x7Ldhd-~F#g?&oxGR|vB3KR)qbDs# z7e;_&1dyn~!zxp@iWa6}6a(`cg}jJo3Iv`NplC@e){GDR>10j~WiA1YHPopTWfTe_ z7sg(8ZYrUk9?MKOElL_NWq|~cb$`(;N@q7L-jH9qrrc&Iz-QcHA$LoVKWdTEldiCI z>&v%`Wxt_id3?i|yO3i+{qt8Wh75*F)Gy5J+!j0g+E-+$1}KRx{mP)h>@6aWAK2mqj=$b%LIhZY3^hZY3_hZY3`hZY3{hZY3|hZY3} zw-yBlzs(Myp~zICJFa-(1ONb85trlA2Py-gp~#mI(+3*?2$w9=2OI{-(gy$l00df% A=>Px# delta 1395 zcmV-(1&sR5rXS9xAAe9w0|XQR000O8V3)^KJV(I-;RFBx`49jA3IG5AXL4b1XfA4V zZ*G-Xe{b715dA8K|0V{Kcl?$A(x521q1~{o8-}77SetQ#I8q?X%aS7BeMi+IDaVP_ zSa577;^Vz{_wML#%dP(!w!7uox8?6)Q#JMS?DO(t({|U3^MBjWRx7_`%gbgzdjEFc zHg|zuoL?P2Ip6QP)^Dr+^^Sghd1A}DS%-dQ6QBNDp7EcgG~5Y|Z28c?)nK$0^soLl zEYFs|)}3#wh879yO}zKBX2{eRc4C`HyL=)*DH4zp09LyLj?zv`E>!}0yLq_l3K%Jw z)Qq0^X{GT0afD~vjVf^iH_TBELKmp6}C4fj;Twcf=gBDy0qY_!x z#47=Ox@mn?hcXPqq+>!cp-;mclotD-0p;6otVTBQ_Zpx;8fFHpoNhPW0_OOd)L zxq^WctrRyplXAZGyBl9F#)uU?Tj5MTtt_pv133yPt$$QO4y9vBJ!c>$sJCftkP-y) zfq5acbyno`dDn(eZ+*QU1!Cq#0hurD4DC7rS*y8GE&*LuU+K1zFCfAk1DMVvsWJ)| z2KgY0MQuSLB$N++?Kgh6@@<(*I9&>vF$;vjO)3RLacP?ne(|@fYu}BOM=>2k4nbo7 z7?>h)lz%W~Mf+VB+IsP+uDY;ZTz0-AlmczGI5`Ul!)Y9X(u5hW1o@`*kJWZm7);=C zZHO$kBu=!nL(UqDRM0ZlM!N}hSJfp+$E(R)wMqh#oetZxN>GxToI2l4dvNF^D@@9Y z*+wlzY=^u74HQ3Q&VTlIzJ6h|C&a|egrW3PX@BdIYFU>F`My22&WWV~i%gU{H5eVU zuyC@E;ePRMPo_C4DaGIg2g-e|mohgp6qd+HB_(5b0g>5%zY0hGV40vxP5QDd8UQ+4 z6q7G&kdC})l=CMPW>gju*o*=eP}CP_DN&`4Y0>hm$e*DOk72(ZSvN94ZuBfgv&?A0 zfqycC8hCK&agN5SO%uiIdKWZ7-g!ou zC#Kwu;;O0`bBxt=WuVIy6+b!ozoSrQOl2TmNNYUIunFSgdfX{M(o`@-Rc_cc4rqbN zTR;eK63Yq4cZ)ZqJdK!T2(xBOP(=sPM)Sb|tfZz_4E#gaF3%4spjn*y!r)oZraodH=Zs^okcO$(7`2H8M$XP&~a6s zOkm+&Ji8RS=%W6S>R+S|jQu!O&gnFGb|cSZVs_PM91|NBrhaVuV;DRu$W!{y>d1wo z8Fa{9Di>*?td>Eabch{b6q`R9ntx(|0Xn!2VHqjNu*+0y>TqOZngPyA=uDm>kKuaI zNb%mzipEy0*W0j&HB@E-^?%Evp-mZQ_DwoSaZYFRG> z{_d`->Eo{5ho`6i0Z>Z=1QY-O00;nJm&bz@1&0*{0f!X@0*4g^1BVp_1cwy`1&0*{ z2DcRj2fxh@V3)^KJV(I-;RFBx`4E@l(g!L7V3)_257P%50ST8Y(+3;|$. +//! 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();