use super::api::{Api, ApiError, Skill, Specialization, Trait}; use super::bt::{self, BuildTemplate, ExtraData, TraitChoice, Traitline}; use image::{ imageops, imageops::FilterType::CatmullRom, DynamicImage, GenericImage, GenericImageView, ImageBuffer, Pixel, Primitive, Rgba, RgbaImage, }; use imageproc::{drawing, rect::Rect}; use num_traits::{Num, NumCast}; use rusttype::{Font, Scale}; use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Error, Debug)] pub enum RenderError { #[error("Error accessing the API")] ApiError(#[from] ApiError), #[error("Image processing error")] ImageError(#[from] image::ImageError), #[error("The build template contains nothing worth rendering")] EmptyBuild, } #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Alignment { Left, Center, Right, } #[derive(Debug, Clone)] pub struct RenderOptions { pub skill_size: u32, pub traitline_height: u32, pub traitline_width: u32, pub traitline_brightness: i32, pub traitline_use_gradient: bool, pub traitline_gradient_size: u32, pub traitline_x_offset: u32, pub trait_size: u32, pub line_color: Rgba, pub line_height: u32, pub font: Font<'static>, pub text_color: Rgba, pub text_size: u32, pub background_color: Rgba, pub render_specialization_names: bool, pub skill_offset: u32, pub skill_alignment: Alignment, pub legend_size: u32, pub legend_alignment: Alignment, pub specialization_name_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::try_from_bytes(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, legend_size: 130, legend_alignment: Alignment::Center, specialization_name_alignment: Alignment::Left, } } } // 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; const MIDDLE_COL: u32 = 1; 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, grid: Grid, } 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); let grid = Grid { x_offset: options.traitline_x_offset, width: options.traitline_width - options.traitline_x_offset, height: options.traitline_height, rows: TRAITS_PER_TIER, cols: TOTAL_COLUMN_COUNT, }; Renderer { api, options, minor_mask, major_mask, grid, } } 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) } pub fn render_legends(&mut self, legends: &[bt::Legend]) -> Result { let legend_ids = legends .iter() .filter_map(|l| l.api_id()) .collect::>(); let api_legends = self.api.get_legends(&legend_ids)?; let mut buffer = ImageBuffer::from_pixel( api_legends.len() as u32 * self.options.legend_size, self.options.legend_size, self.options.background_color, ); for (i, legend) in api_legends.iter().enumerate() { let swapper = self .api .get_skills(&[legend.swap])? .into_iter() .next() .unwrap(); let img = self.api.get_image(&swapper.icon)?.resize( self.options.legend_size, self.options.legend_size, CatmullRom, ); buffer.copy_from(&img, i as u32 * self.options.legend_size, 0)?; } Ok(buffer) } fn render_minor_trait( &mut self, buffer: &mut RgbaImage, minor: &Trait, ) -> Result<(), RenderError> { let trait_size = self.options.trait_size; let minor_img = self .api .get_image(&minor.icon)? .resize(trait_size, trait_size, CatmullRom); let minor_img = with_mask(&minor_img, &self.minor_mask); // Minor traits are always in the middle row, which should be 1. // We also need to skip the major columns, therefore we need to multiply the column number // by two. let (x, y) = self.grid.pos(MIDDLE_COL, 2 * (minor.tier - 1)); imageops::overlay( buffer, &minor_img, (x - half(trait_size)).into(), (y - half(trait_size)).into(), ); Ok(()) } fn render_major_trait( &mut self, buffer: &mut RgbaImage, major: &Trait, vertical_pos: u8, chosen: bool, ) -> Result<(), RenderError> { let trait_size = self.options.trait_size; let major_img = self .api .get_image(&major.icon)? .resize(trait_size, trait_size, CatmullRom); let major_img = if !chosen { with_mask(&major_img.grayscale(), &major_img) } else { major_img.to_rgba8() }; let major_img = with_mask(&major_img, &self.major_mask); let (x, y) = self.grid.pos(vertical_pos as u32, 2 * (major.tier - 1) + 1); imageops::overlay( buffer, &major_img, (x - half(trait_size)).into(), (y - half(trait_size)).into(), ); Ok(()) } fn render_line( &mut self, buffer: &mut RgbaImage, tier: u8, choice: TraitChoice, ) -> Result<(), RenderError> { if choice == TraitChoice::None { return Ok(()); } let trait_size = self.options.trait_size; let (start_x, start_y) = self.grid.pos(MIDDLE_COL, 2 * tier as u32); let start_x = start_x + half(trait_size); let start_y = start_y - half(trait_size) + trait_size / MINOR_LINE_SPLIT * (choice as u32); let (end_x, end_y) = self.grid.pos(choice as u32 - 1, 2 * tier as u32 + 1); let end_x = end_x - half(trait_size); 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 + trait_size, end_y), (end_x + self.grid.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_rgba8(); 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 text = &specialization.name; let scale = Scale { x: self.options.text_size as f32, y: self.options.text_size as f32, }; let width = text_width(text, &self.options.font, scale); let mut buffer = ImageBuffer::from_pixel(width, self.options.text_size, self.options.background_color); drawing::draw_text_mut( &mut buffer, self.options.text_color, 0, 0, scale, &self.options.font, text, ); 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((self.options.specialization_name_alignment, header)); } let inner = self.render_traitline(traitline)?; images.push((Alignment::Left, inner)); } let is_rev = matches!(build.extra_data(), ExtraData::Legends(_)); let needs_space = (is_rev || 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 let ExtraData::Legends(legends) = build.extra_data() { let rendered_legends = self.render_legends(legends.as_slice())?; images.push((self.options.skill_alignment, rendered_legends)); } else 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 clamp(value: T, lower: T, upper: T) -> T { if value < lower { lower } else if value > upper { upper } else { value } } fn brighten_gradient( image: &I, start_x: u32, end_x: u32, value: i32, ) -> ImageBuffer> where I: GenericImageView, P: Pixel + 'static, S: Primitive + 'static, { 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)).into(), (half_y - half(line_length)).into(), ); } fn text_width(text: &str, font: &Font, scale: Scale) -> u32 { font.glyphs_for(text.chars()) .map(|glyph| glyph.scaled(scale).h_metrics().advance_width) .sum::() as u32 } /// A helper structure representing a grid of squares. /// /// This can be used to calculate the centers of the traits that should be placed on the /// traitlines. struct Grid { x_offset: u32, width: u32, height: u32, rows: u32, cols: u32, } impl Grid { /// Return the coordinates of the center of the given square. #[inline(always)] fn pos(&self, row: u32, col: u32) -> (u32, u32) { assert!( row < self.rows, "row {} is outside the bounds ({})", row, self.rows ); assert!( col < self.cols, "col {} is outside the bounds ({})", col, self.cols ); let x_slice = self.x_slice(); let y_slice = self.y_slice(); let x_pos = x_slice * col + self.x_offset + half(x_slice); let y_pos = y_slice * row + half(y_slice); (x_pos, y_pos) } /// Returns the width of a x slice. #[inline(always)] fn x_slice(&self) -> u32 { self.width / self.cols } /// Returns the height of an y slice. #[inline(always)] fn y_slice(&self) -> u32 { self.height / self.rows } }