diff --git a/data/assets/diff-byd.png b/data/assets/diff-byd.png deleted file mode 100644 index 22b9eff..0000000 Binary files a/data/assets/diff-byd.png and /dev/null differ diff --git a/data/assets/diff-etr.png b/data/assets/diff-etr.png deleted file mode 100644 index 1671368..0000000 Binary files a/data/assets/diff-etr.png and /dev/null differ diff --git a/data/assets/diff-ftr.png b/data/assets/diff-ftr.png deleted file mode 100644 index af5b3d5..0000000 Binary files a/data/assets/diff-ftr.png and /dev/null differ diff --git a/data/assets/diff-prs.png b/data/assets/diff-prs.png deleted file mode 100644 index 0265965..0000000 Binary files a/data/assets/diff-prs.png and /dev/null differ diff --git a/data/assets/diff-pst.png b/data/assets/diff-pst.png deleted file mode 100644 index e57182f..0000000 Binary files a/data/assets/diff-pst.png and /dev/null differ diff --git a/data/assets/grade_background.png b/data/assets/grade_background.png new file mode 100644 index 0000000..35468b4 Binary files /dev/null and b/data/assets/grade_background.png differ diff --git a/data/assets/name_background.png b/data/assets/name_background.png new file mode 100644 index 0000000..dd50a1f Binary files /dev/null and b/data/assets/name_background.png differ diff --git a/data/assets/placeholder-jacket.jpg b/data/assets/placeholder_jacket.jpg similarity index 100% rename from data/assets/placeholder-jacket.jpg rename to data/assets/placeholder_jacket.jpg diff --git a/data/assets/ptt_emblem.png b/data/assets/ptt_emblem.png new file mode 100644 index 0000000..1106fbc Binary files /dev/null and b/data/assets/ptt_emblem.png differ diff --git a/data/assets/score_background.png b/data/assets/score_background.png new file mode 100644 index 0000000..08d7cd9 Binary files /dev/null and b/data/assets/score_background.png differ diff --git a/data/assets/status_background.png b/data/assets/status_background.png new file mode 100644 index 0000000..e0c2ef9 Binary files /dev/null and b/data/assets/status_background.png differ diff --git a/data/assets/top_background.png b/data/assets/top_background.png new file mode 100644 index 0000000..a4d4f37 Binary files /dev/null and b/data/assets/top_background.png differ diff --git a/src/assets.rs b/src/assets.rs index 1f76257..aba0dd8 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -2,28 +2,34 @@ use std::{cell::RefCell, env::var, path::PathBuf, str::FromStr, sync::OnceLock}; use freetype::{Face, Library}; +use image::{imageops::FilterType, ImageBuffer, Rgb, Rgba}; + +use crate::chart::Difficulty; #[inline] -fn get_data_dir() -> PathBuf { +pub fn get_data_dir() -> PathBuf { PathBuf::from_str(&var("SHIMMERING_DATA_DIR").expect("Missing `SHIMMERING_DATA_DIR` env var")) .expect("`SHIMMERING_DATA_DIR` is not a valid path") } #[inline] -fn get_font(name: &str, assets_dir: &PathBuf) -> RefCell { +pub fn get_assets_dir() -> PathBuf { + get_data_dir().join("assets") +} + +#[inline] +fn get_font(name: &str) -> RefCell { let face = FREETYPE_LIB.with(|lib| { - lib.new_face(assets_dir.join(format!("{}-variable.ttf", name)), 0) + lib.new_face(get_assets_dir().join(format!("{}-variable.ttf", name)), 0) .expect(&format!("Could not load {} font", name)) }); RefCell::new(face) } thread_local! { -pub static DATA_DIR: PathBuf = get_data_dir(); -pub static ASSETS_DIR: PathBuf = DATA_DIR.with(|p| p.join("assets")); pub static FREETYPE_LIB: Library = Library::init().unwrap(); -pub static SAIRA_FONT: RefCell = ASSETS_DIR.with(|assets_dir| get_font("saira", assets_dir)); -pub static EXO_FONT: RefCell = ASSETS_DIR.with(|assets_dir| get_font("exo", assets_dir)); +pub static SAIRA_FONT: RefCell = get_font("saira"); +pub static EXO_FONT: RefCell = get_font("exo"); } #[inline] @@ -31,3 +37,100 @@ pub fn should_skip_jacket_art() -> bool { static CELL: OnceLock = OnceLock::new(); *CELL.get_or_init(|| var("SHIMMERING_NO_JACKETS").unwrap_or_default() == "1") } + +pub fn get_b30_background() -> &'static ImageBuffer, Vec> { + static CELL: OnceLock, Vec>> = OnceLock::new(); + CELL.get_or_init(|| { + let raw_b30_background = image::open(get_assets_dir().join("b30_background.jpg")) + .expect("Could not open b30 background"); + + raw_b30_background + .resize( + 3 * raw_b30_background.width(), + 3 * raw_b30_background.height(), + FilterType::Lanczos3, + ) + .blur(7.0) + .into_rgb8() + }) +} + +pub fn get_count_background() -> &'static ImageBuffer, Vec> { + static CELL: OnceLock, Vec>> = OnceLock::new(); + CELL.get_or_init(|| { + image::open(get_assets_dir().join("count_background.png")) + .expect("Could not open count background") + .into_rgba8() + }) +} + +pub fn get_score_background() -> &'static ImageBuffer, Vec> { + static CELL: OnceLock, Vec>> = OnceLock::new(); + CELL.get_or_init(|| { + image::open(get_assets_dir().join("score_background.png")) + .expect("Could not open score background") + .into_rgba8() + }) +} + +pub fn get_status_background() -> &'static ImageBuffer, Vec> { + static CELL: OnceLock, Vec>> = OnceLock::new(); + CELL.get_or_init(|| { + image::open(get_assets_dir().join("status_background.png")) + .expect("Could not open status background") + .into_rgba8() + }) +} + +pub fn get_grade_background() -> &'static ImageBuffer, Vec> { + static CELL: OnceLock, Vec>> = OnceLock::new(); + CELL.get_or_init(|| { + image::open(get_assets_dir().join("grade_background.png")) + .expect("Could not open grade background") + .into_rgba8() + }) +} + +pub fn get_top_backgound() -> &'static ImageBuffer, Vec> { + static CELL: OnceLock, Vec>> = OnceLock::new(); + CELL.get_or_init(|| { + image::open(get_assets_dir().join("top_background.png")) + .expect("Could not open top background") + .into_rgb8() + }) +} + +pub fn get_name_backgound() -> &'static ImageBuffer, Vec> { + static CELL: OnceLock, Vec>> = OnceLock::new(); + CELL.get_or_init(|| { + image::open(get_assets_dir().join("name_background.png")) + .expect("Could not open name background") + .into_rgb8() + }) +} + +pub fn get_ptt_emblem() -> &'static ImageBuffer, Vec> { + static CELL: OnceLock, Vec>> = OnceLock::new(); + CELL.get_or_init(|| { + image::open(get_assets_dir().join("ptt_emblem.png")) + .expect("Could not open ptt emblem") + .into_rgba8() + }) +} + +pub fn get_difficulty_background( + difficulty: Difficulty, +) -> &'static ImageBuffer, Vec> { + static CELL: OnceLock<[ImageBuffer, Vec>; 5]> = OnceLock::new(); + &CELL.get_or_init(|| { + let assets_dir = get_assets_dir(); + Difficulty::DIFFICULTY_SHORTHANDS.map(|shorthand| { + image::open(assets_dir.join(format!("diff_{}.png", shorthand.to_lowercase()))) + .expect(&format!( + "Could not get background for difficulty {:?}", + shorthand + )) + .into_rgba8() + }) + })[difficulty.to_index()] +} diff --git a/src/bitmap.rs b/src/bitmap.rs index c3a96e2..9aebb4d 100644 --- a/src/bitmap.rs +++ b/src/bitmap.rs @@ -2,15 +2,103 @@ use freetype::{ bitmap::PixelMode, face::{KerningMode, LoadFlag}, ffi::{FT_Err_Ok, FT_Set_Var_Design_Coordinates, FT_GLYPH_BBOX_PIXELS}, - Face, FtResult, Stroker, StrokerLineCap, StrokerLineJoin, + Bitmap, BitmapGlyph, Face, FtResult, Glyph, StrokerLineCap, StrokerLineJoin, }; +use image::GenericImage; use num::traits::Euclid; use crate::{assets::FREETYPE_LIB, context::Error}; -// {{{ Config types -pub type Color = (u8, u8, u8, u8); +// {{{ Color +#[derive(Debug, Clone, Copy)] +pub struct Color(pub u8, pub u8, pub u8, pub u8); +impl Color { + pub const BLACK: Self = Self::from_rgb_int(0x000000); + pub const WHITE: Self = Self::from_rgb_int(0xffffff); + + #[inline] + pub const fn from_rgba_int(i: u32) -> Self { + Self( + (i >> 24) as u8, + ((i >> 16) & 0xff) as u8, + ((i >> 8) & 0xff) as u8, + (i & 0xff) as u8, + ) + } + + #[inline] + pub const fn from_rgb_int(i: u32) -> Self { + Self::from_rgba_int((i << 8) + 0xff) + } + + #[inline] + pub fn alpha(mut self, a: u8) -> Self { + self.3 = a; + self + } +} +// }}} +// {{{ Rect +#[derive(Debug, Clone, Copy)] +pub struct Rect { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +impl Rect { + #[inline] + pub fn new(x: i32, y: i32, width: u32, height: u32) -> Self { + Self { + x, + y, + width, + height, + } + } + + #[inline] + pub fn from_extremes(x_min: i32, y_min: i32, x_max: i32, y_max: i32) -> Self { + Self::new(x_min, y_min, (x_max - x_min) as u32, (y_max - y_min) as u32) + } + + #[inline] + pub fn from_image(image: &impl GenericImage) -> Self { + Self::new(0, 0, image.width(), image.height()) + } + + #[inline] + pub fn align(&self, alignment: (Align, Align), pos: Position) -> Position { + ( + pos.0 - alignment.0.scale(self.width) as i32, + pos.1 - alignment.1.scale(self.height) as i32, + ) + } + + #[inline] + pub fn align_whole(&self, alignment: (Align, Align), pos: Position) -> Self { + let pos = self.align(alignment, pos); + Self::new(pos.0, pos.1, self.width, self.height) + } + + #[inline] + pub fn center(&self) -> Position { + ( + self.x + self.width as i32 / 2, + self.y + self.height as i32 / 2, + ) + } + + #[inline] + pub fn top_left(&self) -> Position { + (self.x, self.y) + } +} +// }}} +// {{{ Align +#[allow(dead_code)] #[derive(Debug, Clone, Copy)] pub enum Align { Start, @@ -18,13 +106,32 @@ pub enum Align { End, } +impl Align { + #[inline] + pub fn scale(self, dist: u32) -> u32 { + match self { + Self::Start => 0, + Self::Center => dist / 2, + Self::End => dist, + } + } +} +// }}} +// {{{ Other types +pub type Position = (i32, i32); + +fn float_to_ft_fixed(f: f32) -> i64 { + (f * 64.0) as i64 +} + #[derive(Debug, Clone, Copy)] pub struct TextStyle { pub size: u32, pub weight: u32, pub color: Color, - pub h_align: Align, - pub v_align: Align, + pub align: (Align, Align), + pub stroke: Option<(Color, f32)>, + pub drop_shadow: Option<(Color, Position)>, } // }}} // {{{ BitmapCanvas @@ -48,7 +155,7 @@ impl BitmapCanvas { // }}} // {{{ Draw RBG image /// Draws a bitmap image - pub fn blit_rbg(&mut self, pos: (i32, i32), (iw, ih): (u32, u32), src: &[u8]) { + pub fn blit_rbg(&mut self, pos: Position, (iw, ih): (u32, u32), src: &[u8]) { let height = self.buffer.len() as u32 / 3 / self.width; for dx in 0..iw { for dy in 0..ih { @@ -59,7 +166,7 @@ impl BitmapCanvas { let g = src[(dx + dy * iw) as usize * 3 + 1]; let b = src[(dx + dy * iw) as usize * 3 + 2]; - let color = (r, g, b, 255); + let color = Color(r, g, b, 0xff); self.set_pixel((x as u32, y as u32), color); } @@ -69,7 +176,7 @@ impl BitmapCanvas { // }}} // {{{ Draw RGBA image /// Draws a bitmap image taking care of the alpha channel. - pub fn blit_rbga(&mut self, pos: (i32, i32), (iw, ih): (u32, u32), src: &[u8]) { + pub fn blit_rbga(&mut self, pos: Position, (iw, ih): (u32, u32), src: &[u8]) { let height = self.buffer.len() as u32 / 3 / self.width; for dx in 0..iw { for dy in 0..ih { @@ -81,7 +188,7 @@ impl BitmapCanvas { let b = src[(dx + dy * iw) as usize * 4 + 2]; let a = src[(dx + dy * iw) as usize * 4 + 3]; - let color = (r, g, b, a); + let color = Color(r, g, b, a); self.set_pixel((x as u32, y as u32), color); } @@ -91,7 +198,7 @@ impl BitmapCanvas { // }}} // {{{ Fill /// Fill with solid color - pub fn fill(&mut self, pos: (i32, i32), (iw, ih): (u32, u32), color: Color) { + pub fn fill(&mut self, pos: Position, (iw, ih): (u32, u32), color: Color) { let height = self.buffer.len() as u32 / 3 / self.width; for dx in 0..iw { for dy in 0..ih { @@ -105,15 +212,13 @@ impl BitmapCanvas { } // }}} // {{{ Draw text - // TODO: perform gamma correction on the color interpolation. - /// Render text - pub fn text( + pub fn plan_text_rendering( &mut self, - pos: (i32, i32), + pos: Position, face: &mut Face, style: TextStyle, text: &str, - ) -> Result<(), Error> { + ) -> Result<(Position, Rect, Vec<(i64, Glyph)>), Error> { // {{{ Control weight unsafe { let raw = face.raw_mut() as *mut _; @@ -135,6 +240,7 @@ impl BitmapCanvas { } } // }}} + face.set_char_size((style.size << 6) as isize, 0, 0, 0)?; // {{{ Compute layout @@ -200,81 +306,102 @@ impl BitmapCanvas { y_max = 0; } - // println!("{}, {} - {}, {}", x_min, y_min, x_max, y_max); - + let bbox = Rect::from_extremes(x_min as i32, y_min as i32, x_max as i32, y_max as i32); + let pos = bbox.align(style.align, pos); // }}} + + Ok((pos, bbox, data)) + } + + /// Render text + pub fn text( + &mut self, + pos: Position, + face: &mut Face, + style: TextStyle, + text: &str, + ) -> Result<(), Error> { + let (pos, bbox, data) = self.plan_text_rendering(pos, face, style, text)?; + // {{{ Render glyphs for (pos_x, glyph) in &data { let b_glyph = glyph.to_bitmap(freetype::RenderMode::Normal, None)?; let bitmap = b_glyph.bitmap(); let pixel_mode = bitmap.pixel_mode()?; assert_eq!(pixel_mode, PixelMode::Gray); - println!("starting to stroke"); - // {{{ Blit border - let stroker = FREETYPE_LIB.with(|lib| lib.new_stroker())?; - stroker.set(1 << 6, StrokerLineCap::Round, StrokerLineJoin::Round, 0); - let sglyph = glyph.stroke(&stroker)?; - let sb_glyph = sglyph.to_bitmap(freetype::RenderMode::Normal, None)?; - let sbitmap = sb_glyph.bitmap(); - let spixel_mode = sbitmap.pixel_mode()?; - assert_eq!(spixel_mode, PixelMode::Gray); + let char_pos = ( + pos.0 + *pos_x as i32 - bbox.x, + pos.1 + bbox.height as i32 + bbox.y, + ); - let iw = sbitmap.width(); - let ih = sbitmap.rows(); - println!("pitch {}, width {}, height {}", sbitmap.pitch(), iw, ih); - let height = self.buffer.len() as u32 / 3 / self.width; - let src = sbitmap.buffer(); - for dx in 0..iw { - for dy in 0..ih { - let x = pos.0 + *pos_x as i32 + dx as i32 + sb_glyph.left(); - let y = pos.1 + dy as i32 - sb_glyph.top(); - if x >= 0 && (x as u32) < self.width && y >= 0 && (y as u32) < height { - let gray = src[(dx + dy * iw) as usize]; - - let r = 255 - style.color.0; - let g = 255 - style.color.1; - let b = 255 - style.color.2; - let a = gray; - - let color = (r, g, b, a); - - self.set_pixel((x as u32, y as u32), color); - } - } + if let Some((shadow_color, offset)) = style.drop_shadow { + let char_pos = (char_pos.0 + offset.0, char_pos.1 + offset.1); + self.blit_glyph(&b_glyph, &bitmap, char_pos, shadow_color); } - // }}} - // {{{ Blit - let iw = bitmap.width(); - let ih = bitmap.rows(); - let height = self.buffer.len() as u32 / 3 / self.width; - let src = bitmap.buffer(); - for dx in 0..iw { - for dy in 0..ih { - let x = pos.0 + *pos_x as i32 + dx as i32 + b_glyph.left(); - let y = pos.1 + dy as i32 - b_glyph.top(); - if x >= 0 && (x as u32) < self.width && y >= 0 && (y as u32) < height { - let gray = src[(dx + dy * iw) as usize]; + if let Some((stroke_color, stroke_width)) = style.stroke { + // {{{ Create stroke + let stroker = FREETYPE_LIB.with(|lib| lib.new_stroker())?; + stroker.set( + float_to_ft_fixed(stroke_width), + StrokerLineCap::Round, + StrokerLineJoin::Round, + 0, + ); - let r = style.color.0; - let g = style.color.1; - let b = style.color.2; - let a = gray; + let sglyph = glyph.stroke(&stroker)?; + let sb_glyph = sglyph.to_bitmap(freetype::RenderMode::Normal, None)?; + let sbitmap = sb_glyph.bitmap(); + let spixel_mode = sbitmap.pixel_mode()?; + assert_eq!(spixel_mode, PixelMode::Gray); + // }}} - let color = (r, g, b, a); - - self.set_pixel((x as u32, y as u32), color); - } - } + self.blit_glyph(&sb_glyph, &sbitmap, char_pos, stroke_color); } - // }}} + + self.blit_glyph(&b_glyph, &bitmap, char_pos, style.color); } // }}} Ok(()) } // }}} + // {{{ Blit glyph + pub fn blit_glyph( + &mut self, + b_glyph: &BitmapGlyph, + bitmap: &Bitmap, + pos: Position, + color: Color, + ) { + let iw = bitmap.width(); + let ih = bitmap.rows(); + let height = self.buffer.len() as u32 / 3 / self.width; + let src = bitmap.buffer(); + + for dx in 0..iw { + for dy in 0..ih { + let x = pos.0 + dx as i32 + b_glyph.left(); + let y = pos.1 + dy as i32 - b_glyph.top(); + + // TODO: gamma correction + if x >= 0 && (x as u32) < self.width && y >= 0 && (y as u32) < height { + let gray = src[(dx + dy * iw) as usize]; + + let r = color.0; + let g = color.1; + let b = color.2; + let a = ((color.3 as u32 * gray as u32) / 0xff) as u8; + + let color = Color(r, g, b, a); + + self.set_pixel((x as u32, y as u32), color); + } + } + } + } + // }}} #[inline] pub fn new(width: u32, height: u32) -> Self { @@ -340,7 +467,7 @@ impl LayoutManager { y: i32, ) { let current = self.boxes[id.0]; - let to = self.boxes[id_relative_to.0]; + if let Some((current_points_to, dx, dy)) = current.relative_to && current_points_to != id_relative_to { @@ -352,7 +479,7 @@ impl LayoutManager { { let a = self.lookup(id); let b = self.lookup(id_relative_to); - assert_eq!((a.0 - b.0, a.1 - b.1), (x, y)); + assert_eq!((a.x - b.x, a.y - b.y), (x, y)); } } // }}} @@ -414,7 +541,7 @@ impl LayoutManager { &mut self, id: LayoutBoxId, amount: (u32, u32), - ) -> (LayoutBoxId, impl Iterator) { + ) -> (LayoutBoxId, impl Iterator) { let inner = self.boxes[id.0]; let outer_id = self.make_box(inner.width * amount.0, inner.height * amount.1); self.edit_to_relative(id, outer_id, 0, 0); @@ -429,13 +556,13 @@ impl LayoutManager { } // }}} // {{{ Lookup box - pub fn lookup(&self, id: LayoutBoxId) -> (i32, i32, u32, u32) { + pub fn lookup(&self, id: LayoutBoxId) -> Rect { let current = self.boxes[id.0]; if let Some((to, dx, dy)) = current.relative_to { - let (x, y, _, _) = self.lookup(to); - (x + dx, y + dy, current.width, current.height) + let r = self.lookup(to); + Rect::new(r.x + dx, r.y + dy, current.width, current.height) } else { - (0, 0, current.width, current.height) + Rect::new(0, 0, current.width, current.height) } } @@ -449,10 +576,17 @@ impl LayoutManager { self.boxes[id.0].height } + // }}} + // {{{ Alignment #[inline] - pub fn position_relative_to(&self, id: LayoutBoxId, pos: (i32, i32)) -> (i32, i32) { + pub fn position_relative_to(&self, id: LayoutBoxId, pos: Position) -> Position { let current = self.lookup(id); - ((pos.0 as i32 + current.0), (pos.1 as i32 + current.1)) + ((pos.0 as i32 + current.x), (pos.1 as i32 + current.y)) + } + + #[inline] + pub fn align(&self, id: LayoutBoxId, align: (Align, Align), pos: Position) -> Position { + self.lookup(id).align(align, pos) } // }}} } @@ -473,14 +607,14 @@ impl LayoutDrawer { // }}} // {{{ Draw RGB image /// Draws a bitmap image - pub fn blit_rbg(&mut self, id: LayoutBoxId, pos: (i32, i32), dims: (u32, u32), src: &[u8]) { + pub fn blit_rbg(&mut self, id: LayoutBoxId, pos: Position, dims: (u32, u32), src: &[u8]) { let pos = self.layout.position_relative_to(id, pos); self.canvas.blit_rbg(pos, dims, src); } // }}} // {{{ Draw RGBA image /// Draws a bitmap image taking care of the alpha channel. - pub fn blit_rbga(&mut self, id: LayoutBoxId, pos: (i32, i32), dims: (u32, u32), src: &[u8]) { + pub fn blit_rbga(&mut self, id: LayoutBoxId, pos: Position, dims: (u32, u32), src: &[u8]) { let pos = self.layout.position_relative_to(id, pos); self.canvas.blit_rbga(pos, dims, src); } @@ -489,8 +623,11 @@ impl LayoutDrawer { /// Fills with solid color pub fn fill(&mut self, id: LayoutBoxId, color: Color) { let current = self.layout.lookup(id); - self.canvas - .fill((current.0, current.1), (current.2, current.3), color); + self.canvas.fill( + (current.x, current.y), + (current.width, current.height), + color, + ); } // }}} // {{{ Draw text @@ -498,7 +635,7 @@ impl LayoutDrawer { pub fn text( &mut self, id: LayoutBoxId, - pos: (i32, i32), + pos: Position, face: &mut Face, style: TextStyle, text: &str, diff --git a/src/commands/stats.rs b/src/commands/stats.rs index d56e11c..cc13418 100644 --- a/src/commands/stats.rs +++ b/src/commands/stats.rs @@ -8,7 +8,7 @@ use plotters::{ drawing::IntoDrawingArea, element::Circle, series::LineSeries, - style::{Color, IntoFont, TextStyle, BLUE, WHITE}, + style::{IntoFont, TextStyle, BLUE, WHITE}, }; use poise::{ serenity_prelude::{CreateAttachment, CreateMessage}, @@ -17,8 +17,12 @@ use poise::{ use sqlx::query_as; use crate::{ - assets::EXO_FONT, - bitmap::{Align, BitmapCanvas, LayoutDrawer, LayoutManager}, + assets::{ + get_b30_background, get_count_background, get_difficulty_background, get_grade_background, + get_name_backgound, get_ptt_emblem, get_score_background, get_status_background, + get_top_backgound, EXO_FONT, + }, + bitmap::{Align, BitmapCanvas, Color, LayoutDrawer, LayoutManager, Rect}, chart::{Chart, Song}, context::{Context, Error}, jacket::BITMAP_IMAGE_SIZE, @@ -214,7 +218,7 @@ pub async fn plot( chart.draw_series( points .iter() - .map(|(t, s)| Circle::new((*t, *s), 3, BLUE.filled())), + .map(|(t, s)| Circle::new((*t, *s), 3, plotters::style::Color::filled(&BLUE))), )?; root.present()?; } @@ -280,16 +284,23 @@ pub async fn b30(ctx: Context<'_>) -> Result<(), Error> { let mut layout = LayoutManager::default(); let jacket_area = layout.make_box(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE); + let jacket_with_border = layout.margin_uniform(jacket_area, 3); let jacket_margin = 10; - let jacket_with_margin = - layout.margin(jacket_area, jacket_margin, jacket_margin, 5, jacket_margin); + let jacket_with_margin = layout.margin( + jacket_with_border, + jacket_margin, + jacket_margin, + 2, + jacket_margin, + ); let top_left_area = layout.make_box(90, layout.height(jacket_with_margin)); let top_area = layout.glue_vertically(top_left_area, jacket_with_margin); - let bottom_area = layout.make_box(layout.width(top_area), 40); + let bottom_area = layout.make_box(layout.width(top_area), 43); + let bottom_in_area = layout.margin_xy(bottom_area, -20, -7); let item_area = layout.glue_horizontally(top_area, bottom_area); - let item_with_margin = layout.margin_xy(item_area, 25, 20); + let item_with_margin = layout.margin_xy(item_area, 22, 17); let (item_grid, item_origins) = layout.repeated_evenly(item_with_margin, (5, 6)); - let root = item_grid; + let root = layout.margin_uniform(item_grid, 30); // layout.normalize(root); let width = layout.width(root); @@ -298,14 +309,14 @@ pub async fn b30(ctx: Context<'_>) -> Result<(), Error> { let canvas = BitmapCanvas::new(width, height); let mut drawer = LayoutDrawer::new(layout, canvas); - let asset_cache = &ctx.data().jacket_cache; - let bg = &asset_cache.b30_background; + let bg = get_b30_background(); drawer.blit_rbg( root, - ( - -((bg.width() - width) as i32) / 2, - -((bg.height() - height) as i32) / 2, + // Align the center of the image with the center of the root + Rect::from_image(bg).align( + (Align::Center, Align::Center), + drawer.layout.lookup(root).center(), ), bg.dimensions(), bg.as_raw(), @@ -316,10 +327,74 @@ pub async fn b30(ctx: Context<'_>) -> Result<(), Error> { .layout .edit_to_relative(item_with_margin, item_grid, origin.0, origin.1); - drawer.fill(top_area, (59, 78, 102, 255)); + let top_bg = get_top_backgound(); + drawer.blit_rbg(top_area, (0, 0), top_bg.dimensions(), top_bg); - let (_play, song, chart) = &plays[i]; + let (play, song, chart) = &plays[i]; + // {{{ Display index + let bg = get_count_background(); + let bg_center = Rect::from_image(bg).center(); + + // Draw background + drawer.blit_rbga(item_area, (-8, jacket_margin as i32), bg.dimensions(), bg); + + EXO_FONT.with_borrow_mut(|font| { + drawer.text( + item_area, + (bg_center.0 - 12, bg_center.1 - 3 + jacket_margin), + font, + crate::bitmap::TextStyle { + size: 25, + weight: 800, + color: Color::WHITE, + align: (Align::Center, Align::Center), + stroke: None, + drop_shadow: Some((Color::BLACK.alpha(0xaa), (2, 2))), + }, + &format!("#{}", i + 1), + ) + })?; + // }}} + // {{{ Display chart name + // Draw background + let bg = get_name_backgound(); + drawer.blit_rbg(bottom_area, (0, 0), bg.dimensions(), bg.as_raw()); + + // Draw text + EXO_FONT.with_borrow_mut(|font| { + let initial_size = 24; + let mut style = crate::bitmap::TextStyle { + size: initial_size, + weight: 800, + color: Color::WHITE, + align: (Align::Start, Align::Center), + stroke: Some((Color::BLACK, 1.5)), + drop_shadow: None, + }; + + while drawer + .canvas + .plan_text_rendering((0, 0), font, style, &song.title)? + .1 + .width >= drawer.layout.width(bottom_in_area) + { + style.size -= 3; + style.stroke = Some(( + Color::BLACK, + style.size as f32 / (initial_size as f32) * 1.5, + )); + } + + drawer.text( + bottom_in_area, + (0, drawer.layout.height(bottom_in_area) as i32 / 2), + font, + style, + &song.title, + ) + })?; + // }}} // {{{ Display jacket let jacket = chart.cached_jacket.as_ref().ok_or_else(|| { format!( @@ -328,6 +403,7 @@ pub async fn b30(ctx: Context<'_>) -> Result<(), Error> { ) })?; + drawer.fill(jacket_with_border, Color::from_rgb_int(0x271E35)); drawer.blit_rbg( jacket_area, (0, 0), @@ -336,13 +412,15 @@ pub async fn b30(ctx: Context<'_>) -> Result<(), Error> { ); // }}} // {{{ Display difficulty background - let diff_bg = &asset_cache.diff_backgrounds[chart.difficulty.to_index()]; + let diff_bg = get_difficulty_background(chart.difficulty); + let diff_bg_area = Rect::from_image(diff_bg).align_whole( + (Align::Center, Align::Center), + (drawer.layout.width(jacket_with_border) as i32, 0), + ); + drawer.blit_rbga( - jacket_area, - ( - BITMAP_IMAGE_SIZE as i32 - (diff_bg.width() as i32) / 2, - -(diff_bg.height() as i32) / 2, - ), + jacket_with_border, + diff_bg_area.top_left(), diff_bg.dimensions(), &diff_bg.as_raw(), ); @@ -356,92 +434,192 @@ pub async fn b30(ctx: Context<'_>) -> Result<(), Error> { 0 }; - // EXO_FONT.with_borrow_mut(|font| { - // drawer.text( - // jacket_area, - // (BITMAP_IMAGE_SIZE as i32 + x_offset - 30, 2), - // font, - // crate::bitmap::TextStyle { - // size: 40, - // weight: 250, - // color: (0xff, 0xff, 0xff, 0xff), - // h_align: Align::Center, - // v_align: Align::Center, - // }, - // &chart.level, - // ) - // })?; - // {{{ Display chart name - // Draw background - drawer.fill(bottom_area, (0x82, 0x71, 0xA7, 255)); + let diff_area_center = diff_bg_area.center(); - let tx = 10; - let ty = drawer.layout.height(bottom_area) as i32 / 2; - - // let text = &song.title; - // let mut size = 30; - // let mut text_style = TextStyle::from(("Exo", size).into_font().style(FontStyle::Bold)) - // .with_anchor::(Pos { - // h_pos: HPos::Left, - // v_pos: VPos::Center, - // }) - // .into_text_style(&bottom_area); - // - // while text_style.font.layout_box(text).unwrap().1 .0 >= item_area.0 as i32 - 20 { - // size -= 3; - // text_style.font = ("Exo", size).into_font(); - // } - // - // Draw drop shadow - // bottom_area.draw_text( - // &song.title, - // &text_style.color(&RGBAColor(0, 0, 0, 0.2)), - // (tx + 3, ty + 3), - // )?; - // bottom_area.draw_text( - // &song.title, - // &text_style.color(&RGBAColor(0, 0, 0, 0.2)), - // (tx - 3, ty + 3), - // )?; - // bottom_area.draw_text( - // &song.title, - // &text_style.color(&RGBAColor(0, 0, 0, 0.2)), - // (tx + 3, ty - 3), - // )?; - // bottom_area.draw_text( - // &song.title, - // &text_style.color(&RGBAColor(0, 0, 0, 0.2)), - // (tx - 3, ty - 3), - // )?; - - // Draw text - // bottom_area.draw_text(&song.title, &text_style.color(&WHITE), (tx, ty))?; + EXO_FONT.with_borrow_mut(|font| { + drawer.text( + jacket_with_border, + (diff_area_center.0 + x_offset, diff_area_center.1), + font, + crate::bitmap::TextStyle { + size: 25, + weight: 600, + color: Color::from_rgb_int(0xffffff), + align: (Align::Center, Align::Center), + stroke: None, + drop_shadow: None, + }, + &chart.level, + ) + })?; // }}} - // {{{ Display index - let bg = &asset_cache.count_background; + // {{{ Display score background + let score_bg = get_score_background(); + let score_bg_pos = Rect::from_image(score_bg).align( + (Align::End, Align::End), + ( + drawer.layout.width(jacket_area) as i32, + drawer.layout.height(jacket_area) as i32, + ), + ); - // Draw background - drawer.blit_rbga(item_area, (-8, jacket_margin as i32), bg.dimensions(), bg); + drawer.blit_rbga( + jacket_area, + score_bg_pos, + score_bg.dimensions(), + &score_bg.as_raw(), + ); + // }}} + // {{{ Display score text + EXO_FONT.with_borrow_mut(|font| { + drawer.text( + jacket_area, + ( + score_bg_pos.0 + 5, + score_bg_pos.1 + score_bg.height() as i32 / 2, + ), + font, + crate::bitmap::TextStyle { + size: 23, + weight: 800, + color: Color::WHITE, + align: (Align::Start, Align::Center), + stroke: Some((Color::BLACK, 1.5)), + drop_shadow: None, + }, + &format!("{:0>10}", format!("{}", play.score)), + ) + })?; + // }}} + // {{{ Display status background + let status_bg = get_status_background(); + let status_bg_area = Rect::from_image(status_bg).align_whole( + (Align::Center, Align::Center), + ( + drawer.layout.width(jacket_area) as i32 + 3, + drawer.layout.height(jacket_area) as i32 + 1, + ), + ); - // let text_style = TextStyle::from(("Exo", 30).into_font().style(FontStyle::Bold)) - // .with_anchor::(Pos { - // h_pos: HPos::Left, - // v_pos: VPos::Center, - // }) - // .into_text_style(&area); + drawer.blit_rbga( + jacket_area, + status_bg_area.top_left(), + status_bg.dimensions(), + &status_bg.as_raw(), + ); + // }}} + // {{{ Display status text + EXO_FONT.with_borrow_mut(|font| { + let status = play + .short_status(chart) + .ok_or_else(|| format!("Could not get status for score {}", play.score))?; - let tx = 7; - let ty = (jacket_margin + bg.height() as i32 / 2) - 3; + let x_offset = match status { + 'P' => 2, + 'M' => 2, + // TODO: ensure the F is rendered properly as well + _ => 0, + }; - // Draw drop shadow - // area.draw_text( - // &format!("#{}", i + 1), - // &text_style.color(&BLACK), - // (tx + 2, ty + 2), - // )?; + let center = status_bg_area.center(); - // Draw main text - // area.draw_text(&format!("#{}", i + 1), &text_style.color(&WHITE), (tx, ty))?; + drawer.text( + jacket_area, + (center.0 + x_offset, center.1), + font, + crate::bitmap::TextStyle { + size: if status == 'M' { 30 } else { 36 }, + weight: if status == 'M' { 800 } else { 500 }, + color: Color::WHITE, + align: (Align::Center, Align::Center), + stroke: None, + drop_shadow: None, + }, + &format!("{}", status), + ) + })?; + // }}} + // {{{ Display grade background + let top_left_center = (drawer.layout.width(top_left_area) as i32 + jacket_margin) / 2; + let grade_bg = get_grade_background(); + let grade_bg_area = Rect::from_image(grade_bg).align_whole( + (Align::Center, Align::Center), + (top_left_center, jacket_margin + 140), + ); + + drawer.blit_rbga( + top_area, + grade_bg_area.top_left(), + grade_bg.dimensions(), + &grade_bg.as_raw(), + ); + // }}} + // {{{ Display grade text + EXO_FONT.with_borrow_mut(|font| { + let grade = play.score.grade(); + let center = grade_bg_area.center(); + + drawer.text( + top_left_area, + (center.0, center.1), + font, + crate::bitmap::TextStyle { + size: 30, + weight: 650, + color: Color::from_rgb_int(0x203C6B), + align: (Align::Center, Align::Center), + stroke: Some((Color::WHITE, 1.5)), + drop_shadow: None, + }, + &format!("{}", grade), + ) + })?; + // }}} + // {{{ Display rating text + EXO_FONT.with_borrow_mut(|font| -> Result<(), Error> { + let mut style = crate::bitmap::TextStyle { + size: 12, + weight: 600, + color: Color::WHITE, + align: (Align::Center, Align::Center), + stroke: None, + drop_shadow: None, + }; + + drawer.text( + top_left_area, + (top_left_center, 73), + font, + style, + "POTENTIAL", + )?; + + style.size = 25; + style.weight = 700; + + drawer.text( + top_left_area, + (top_left_center, 94), + font, + style, + &format!( + "{:.2}", + (play.score.play_rating(chart.chart_constant)) as f32 / 100. + ), + )?; + + Ok(()) + })?; + // }}} + // {{{ Display ptt emblem + let ptt_emblem = get_ptt_emblem(); + drawer.blit_rbga( + top_left_area, + Rect::from_image(ptt_emblem) + .align((Align::Center, Align::Center), (top_left_center, 115)), + ptt_emblem.dimensions(), + ptt_emblem.as_raw(), + ); // }}} } diff --git a/src/jacket.rs b/src/jacket.rs index 8e713f9..63ac7c9 100644 --- a/src/jacket.rs +++ b/src/jacket.rs @@ -1,11 +1,11 @@ use std::{fs, path::PathBuf, str::FromStr}; -use image::{imageops::FilterType, GenericImageView, ImageBuffer, Rgb, Rgba}; +use image::{imageops::FilterType, GenericImageView, Rgba}; use kd_tree::{KdMap, KdPoint}; use num::Integer; use crate::{ - assets::should_skip_jacket_art, + assets::{get_assets_dir, should_skip_jacket_art}, chart::{Difficulty, Jacket, SongCache}, context::Error, score::guess_chart_name, @@ -14,7 +14,7 @@ use crate::{ /// How many sub-segments to split each side into pub const SPLIT_FACTOR: u32 = 8; pub const IMAGE_VEC_DIM: usize = (SPLIT_FACTOR * SPLIT_FACTOR * 3) as usize; -pub const BITMAP_IMAGE_SIZE: u32 = 192; +pub const BITMAP_IMAGE_SIZE: u32 = 174; #[derive(Debug, Clone)] pub struct ImageVec { @@ -77,9 +77,6 @@ impl KdPoint for ImageVec { pub struct JacketCache { tree: KdMap, - pub b30_background: ImageBuffer, Vec>, - pub count_background: ImageBuffer, Vec>, - pub diff_backgrounds: [ImageBuffer, Vec>; 5], } impl JacketCache { @@ -87,7 +84,6 @@ impl JacketCache { // This is a bit inefficient (using a hash set), but only runs once pub fn new(data_dir: &PathBuf, song_cache: &mut SongCache) -> Result { let jacket_dir = data_dir.join("jackets"); - let assets_dir = data_dir.join("assets"); if jacket_dir.exists() { fs::remove_dir_all(&jacket_dir).expect("Could not delete jacket dir"); @@ -96,7 +92,7 @@ impl JacketCache { fs::create_dir_all(&jacket_dir).expect("Could not create jacket dir"); let tree_entries = if should_skip_jacket_art() { - let path = assets_dir.join("placeholder-jacket.jpg"); + let path = get_assets_dir().join("placeholder_jacket.jpg"); let contents: &'static _ = fs::read(path)?.leak(); let image = image::load_from_memory(contents)?; let bitmap: &'static _ = Box::leak(Box::new( @@ -210,21 +206,6 @@ impl JacketCache { let result = Self { tree: KdMap::build_by_ordered_float(tree_entries), - b30_background: image::open(assets_dir.join("b30_background.jpg"))? - .resize(2048 * 2, 1535 * 2, FilterType::Nearest) - .blur(20.0) - .into_rgb8(), - count_background: image::open(assets_dir.join("count_background.png"))? - .blur(1.0) - .into_rgba8(), - diff_backgrounds: Difficulty::DIFFICULTY_SHORTHANDS.try_map( - |shorthand| -> Result<_, Error> { - Ok(image::open( - assets_dir.join(format!("diff-{}.png", shorthand.to_lowercase())), - )? - .into_rgba8()) - }, - )?, }; Ok(result) diff --git a/src/main.rs b/src/main.rs index 3a29ab4..b959709 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ mod jacket; mod score; mod user; -use assets::DATA_DIR; +use assets::get_data_dir; use context::{Error, UserContext}; use poise::serenity_prelude::{self as serenity}; use sqlx::sqlite::SqlitePoolOptions; @@ -33,7 +33,7 @@ async fn on_error(error: poise::FrameworkError<'_, UserContext, Error>) { #[tokio::main] async fn main() { - let data_dir = DATA_DIR.with(|d| d.clone()); + let data_dir = get_data_dir(); let cache_dir = var("SHIMMERING_CACHE_DIR").expect("Missing `SHIMMERING_CACHE_DIR` env var"); let pool = SqlitePoolOptions::new() diff --git a/src/score.rs b/src/score.rs index 11bc09e..2855793 100644 --- a/src/score.rs +++ b/src/score.rs @@ -18,6 +18,34 @@ use crate::context::{Error, UserContext}; use crate::jacket::IMAGE_VEC_DIM; use crate::user::User; +// {{{ Grade +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Grade { + EXP, + EX, + AA, + A, + B, + C, + D, +} + +impl Grade { + pub const GRADE_STRINGS: [&'static str; 7] = ["EX+", "EX", "AA", "A", "B", "C", "D"]; + pub const GRADE_SHORTHANDS: [&'static str; 7] = ["exp", "ex", "aa", "a", "b", "c", "d"]; + + #[inline] + pub fn to_index(self) -> usize { + self as usize + } +} + +impl Display for Grade { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", Self::GRADE_STRINGS[self.to_index()]) + } +} +// }}} // {{{ Score #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct Score(pub u32); @@ -110,22 +138,22 @@ impl Score { // {{{ Score => grade #[inline] // TODO: Perhaps make an enum for this - pub fn grade(self) -> &'static str { + pub fn grade(self) -> Grade { let score = self.0; if score > 9900000 { - "EX+" + Grade::EXP } else if score > 9800000 { - "EX" + Grade::EX } else if score > 9500000 { - "AA" + Grade::AA } else if score > 9200000 { - "A" + Grade::A } else if score > 8900000 { - "B" + Grade::B } else if score > 8600000 { - "C" + Grade::C } else { - "D" + Grade::D } } // }}} @@ -477,7 +505,6 @@ impl Play { pub fn status(&self, chart: &Chart) -> Option { let score = self.score.0; if score >= 10_000_000 { - // Prevent subtracting with overflow if score > chart.note_count + 10_000_000 { return None; } @@ -502,6 +529,25 @@ impl Play { None } } + + #[inline] + pub fn short_status(&self, chart: &Chart) -> Option { + let score = self.score.0; + if score >= 10_000_000 { + let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?; + if non_max_pures == 0 { + Some('M') + } else { + Some('P') + } + } else if let Some(distribution) = self.distribution(chart.note_count) + && distribution.3 == 0 + { + Some('F') + } else { + Some('C') + } + } // }}} // {{{ Play to embed /// Creates a discord embed for this play. @@ -534,7 +580,7 @@ impl Play { ), true, ) - .field("Grade", self.score.grade(), true) + .field("Grade", format!("{}", self.score.grade()), true) .field("ξ-Score", format!("{} (+?)", self.zeta_score), true) .field( "ξ-Rating", @@ -544,7 +590,7 @@ impl Play { ), true, ) - .field("ξ-Grade", self.zeta_score.grade(), true) + .field("ξ-Grade", format!("{}", self.zeta_score.grade()), true) .field( "Status", self.status(chart).unwrap_or("?".to_string()),