use super::api::{Api, ApiError, Skill, Specialization, Trait}; use super::bt::{BuildTemplate, TraitChoice, Traitline}; use image::{ imageops, CatmullRom, DynamicImage, GenericImage, GenericImageView, ImageBuffer, Pixel, Primitive, Rgba, RgbaImage, }; use imageproc::{drawing, rect::Rect}; use num_traits::{Num, NumCast}; use rusttype::{Font, Scale, SharedBytes}; use std::{error::Error, fmt}; #[derive(Debug)] pub enum RenderError { ApiError(ApiError), ImageError(image::ImageError), EmptyBuild, } error_froms! { RenderError, err: ApiError => RenderError::ApiError(err), err: image::ImageError => RenderError::ImageError(err), } impl fmt::Display for RenderError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { RenderError::ApiError(_) => write!(f, "error accessing the API"), RenderError::ImageError(_) => write!(f, "image processing error"), RenderError::EmptyBuild => { write!(f, "the build template contains nothing worth rendering") } } } } impl Error for RenderError { fn source(&self) -> Option<&(dyn Error + 'static)> { match *self { RenderError::ApiError(ref err) => Some(err), RenderError::ImageError(ref err) => Some(err), _ => None, } } } #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum Alignment { Left, Center, Right, } #[derive(Debug, Clone)] pub struct RenderOptions { skill_size: u32, traitline_height: u32, traitline_width: u32, traitline_brightness: i32, traitline_use_gradient: bool, traitline_gradient_size: u32, 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, skill_alignment: Alignment, } impl Default for RenderOptions { fn default() -> Self { RenderOptions { skill_size: 100, traitline_height: 137, traitline_width: 647, traitline_brightness: 100, traitline_use_gradient: true, traitline_gradient_size: 50, traitline_x_offset: 200, 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, skill_alignment: Alignment::Center, } } } // These values were determined from the thief specialization background images, as those do not // have anything fancy going on and are instead transparent. Therefore, we can use them as a // "reference" for the intended specialization crop size. // // Note: Increasing those values will mean that thief build templates will end up with more // transparency than intended. const BG_CROP_HEIGHT: u32 = 136; const BG_CROP_WIDTH: u32 = 647; const TRAITS_PER_TIER: u32 = 3; const TIER_COUNT: u32 = 3; const TOTAL_COLUMN_COUNT: u32 = 2 * TIER_COUNT; const MINOR_LINE_SPLIT: u32 = 4; fn half(input: T) -> T { let two = T::one() + T::one(); input / two } pub struct Renderer<'r> { api: &'r mut Api, options: RenderOptions, minor_mask: DynamicImage, major_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("Minor mask image could not be loaded") .resize(options.trait_size, options.trait_size, CatmullRom); let major_mask = image::load_from_memory(include_bytes!("major_trait_mask.png")) .expect("Major mask image could not be loaded") .resize(options.trait_size, options.trait_size, CatmullRom); Renderer { api, options, minor_mask, major_mask, } } pub fn render_skills(&mut self, skills: &[Option]) -> Result { let mut buffer = ImageBuffer::from_pixel( skills.len() as u32 * self.options.skill_size, self.options.skill_size, self.options.background_color, ); 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, 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, CatmullRom, ); let minor_img = with_mask(&minor_img, &self.minor_mask); let y_pos = half(buffer.height() - minor_img.height()); let x_slice = (buffer.width() - self.options.traitline_x_offset) / TOTAL_COLUMN_COUNT; let x_pos = 2 * (minor.tier - 1) * x_slice + half(x_slice - minor_img.width()) + 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, CatmullRom, ); let major_img = if !chosen { with_mask(&major_img.grayscale(), &major_img) } else { major_img.to_rgba() }; let major_img = with_mask(&major_img, &self.major_mask); let y_slice = buffer.height() / TRAITS_PER_TIER; let y_pos = vertical_pos as u32 * y_slice + half(y_slice - major_img.height()); let x_slice = (buffer.width() - self.options.traitline_x_offset) / TOTAL_COLUMN_COUNT; let x_pos = 2 * (major.tier - 1) * x_slice + x_slice + half(x_slice - major_img.width()) + 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) / TOTAL_COLUMN_COUNT; let start_x = 2 * tier as u32 * x_slice + half(x_slice + self.options.trait_size) + self.options.traitline_x_offset; let end_x = start_x + x_slice - self.options.trait_size; let start_y = half(buffer.height() - self.options.trait_size) + self.options.trait_size / MINOR_LINE_SPLIT * (choice as u32); let y_slice = buffer.height() / TRAITS_PER_TIER; let end_y = y_slice * (choice as u32 - 1) + half(y_slice); draw_thick_line( buffer, (start_x, start_y), (end_x, end_y), self.options.line_height, self.options.line_color, ); if tier as u32 == TIER_COUNT - 1 { return Ok(()); } draw_thick_line( buffer, (end_x + self.options.trait_size, end_y), (end_x + x_slice, start_y), self.options.line_height, 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, ) .resize( self.options.traitline_width, self.options.traitline_height, imageops::FilterType::CatmullRom, ) .to_rgba(); if self.options.traitline_use_gradient { buffer = brighten_gradient( &buffer, self.options.traitline_x_offset - self.options.traitline_gradient_size, self.options.traitline_x_offset, self.options.traitline_brightness, ); } else { buffer = imageops::colorops::brighten(&buffer, self.options.traitline_brightness); } 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 as u32 % TRAITS_PER_TIER) 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::from_pixel( self.options.traitline_width, scale, self.options.background_color, ); 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)?; if images.is_empty() { return Err(RenderError::EmptyBuild); } self.merge_parts(&images) } fn construct_parts( &mut self, build: &BuildTemplate, ) -> Result, RenderError> { let mut images: Vec<(Alignment, RgbaImage)> = 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((Alignment::Left, header)); } let inner = self.render_traitline(&traitline)?; images.push((Alignment::Left, 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((Alignment::Left, separator)); } if build.skill_count() > 0 { let skills = self.render_skills(build.skills())?; images.push((self.options.skill_alignment, skills)); } Ok(images) } fn merge_parts(&mut self, images: &[(Alignment, RgbaImage)]) -> Result { let width = images.iter().map(snd).map(RgbaImage::width).max().unwrap(); let height = images.iter().map(snd).map(RgbaImage::height).sum(); let mut buffer = ImageBuffer::from_pixel(width, height, self.options.background_color); let mut pos_y = 0; for (alignment, image) in images { let pos_x = match alignment { Alignment::Left => 0, Alignment::Center => half(width - image.width()), Alignment::Right => width - image.width(), }; buffer.copy_from(image, pos_x, pos_y); pos_y += image.height(); } Ok(buffer) } } fn snd(input: &(A, B)) -> &B { &input.1 } fn with_mask(input: &I, mask: &J) -> RgbaImage where I: GenericImage>, J: GenericImage>, { imageproc::map::map_pixels(input, |x, y, p| { let alpha = mask.get_pixel(x, y)[3]; Rgba([p[0], p[1], p[2], alpha]) }) } fn brighten_gradient( image: &I, start_x: u32, end_x: u32, value: i32, ) -> ImageBuffer> where I: GenericImageView, P: Pixel + 'static, S: Primitive + 'static, { use image::math::utils::clamp; let (width, height) = image.dimensions(); let mut out = ImageBuffer::new(width, height); let max = S::max_value(); let max: i32 = NumCast::from(max).unwrap(); for y in 0..height { for x in 0..width { let e = image.get_pixel(x, y).map_with_alpha( |b| { let interpol_value = if x < start_x { 0 } else if x > end_x { value } else { let frac = (x - start_x) as f32 / (end_x - start_x) as f32; (frac * value as f32).round() as i32 }; let c: i32 = NumCast::from(b).unwrap(); let d = clamp(c + interpol_value, 0, max); NumCast::from(d).unwrap() }, |alpha| alpha, ); out.put_pixel(x, y, e); } } out } fn draw_thick_line( image: &mut I, start: (u32, u32), end: (u32, u32), thickness: u32, color: I::Pixel, ) where I: GenericImage>, { let delta_x = end.0 as i32 - start.0 as i32; let delta_y = end.1 as i32 - start.1 as i32; let line_length = ((delta_x * delta_x + delta_y * delta_y) as f32) .sqrt() .round() as u32; let mut line_buffer: RgbaImage = ImageBuffer::new(line_length, line_length); let halfway = half(line_length - thickness); let rect = Rect::at(0, halfway as i32).of_size(line_length, thickness); drawing::draw_filled_rect_mut(&mut line_buffer, rect, color); line_buffer = imageproc::geometric_transformations::rotate_about_center( &line_buffer, (delta_y as f32 / delta_x as f32).atan(), imageproc::geometric_transformations::Interpolation::Bicubic, Rgba([0, 0, 0, 0]), ); let half_x = (start.0 as i32 + half(delta_x)) as u32; let half_y = (start.1 as i32 + half(delta_y)) as u32; imageops::overlay( image, &line_buffer, half_x - half(line_length), half_y - half(line_length), ); }