From d35534c0795caeda46e57fc515b74eba701110a2 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Fri, 6 Dec 2019 18:00:04 +0100 Subject: initial commit --- .gitignore | 1 + Cargo.toml | 20 +++ src/LiberationMono.ttf | Bin 0 -> 313408 bytes src/api/mod.rs | 205 ++++++++++++++++++++++++++++ src/api/professions.rs | 36 +++++ src/api/skills.rs | 24 ++++ src/api/specializations.rs | 34 +++++ src/api/traits.rs | 40 ++++++ src/bt.rs | 160 ++++++++++++++++++++++ src/cache.rs | 78 +++++++++++ src/main.rs | 252 +++++++++++++++++++++++++++++++++++ src/minor_trait_mask.png | Bin 0 -> 4287 bytes src/render.rs | 325 +++++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 1175 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/LiberationMono.ttf create mode 100644 src/api/mod.rs create mode 100644 src/api/professions.rs create mode 100644 src/api/skills.rs create mode 100644 src/api/specializations.rs create mode 100644 src/api/traits.rs create mode 100644 src/bt.rs create mode 100644 src/cache.rs create mode 100644 src/main.rs create mode 100644 src/minor_trait_mask.png create mode 100644 src/render.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..05dc674 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "kondou" +version = "0.1.0" +authors = ["Daniel "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +image = "0.22" +imageproc = "0.19" +rusttype = "0.7" +reqwest = "0.9" +serde = "1.0" +serde_json = "1.0" +clap = "2.33" +xdg = "2.2.0" +quick-error = "1.2.0" +itertools = "0.8.0" +md5 = "0.7" diff --git a/src/LiberationMono.ttf b/src/LiberationMono.ttf new file mode 100644 index 0000000..1a39bc7 Binary files /dev/null and b/src/LiberationMono.ttf differ diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..6e1b457 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,205 @@ +//! This module is responsible for accessing the Guild Wars 2 API. +//! +//! It contains `Deserialize`able definitions for the required API responses, as well as a wrapper +//! around the HTTP library. Note that only the required fields are modelled here, which means this +//! does not provide a full-featured mapping for all API types. +pub mod professions; +pub mod skills; +pub mod specializations; +pub mod traits; + +pub use self::{ + professions::Profession, skills::Skill, specializations::Specialization, traits::Trait, +}; + +use image::DynamicImage; +use itertools::Itertools; +use reqwest::{Client, Url}; +use serde::{de::DeserializeOwned, Serialize}; +use std::path::Path; + +use super::cache::{Cache, CacheError}; + +/// The base URL of the official Guild Wars 2 API. +const BASE_URL: &str = "https://api.guildwars2.com/v2/"; + +quick_error! { + #[derive(Debug)] + pub enum ApiError { + SerializationError(err: serde_json::Error) { + cause(err) + from() + } + CacheError(err: CacheError) { + cause(err) + from() + } + HttpError(err: reqwest::Error) { + cause(err) + from() + } + ImageError(err: image::ImageError) { + cause(err) + from() + } + } +} + +/// Trait for API objects that have an ID. +/// +/// This is used by [`Api`](struct.Api.html) to properly retrieve and cache objects. +trait HasId { + type Id: ToString + Eq + Clone; + fn get_id(&self) -> Self::Id; +} + +/// The main API access struct. +/// +/// This takes care of caching given the provided cache implementation, as well as keeping a HTTP +/// connection pool around to re-use. +pub struct Api { + cache: Box, + base_url: Url, + client: Client, +} + +/// API access for the GW2 api. +impl Api { + /// Create a new API instance with the given cache underlying. + pub fn new(cache: C) -> Api { + Api { + cache: Box::new(cache), + base_url: Url::parse(BASE_URL).unwrap(), + client: Client::new(), + } + } + + /// Combines the given endpoint with the `base_url` of this API. + fn make_url(&self, endpoint: &str) -> Url { + self.base_url.join(endpoint).expect("Invalid API endpoint") + } + + /// Get and deserialize a cached value. + fn get_cached(&mut self, name: P) -> Result, ApiError> + where + T: DeserializeOwned, + P: AsRef, + { + match self.cache.get(name.as_ref())? { + Some(data) => Ok(serde_json::from_slice(&data)?), + None => Ok(None), + } + } + + /// Serialize and store a value in the cache. + fn save_cached(&mut self, name: P, value: &T) -> Result<(), ApiError> + where + T: Serialize, + P: AsRef, + { + self.cache + .store(name.as_ref(), &serde_json::to_vec(value)?)?; + Ok(()) + } + + /// Retrieves a list of elements by their ID. + /// + /// This function first checks the cache if elements can be found there, and otherwise hits the + /// API. + fn get_multiple_cached( + &mut self, + endpoint: &str, + cache_prefix: &str, + ids: &[R::Id], + ) -> Result, ApiError> + where + R: HasId + DeserializeOwned + Serialize, + { + let mut result: Vec = Vec::new(); + let mut api_ids: Vec = Vec::new(); + + for id in ids { + let cache_path = format!("{}{}", cache_prefix, id.to_string()); + match self.get_cached(cache_path)? { + Some(cached) => result.push(cached), + None => api_ids.push(id.clone()), + } + } + + if api_ids.is_empty() { + return Ok(result); + } + + let url = self.make_url(endpoint); + let api_arg = api_ids.iter().map(ToString::to_string).join(","); + let resp: Vec = self + .client + .get(url) + .query(&[("ids", api_arg)]) + .send()? + .json()?; + for result in &resp { + let cache_path = format!("{}{}", cache_prefix, result.get_id().to_string()); + self.save_cached(cache_path, result)?; + } + result.extend(resp.into_iter()); + + Ok(result) + } + + /// Retrieve a list of all professions using the professions endpoint. + pub fn get_profession_ids(&mut self) -> Result, ApiError> { + if let Some(cached) = self.get_cached("profession_ids")? { + return Ok(cached); + } + let url = self.make_url("professions"); + let resp = self.client.get(url).send()?.json()?; + self.save_cached("profession_ids", &resp)?; + Ok(resp) + } + + /// Retrieve detailed information about the given professions. + /// + /// Professions that are found in the cache are taken from there. Therefore, the order of the + /// return vector is not guaranteed to be the same as the input order. + pub fn get_professions(&mut self, ids: &[String]) -> Result, ApiError> { + self.get_multiple_cached("professions", "professions/", ids) + } + + /// Retrieve detailed information about the given skills. + /// + /// Skills that are found in the cache are taken from there. + pub fn get_skills(&mut self, ids: &[u32]) -> Result, ApiError> { + self.get_multiple_cached("skills", "skills/", ids) + } + + /// Retrieve detailed information about the given specializations. + /// + /// Specializations that are found in the cache are taken from there. + pub fn get_specializations(&mut self, ids: &[u32]) -> Result, ApiError> { + self.get_multiple_cached("specializations", "specializations/", ids) + } + + /// Retrieve detailed information about the given traits. + /// + /// Traits that are found in the cache are taken from there. + pub fn get_traits(&mut self, ids: &[u32]) -> Result, ApiError> { + self.get_multiple_cached("traits", "traits/", ids) + } + + /// Loads the image from the given URL. + /// + /// This automatically caches and also decodes the resulting data. + pub fn get_image(&mut self, url: &str) -> Result { + let hashed_url = format!("images/{:x}", md5::compute(url.as_bytes())); + if let Some(data) = self.cache.get(Path::new(&hashed_url))? { + return Ok(image::load_from_memory(&data)?); + } + + let mut img = Vec::new(); + self.client.get(url).send()?.copy_to(&mut img)?; + + self.cache.store(Path::new(&hashed_url), &img)?; + Ok(image::load_from_memory(&img)?) + } +} diff --git a/src/api/professions.rs b/src/api/professions.rs new file mode 100644 index 0000000..2716a1a --- /dev/null +++ b/src/api/professions.rs @@ -0,0 +1,36 @@ +//! Struct definitions for the professions API endpoint. +//! +//! * [Example](https://api.guildwars2.com/v2/professions/Engineer) +//! * [Wiki](https://wiki.guildwars2.com/wiki/API:2/professions) + +use super::HasId; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Profession { + /// The profession id. + pub id: String, + /// The name of the profession. + pub name: String, + /// List of specialization ids. + pub specializations: Vec, + /// List of skills. + pub skills: Vec, +} + +impl HasId for Profession { + type Id = String; + fn get_id(&self) -> String { + self.id.clone() + } +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Skill { + /// The id of the skill. + pub id: u32, + /// The skill bar slot that this skill can be used in. + pub slot: String, + #[serde(rename = "type")] + pub typ: String, +} diff --git a/src/api/skills.rs b/src/api/skills.rs new file mode 100644 index 0000000..9c692e1 --- /dev/null +++ b/src/api/skills.rs @@ -0,0 +1,24 @@ +//! Struct definitions for the skills API endpoint. +//! +//! * [Example](https://api.guildwars2.com/v2/skills/14375) +//! * [Wiki](https://wiki.guildwars2.com/wiki/API:2/skills) + +use super::HasId; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Skill { + /// The skill id. + pub id: u32, + /// The skill name. + pub name: String, + /// A URL to an icon of the skill. + pub icon: String, +} + +impl HasId for Skill { + type Id = u32; + fn get_id(&self) -> u32 { + self.id + } +} diff --git a/src/api/specializations.rs b/src/api/specializations.rs new file mode 100644 index 0000000..97fc289 --- /dev/null +++ b/src/api/specializations.rs @@ -0,0 +1,34 @@ +//! Struct definitions for the specializations API endpoint. +//! +//! * [Example](https://api.guildwars2.com/v2/specializations/1) +//! * [Wiki](https://wiki.guildwars2.com/wiki/API:2/specializations) + +use super::HasId; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Specialization { + /// The specialization's ID. + pub id: u32, + /// The name of the specialization. + pub name: String, + /// The profession that this specialization belongs to. + pub profession: String, + /// `true` if this specialization is an elite specialization, `false` otherwise. + pub elite: bool, + /// A URL to an icon of the specialization. + pub icon: String, + /// An URL to the background image of the specialization. + pub background: String, + /// Contains a list of IDs specifying the minor traits in the specialization. + pub minor_traits: Vec, + /// Contains a list of IDs specifying the major traits in the specialization. + pub major_traits: Vec, +} + +impl HasId for Specialization { + type Id = u32; + fn get_id(&self) -> u32 { + self.id + } +} diff --git a/src/api/traits.rs b/src/api/traits.rs new file mode 100644 index 0000000..194d061 --- /dev/null +++ b/src/api/traits.rs @@ -0,0 +1,40 @@ +//! Struct definitions for the traits API endpoint. +//! +//! * [Example](https://api.guildwars2.com/v2/traits/214) +//! * [Wiki](https://wiki.guildwars2.com/wiki/API:2/traits) + +use super::HasId; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct Trait { + /// The trait id. + pub id: u32, + /// The trait name. + pub name: String, + /// The trait's icon URL. + pub icon: String, + /// The trait description. + pub description: String, + /// The id of the specialization this trait belongs to. + pub specialization: u32, + /// The trait's tier, as a value from 1-3. + /// + /// Elite specializations also contain a tier 0 minor trait, describing which weapon the elite + /// specialization gains access to. + pub tier: u32, + pub slot: Slot, +} + +impl HasId for Trait { + type Id = u32; + fn get_id(&self) -> u32 { + self.id + } +} + +#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Copy, Clone)] +pub enum Slot { + Major, + Minor, +} diff --git a/src/bt.rs b/src/bt.rs new file mode 100644 index 0000000..3485448 --- /dev/null +++ b/src/bt.rs @@ -0,0 +1,160 @@ +use super::api::{Skill, Specialization}; +use std::{fmt, str::FromStr}; + +/// The profession of the template. +/// +/// Can be cast to an `u8` to get the right ID for building chat links. +#[repr(u8)] +#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone)] +pub enum Profession { + Guardian = 1, + Warrior = 2, + Engineer = 3, + Ranger = 4, + Thief = 5, + Elementalist = 6, + Mesmer = 7, + Necromancer = 8, + Revenant = 9, +} + +impl fmt::Display for Profession { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fmt::Debug::fmt(&self, f) + } +} + +impl FromStr for Profession { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "Guardian" => Ok(Profession::Guardian), + "Warrior" => Ok(Profession::Warrior), + "Engineer" => Ok(Profession::Engineer), + "Ranger" => Ok(Profession::Ranger), + "Thief" => Ok(Profession::Thief), + "Elementalist" => Ok(Profession::Elementalist), + "Mesmer" => Ok(Profession::Mesmer), + "Necromancer" => Ok(Profession::Necromancer), + "Revenant" => Ok(Profession::Revenant), + _ => Err(()), + } + } +} + +/// Represents the selected trait. +#[repr(u8)] +#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone)] +pub enum TraitChoice { + None = 0, + Top = 1, + Middle = 2, + Bottom = 3, +} + +impl FromStr for TraitChoice { + type Err = (); + + fn from_str(s: &str) -> Result { + let lower = s.to_lowercase(); + match &lower as &str { + "" | "none" => Ok(TraitChoice::None), + "top" => Ok(TraitChoice::Top), + "mid" | "middle" => Ok(TraitChoice::Middle), + "bot" | "bottom" => Ok(TraitChoice::Bottom), + _ => Err(()), + } + } +} + +/// Represents a traitline. +/// +/// A traitline consists of the chosen specialization including 3 possible trait choices. +pub type Traitline = (Specialization, [TraitChoice; 3]); + +pub const SKILL_COUNT: usize = 5; +pub const TRAITLINE_COUNT: usize = 3; + +/// Represents a build template. +/// +/// This struct is made with the same limitations as the game imposes. That is, even though the +/// renderer can support more than three traitlines, this template will only allow you to take +/// three. +#[derive(Debug)] +pub struct BuildTemplate { + /// Profession of this build. + profession: Profession, + /// The skills of the build. + /// + /// Each slot can either contain a slot or be empty. A maximum of 5 skills are allowed (heal, 3 + /// utilities and elite). + skills: [Option; SKILL_COUNT], + /// The traitlines of the build. + traitlines: [Option; TRAITLINE_COUNT], +} + +impl BuildTemplate { + /// Returns a template without any skills or traitlines. + pub fn empty(profession: Profession) -> BuildTemplate { + BuildTemplate { + profession, + skills: [None, None, None, None, None], + traitlines: [None, None, None], + } + } + + /// Creates a template with the given skills and traitlines. + /// + /// If there are more than 5 skills or 3 traitlines given, the function will return `None`. + pub fn new( + profession: Profession, + skills: &[Skill], + traitlines: &[Traitline], + ) -> Option { + if skills.len() > SKILL_COUNT { + return None; + } + if traitlines.len() > TRAITLINE_COUNT { + return None; + } + let mut skill_array = [None, None, None, None, None]; + for (i, skill) in skills.iter().enumerate() { + skill_array[i] = Some(skill.clone()); + } + let mut trait_array = [None, None, None]; + for (i, traitline) in traitlines.iter().enumerate() { + trait_array[i] = Some(traitline.clone()); + } + Some(BuildTemplate { + profession, + skills: skill_array, + traitlines: trait_array, + }) + } + + /// Returns the profession of this build. + pub fn profession(&self) -> Profession { + self.profession + } + + /// Returns the skills of this build. + pub fn skills(&self) -> &[Option] { + &self.skills + } + + /// Returns the number of actually equipped skills. + pub fn skill_count(&self) -> u32 { + self.skills.iter().filter(|x| x.is_some()).count() as u32 + } + + /// Returns the traitlines of this build. + pub fn traitlines(&self) -> &[Option] { + &self.traitlines + } + + /// Returns the number of actually equipped specializations. + pub fn traitline_count(&self) -> u32 { + self.traitlines.iter().filter(|x| x.is_some()).count() as u32 + } +} diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..f64b45a --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,78 @@ +//! Caching support to prevent hitting the API a lot. +use std::fs::File; +use std::io::prelude::*; +use std::path::Path; + +use xdg::BaseDirectories; + +use super::APP_NAME; + +quick_error! { + #[derive(Debug)] + pub enum CacheError { + Io(err: std::io::Error) { + cause(err) + from() + } + } +} + +/// A generic cache. +pub trait Cache { + fn store(&mut self, path: &Path, data: &[u8]) -> Result<(), CacheError>; + fn get(&mut self, path: &Path) -> Result>, CacheError>; +} + +/// A cache that stores files in the XDG specified cache location. +pub struct FileCache { + dirs: BaseDirectories, +} + +impl FileCache { + /// Create a new file backed cache. + pub fn new() -> Self { + let dirs = BaseDirectories::with_prefix(APP_NAME).unwrap(); + FileCache { dirs } + } +} + +impl Cache for FileCache { + fn store(&mut self, path: &Path, data: &[u8]) -> Result<(), CacheError> { + let cache_path = self.dirs.place_cache_file(path).unwrap(); + let mut f = File::create(cache_path)?; + f.write_all(data)?; + Ok(()) + } + + fn get(&mut self, path: &Path) -> Result>, CacheError> { + let cache_path = self.dirs.find_cache_file(path); + match cache_path { + Some(p) => { + let mut f = File::open(p)?; + let mut buffer = Vec::new(); + f.read_to_end(&mut buffer)?; + Ok(Some(buffer)) + } + None => Ok(None), + } + } +} + +/// A cache that does nothing. +struct NoopCache; + +impl NoopCache { + pub fn new() -> Self { + NoopCache + } +} + +impl Cache for NoopCache { + fn store(&mut self, _path: &Path, _data: &[u8]) -> Result<(), CacheError> { + Ok(()) + } + + fn get(&mut self, _path: &Path) -> Result>, CacheError> { + Ok(None) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9ffa1eb --- /dev/null +++ b/src/main.rs @@ -0,0 +1,252 @@ +extern crate clap; +extern crate image; +extern crate imageproc; +extern crate itertools; +extern crate md5; +extern crate reqwest; +extern crate rusttype; +extern crate xdg; +#[macro_use] +extern crate quick_error; + +use std::error::Error as StdError; +use std::fmt; + +mod api; +mod bt; +mod cache; +mod render; + +use clap::{App, Arg, ArgMatches}; + +use api::{Api, Profession, Skill}; +use bt::{BuildTemplate, TraitChoice, Traitline}; + +const APP_NAME: &str = "kondou"; + +#[derive(Debug, Clone)] +enum Error { + ProfessionNotFound(String), + SkillIdNotFound(u32), + SkillNotFound(String), + SpecializationNotFound(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Error::ProfessionNotFound(ref name) => { + write!(f, "the profession '{}' could not be found", name) + } + Error::SkillIdNotFound(id) => write!(f, "the skill with the ID '{}' was not found", id), + Error::SkillNotFound(ref name) => { + write!(f, "the skill with the name '{}' was not found", name) + } + Error::SpecializationNotFound(ref name) => write!( + f, + "the specialization with the name '{}' was not found", + name + ), + } + } +} + +impl StdError for Error {} + +type MainResult = Result>; + +fn find_profession(api: &mut Api, name: &str) -> MainResult { + let profession_ids = api.get_profession_ids()?; + let lower_name = name.to_lowercase(); + let profession_id = profession_ids + .iter() + .find(|id| id.to_lowercase() == lower_name) + .ok_or_else(|| Error::ProfessionNotFound(name.to_owned()))? + .clone(); + + Ok(api.get_professions(&[profession_id])?.remove(0)) +} + +fn resolve_skill(api: &mut Api, profession: &Profession, text: &str) -> MainResult { + // Try it as an ID first + let numeric = text.parse::(); + if let Ok(num_id) = numeric { + let exists = profession.skills.iter().any(|s| s.id == num_id); + if exists { + return Ok(api.get_skills(&[num_id])?.remove(0)); + } else { + return Err(Error::SkillIdNotFound(num_id).into()); + } + } + + // Check all names of the profession skills + let all_ids = profession.skills.iter().map(|s| s.id).collect::>(); + let all_skills = api.get_skills(&all_ids)?; + let lower_text = text.to_lowercase(); + all_skills + .into_iter() + .find(|s| s.name.to_lowercase().contains(&lower_text)) + .ok_or_else(|| Error::SkillNotFound(text.to_owned()).into()) +} + +fn resolve_traitline(api: &mut Api, profession: &Profession, text: &str) -> MainResult { + let parts = text.split(':').collect::>(); + assert_eq!( + parts.len(), + 4, + "invalid text format passed to resolve_traitline" + ); + + let name = parts[0]; + let lower_name = name.to_lowercase(); + let spec = api + .get_specializations(&profession.specializations)? + .into_iter() + .find(|s| s.name.to_lowercase() == lower_name) + .ok_or_else(|| Error::SpecializationNotFound(name.to_owned()))?; + + let mut choices = [TraitChoice::None; 3]; + for (i, text_choice) in parts.iter().skip(1).enumerate() { + let choice = text_choice + .parse() + .expect("Argument validation failed us, there is an invalid value here"); + choices[i] = choice; + } + Ok((spec, choices)) +} + +fn run_searching(api: &mut Api, matches: &ArgMatches) -> MainResult { + let requested_profession = matches + .value_of("profession") + .expect("clap handles missing argument"); + + let profession = find_profession(api, requested_profession)?; + + let skills = matches + .values_of("skill") + .map(Iterator::collect::>) + .unwrap_or_default() + .into_iter() + .map(|s| resolve_skill(api, &profession, s)) + .collect::, _>>()?; + + let traitlines = matches + .values_of("traitline") + .map(Iterator::collect::>) + .unwrap_or_default() + .into_iter() + .map(|t| resolve_traitline(api, &profession, t)) + .collect::, _>>()?; + + assert!(skills.len() <= bt::SKILL_COUNT, "got too many skills"); + assert!( + traitlines.len() <= bt::TRAITLINE_COUNT, + "got too many traitlines" + ); + + let build = BuildTemplate::new( + profession + .name + .parse() + .expect("Profession object has unparseable name"), + &skills, + &traitlines, + ) + .expect("BuildTemplate could not be constructed"); + + Ok(build) +} + +fn validate_traitline_format(input: String) -> Result<(), String> { + let parts = input.split(':').collect::>(); + if parts.len() != 4 { + return Err("traitline format is line:trait_1:trait_2:trait_3".to_owned()); + } + + for part in parts.iter().skip(1) { + let parsed = part.parse::(); + if parsed.is_err() { + return Err(format!( + "{} is not a valid trait. Use top, middle or bottom.", + part + )); + } + } + + Ok(()) +} + +fn run() -> MainResult<()> { + let matches = App::new(APP_NAME) + .version("0.1") + .author("Daniel ") + .about("Renders GW2 skills and traits.") + .arg( + Arg::with_name("profession") + .help("Selects which profession to use.") + .required_unless("chatlink"), + ) + .arg( + Arg::with_name("skill") + .help("Selects a skill based on either the name or the ID.") + .takes_value(true) + .number_of_values(1) + .long("skill") + .short("s") + .multiple(true) + .max_values(bt::SKILL_COUNT as u64), + ) + .arg( + Arg::with_name("traitline") + .help("Selects a traitline.") + .takes_value(true) + .number_of_values(1) + .long("traitline") + .short("t") + .multiple(true) + .validator(validate_traitline_format) + .max_values(bt::TRAITLINE_COUNT as u64), + ) + .arg( + Arg::with_name("chatlink") + .help("Specifies a chat link to parse.") + .short("c") + .long("chatlink") + .takes_value(true) + .conflicts_with_all(&["profession", "skill"]), + ) + .arg( + Arg::with_name("outfile") + .help("Specifies the output filename") + .short("o") + .long("outfile") + .default_value("buildtemplate.png") + .takes_value(true), + ) + .get_matches(); + + let mut api = Api::new(cache::FileCache::new()); + let build = match matches.is_present("chatlink") { + false => run_searching(&mut api, &matches)?, + true => unimplemented!(), + }; + + let mut renderer = render::Renderer::new(&mut api, Default::default()); + let img = renderer.render_buildtemplate(&build).unwrap(); + let filename = matches.value_of("outfile").unwrap(); + img.save(filename)?; + + Ok(()) +} + +fn main() { + let result = run(); + if let Err(e) = result { + eprintln!("[Error] {}", e); + let mut source = e.source(); + while let Some(s) = source { + eprintln!(" caused by {}", s); + source = s.source(); + } + } +} diff --git a/src/minor_trait_mask.png b/src/minor_trait_mask.png new file mode 100644 index 0000000..4fed74d Binary files /dev/null and b/src/minor_trait_mask.png differ diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..77a847f --- /dev/null +++ b/src/render.rs @@ -0,0 +1,325 @@ +use super::api::{Api, ApiError, Skill, Specialization, Trait}; +use super::bt::{BuildTemplate, TraitChoice, Traitline}; +use image::{imageops, DynamicImage, GenericImage, GenericImageView, ImageBuffer, Rgba, RgbaImage}; +use imageproc::drawing::{self, Point}; +use rusttype::{Font, Scale, SharedBytes}; + +quick_error! { + #[derive(Debug)] + pub enum RenderError { + ApiError(err: ApiError) { + cause(err) + from() + } + ImageError(err: image::ImageError) { + cause(err) + from() + } + } +} + +#[derive(Debug, Clone)] +pub struct RenderOptions { + skill_size: u32, + traitline_height: u32, + traitline_width: u32, + traitline_brightness: i32, + traitline_x_offset: u32, + trait_size: u32, + line_color: Rgba, + line_height: u32, + font: Font<'static>, + text_color: Rgba, + text_size: u32, + background_color: Rgba, + render_specialization_names: bool, + skill_offset: u32, +} + +impl Default for RenderOptions { + fn default() -> Self { + RenderOptions { + skill_size: 100, + traitline_height: 160, + traitline_width: 550, + traitline_brightness: 100, + traitline_x_offset: 0, + trait_size: 30, + line_color: Rgba([0, 0, 0, 255]), + line_height: 4, + font: Font::from_bytes(SharedBytes::ByRef(include_bytes!("LiberationMono.ttf"))) + .expect("Invalid font data for default font"), + text_color: Rgba([0, 0, 0, 255]), + text_size: 20, + background_color: Rgba([0, 0, 0, 0]), + render_specialization_names: true, + skill_offset: 30, + } + } +} + +const BG_CROP_HEIGHT: u32 = 160; +const BG_CROP_WIDTH: u32 = 550; + +pub struct Renderer<'r> { + api: &'r mut Api, + options: RenderOptions, + minor_mask: DynamicImage, +} + +impl<'r> Renderer<'r> { + /// Create a new renderer using the given API. + pub fn new(api: &mut Api, options: RenderOptions) -> Renderer<'_> { + let minor_mask = image::load_from_memory(include_bytes!("minor_trait_mask.png")) + .expect("Mask image could not be loaded") + .resize( + options.trait_size, + options.trait_size, + imageops::FilterType::CatmullRom, + ); + Renderer { + api, + options, + minor_mask, + } + } + + pub fn render_skills(&mut self, skills: &[Option]) -> Result { + let mut buffer = ImageBuffer::new( + skills.len() as u32 * self.options.skill_size, + self.options.skill_size, + ); + for (i, skill) in skills.iter().enumerate() { + if let Some(skill) = skill { + let img = self.api.get_image(&skill.icon)?.resize( + self.options.skill_size, + self.options.skill_size, + imageops::FilterType::CatmullRom, + ); + buffer.copy_from(&img, i as u32 * self.options.skill_size, 0); + } + } + Ok(buffer) + } + + fn render_minor_trait( + &mut self, + buffer: &mut RgbaImage, + minor: &Trait, + ) -> Result<(), RenderError> { + let minor_img = self.api.get_image(&minor.icon)?.resize( + self.options.trait_size, + self.options.trait_size, + imageops::FilterType::CatmullRom, + ); + let minor_img = imageproc::map::map_pixels(&minor_img, |x, y, p| { + let alpha = self.minor_mask.get_pixel(x, y)[3]; + Rgba([p[0], p[1], p[2], alpha]) + }); + let y_pos = (buffer.height() - minor_img.height()) / 2; + let x_slice = (buffer.width() - self.options.traitline_x_offset) / 6; + let x_pos = 2 * (minor.tier - 1) * x_slice + + (x_slice - minor_img.width()) / 2 + + self.options.traitline_x_offset; + imageops::overlay(buffer, &minor_img, x_pos, y_pos); + Ok(()) + } + + fn render_major_trait( + &mut self, + buffer: &mut RgbaImage, + major: &Trait, + vertical_pos: u8, + chosen: bool, + ) -> Result<(), RenderError> { + let major_img = self.api.get_image(&major.icon)?.resize( + self.options.trait_size, + self.options.trait_size, + imageops::FilterType::CatmullRom, + ); + let major_img = if !chosen { + major_img.grayscale() + } else { + major_img + }; + let y_slice = buffer.height() / 3; + let y_pos = vertical_pos as u32 * y_slice + (y_slice - major_img.height()) / 2; + let x_slice = (buffer.width() - self.options.traitline_x_offset) / 6; + let x_pos = 2 * (major.tier - 1) * x_slice + + x_slice + + (x_slice - major_img.width()) / 2 + + self.options.traitline_x_offset; + imageops::overlay(buffer, &major_img, x_pos, y_pos); + Ok(()) + } + + fn render_line( + &mut self, + buffer: &mut RgbaImage, + tier: u8, + choice: TraitChoice, + ) -> Result<(), RenderError> { + if choice == TraitChoice::None { + return Ok(()); + } + + let x_slice = (buffer.width() - self.options.traitline_x_offset) / 6; + let start_x = 2 * tier as u32 * x_slice + + (x_slice + self.options.trait_size) / 2 + + self.options.traitline_x_offset; + let end_x = start_x + x_slice - self.options.trait_size; + let start_y = (buffer.height() - self.options.line_height) / 2; + let y_slice = buffer.height() / 3; + let end_y = y_slice * (choice as u32 - 1) + (y_slice - self.options.line_height) / 2; + + drawing::draw_convex_polygon_mut( + buffer, + &[ + Point::new(start_x as i32, start_y as i32), + Point::new( + start_x as i32, + start_y as i32 + self.options.line_height as i32, + ), + Point::new(end_x as i32, end_y as i32 + self.options.line_height as i32), + Point::new(end_x as i32, end_y as i32), + ], + self.options.line_color, + ); + if tier == 2 { + return Ok(()); + } + + drawing::draw_convex_polygon_mut( + buffer, + &[ + Point::new( + 2 * x_slice as i32 + start_x as i32 - self.options.trait_size as i32, + start_y as i32, + ), + Point::new( + 2 * x_slice as i32 + start_x as i32 - self.options.trait_size as i32, + start_y as i32 + self.options.line_height as i32, + ), + Point::new( + self.options.trait_size as i32 + end_x as i32, + end_y as i32 + self.options.line_height as i32, + ), + Point::new(self.options.trait_size as i32 + end_x as i32, end_y as i32), + ], + self.options.line_color, + ); + + Ok(()) + } + + pub fn render_traitline(&mut self, traitline: &Traitline) -> Result { + let (spec, choices) = traitline; + let mut background = self.api.get_image(&spec.background)?; + let mut buffer = background + .crop( + 0, + background.height() - BG_CROP_HEIGHT, + BG_CROP_WIDTH, + BG_CROP_HEIGHT, + ) + .brighten(self.options.traitline_brightness) + .resize( + self.options.traitline_width, + self.options.traitline_height, + imageops::FilterType::CatmullRom, + ) + .to_rgba(); + + let minor_traits = self.api.get_traits(&spec.minor_traits)?; + for minor in &minor_traits { + self.render_minor_trait(&mut buffer, &minor)?; + } + + let major_traits = self.api.get_traits(&spec.major_traits)?; + for (i, major) in major_traits.iter().enumerate() { + let choice = choices[major.tier as usize - 1]; + let vert_pos = (i % 3) as u8; + let chosen = choice as u8 == vert_pos + 1; + self.render_major_trait(&mut buffer, &major, vert_pos, chosen)?; + } + + for (tier, choice) in choices.iter().enumerate() { + self.render_line(&mut buffer, tier as u8, *choice)?; + } + Ok(buffer) + } + + pub fn render_specialization_name( + &mut self, + specialization: &Specialization, + ) -> Result { + let scale = self.options.text_size; + let mut buffer = ImageBuffer::new(self.options.traitline_width, scale); + drawing::draw_text_mut( + &mut buffer, + self.options.text_color, + 0, + 0, + Scale { + x: scale as f32, + y: scale as f32, + }, + &self.options.font, + &specialization.name, + ); + Ok(buffer) + } + + pub fn render_buildtemplate( + &mut self, + build: &BuildTemplate, + ) -> Result { + let images = self.construct_parts(build)?; + self.merge_parts(&images) + } + + fn construct_parts(&mut self, build: &BuildTemplate) -> Result, RenderError> { + let mut images: Vec = Vec::new(); + + for traitline in build.traitlines().iter().filter(|x| x.is_some()) { + let traitline = traitline.as_ref().unwrap(); + if self.options.render_specialization_names { + let header = self.render_specialization_name(&traitline.0)?; + images.push(header); + } + let inner = self.render_traitline(&traitline)?; + images.push(inner); + } + + let needs_space = build.skill_count() > 0 && build.traitline_count() > 0; + if needs_space { + let separator = ImageBuffer::from_pixel( + self.options.traitline_width, + self.options.skill_offset, + self.options.background_color, + ); + images.push(separator); + } + + if build.skill_count() > 0 { + let skills = self.render_skills(build.skills())?; + images.push(skills); + } + + Ok(images) + } + + fn merge_parts(&mut self, images: &[RgbaImage]) -> Result { + let width = images.iter().map(RgbaImage::width).max().unwrap(); + let height = images.iter().map(RgbaImage::height).sum(); + let mut buffer = ImageBuffer::from_pixel(width, height, self.options.background_color); + let mut pos_y = 0; + + for image in images { + imageops::overlay(&mut buffer, image, 0, pos_y); + pos_y += image.height(); + } + + Ok(buffer) + } +} -- cgit v1.2.3