diff --git a/Cargo.lock b/Cargo.lock index 2972394..169b446 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -904,6 +904,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freetype-rs" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5442dee36ca09604133580dc0553780e867936bb3cbef3275859e889026d2b17" +dependencies = [ + "bitflags 2.5.0", + "freetype-sys", + "libc", +] + [[package]] name = "freetype-sys" version = "0.20.1" @@ -2672,6 +2683,7 @@ version = "0.1.0" dependencies = [ "chrono", "edit-distance", + "freetype-rs", "image 0.25.1", "kd-tree", "num", diff --git a/Cargo.toml b/Cargo.toml index c5e8f46..5b23459 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,10 +6,11 @@ edition = "2021" [dependencies] chrono = "0.4.38" edit-distance = "2.1.0" +freetype-rs = "0.36.0" image = "0.25.1" kd-tree = { version="0.6.0", features=["serde"] } num = "0.4.3" -plotters = { git="https://github.com/starlitcanopy/plotters.git", rev="986cd959362a2dbec8d1b25670fd083b904d7b8c" } +plotters = { git="https://github.com/starlitcanopy/plotters.git", rev="986cd959362a2dbec8d1b25670fd083b904d7b8c", features=["bitmap_backend"] } poise = "0.6.1" postcard = { version="1.0.8", features=["use-std"] } serde = "1.0.204" @@ -19,5 +20,5 @@ tesseract = "0.15.1" tokio = {version="1.38.0", features=["rt-multi-thread"]} typenum = "1.17.0" -[profile.dev.package.sqlx-macros] +[profile.dev.package."*"] opt-level = 3 diff --git a/data/assets/count_background.png b/data/assets/count_background.png new file mode 100644 index 0000000..aed324d Binary files /dev/null and b/data/assets/count_background.png differ diff --git a/data/assets/diff-byd.png b/data/assets/diff-byd.png new file mode 100644 index 0000000..22b9eff Binary files /dev/null and b/data/assets/diff-byd.png differ diff --git a/data/assets/diff-etr.png b/data/assets/diff-etr.png new file mode 100644 index 0000000..1671368 Binary files /dev/null and b/data/assets/diff-etr.png differ diff --git a/data/assets/diff-ftr.png b/data/assets/diff-ftr.png new file mode 100644 index 0000000..af5b3d5 Binary files /dev/null and b/data/assets/diff-ftr.png differ diff --git a/data/assets/diff-prs.png b/data/assets/diff-prs.png new file mode 100644 index 0000000..0265965 Binary files /dev/null and b/data/assets/diff-prs.png differ diff --git a/data/assets/diff-pst.png b/data/assets/diff-pst.png new file mode 100644 index 0000000..e57182f Binary files /dev/null and b/data/assets/diff-pst.png differ diff --git a/src/bitmap.rs b/src/bitmap.rs new file mode 100644 index 0000000..acac317 --- /dev/null +++ b/src/bitmap.rs @@ -0,0 +1,411 @@ +use freetype::{ + face::{KerningMode, LoadFlag}, + ffi::FT_GLYPH_BBOX_PIXELS, + Face, +}; +use num::traits::Euclid; + +use crate::context::Error; + +// {{{ BitmapCanvas +pub struct BitmapCanvas { + pub buffer: Box<[u8]>, + pub width: u32, +} + +impl BitmapCanvas { + // {{{ Draw pixel + pub fn set_pixel(&mut self, pos: (u32, u32), color: (u8, u8, u8, u8)) { + let index = 3 * (pos.1 * self.width + pos.0) as usize; + let alpha = color.3 as u32; + self.buffer[index + 0] = + ((alpha * color.0 as u32 + (255 - alpha) * self.buffer[index + 0] as u32) / 255) as u8; + self.buffer[index + 1] = + ((alpha * color.1 as u32 + (255 - alpha) * self.buffer[index + 1] as u32) / 255) as u8; + self.buffer[index + 2] = + ((alpha * color.2 as u32 + (255 - alpha) * self.buffer[index + 2] as u32) / 255) as u8; + } + // }}} + // {{{ Draw RBG image + /// Draws a bitmap image + pub fn blit_rbg(&mut self, pos: (i32, i32), (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 { + let x = pos.0 + dx as i32; + let y = pos.1 + dy as i32; + if x >= 0 && (x as u32) < self.width && y >= 0 && (y as u32) < height { + let r = src[(dx + dy * iw) as usize * 3]; + 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); + + self.set_pixel((x as u32, y as u32), color); + } + } + } + } + // }}} + // {{{ 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]) { + let height = self.buffer.len() as u32 / 3 / self.width; + for dx in 0..iw { + for dy in 0..ih { + let x = pos.0 + dx as i32; + let y = pos.1 + dy as i32; + if x >= 0 && (x as u32) < self.width && y >= 0 && (y as u32) < height { + let r = src[(dx + dy * iw) as usize * 4]; + let g = src[(dx + dy * iw) as usize * 4 + 1]; + 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); + + self.set_pixel((x as u32, y as u32), color); + } + } + } + } + // }}} + // {{{ Fill + /// Fill with solid color + pub fn fill(&mut self, pos: (i32, i32), (iw, ih): (u32, u32), color: (u8, u8, u8, u8)) { + let height = self.buffer.len() as u32 / 3 / self.width; + for dx in 0..iw { + for dy in 0..ih { + let x = pos.0 + dx as i32; + let y = pos.1 + dy as i32; + if x >= 0 && (x as u32) < self.width && y >= 0 && (y as u32) < height { + self.set_pixel((x as u32, y as u32), color); + } + } + } + } + // }}} + // {{{ Draw text + /// Render text + pub fn text( + &mut self, + pos: (i32, i32), + face: Face, + size: u32, + text: &str, + color: (u8, u8, u8, u8), + ) -> Result<(), Error> { + face.set_char_size(0, (size as isize) << 6, 300, 300)?; + + let mut pen_x = 0; + let kerning = face.has_kerning(); + let mut previous = None; + let mut data = Vec::new(); + + for c in text.chars() { + let glyph_index = face + .get_char_index(c as usize) + .ok_or_else(|| format!("Could not get glyph index for char {:?}", c))?; + + if let Some(previous) = previous + && kerning + { + let delta = face.get_kerning(previous, glyph_index, KerningMode::KerningDefault)?; + pen_x += delta.x >> 6; // we shift to get rid of sub-pixel accuracy + } + + face.load_glyph(glyph_index, LoadFlag::DEFAULT)?; + + data.push((pen_x, face.glyph().get_glyph()?)); + pen_x += face.glyph().advance().x >> 6; + previous = Some(glyph_index); + } + + let mut x_min = 32000; + let mut y_min = 32000; + let mut x_max = -32000; + let mut y_max = -32000; + + for (pen_x, glyph) in &data { + let mut bbox = glyph.get_cbox(FT_GLYPH_BBOX_PIXELS); + + bbox.xMin += pen_x; + bbox.xMax += pen_x; + + if bbox.xMin < x_min { + x_min = bbox.xMin + } + + if bbox.xMax < x_max { + x_max = bbox.xMax + } + + if bbox.yMin < y_min { + y_min = bbox.yMin + } + + if bbox.yMax < y_max { + y_max = bbox.yMax + } + } + + // Check that we really grew the string bbox + if x_min > x_max { + x_min = 0; + x_max = 0; + y_min = 0; + y_max = 0; + } + + 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()?; + println!( + "Pixel mode: {:?}, width {:?}, height {:?}, len {:?}, pen x {:?}", + pixel_mode, + bitmap.width(), + bitmap.rows(), + bitmap.buffer().len(), + pos_x + ); + } + + Ok(()) + } + // }}} + + #[inline] + pub fn new(width: u32, height: u32) -> Self { + let buffer = vec![u8::MAX; 8 * 3 * (width * height) as usize].into_boxed_slice(); + Self { buffer, width } + } +} +// }}} +// {{{ Layout types +#[derive(Clone, Copy, Debug)] +pub struct LayoutBox { + relative_to: Option<(LayoutBoxId, i32, i32)>, + pub width: u32, + pub height: u32, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct LayoutBoxId(usize); + +#[derive(Default, Debug)] +pub struct LayoutManager { + boxes: Vec, +} + +pub struct LayoutDrawer { + pub layout: LayoutManager, + pub canvas: BitmapCanvas, +} + +impl LayoutManager { + // {{{ Trivial box creation + pub fn make_box(&mut self, width: u32, height: u32) -> LayoutBoxId { + let id = self.boxes.len(); + self.boxes.push(LayoutBox { + relative_to: None, + width, + height, + }); + + LayoutBoxId(id) + } + + pub fn make_relative_box( + &mut self, + to: LayoutBoxId, + x: i32, + y: i32, + width: u32, + height: u32, + ) -> LayoutBoxId { + let id = self.make_box(width, height); + self.edit_to_relative(id, to, x, y); + + id + } + // }}} + // {{{ Chage box to be relative + pub fn edit_to_relative( + &mut self, + id: LayoutBoxId, + id_relative_to: LayoutBoxId, + x: i32, + 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 + { + self.edit_to_relative(current_points_to, id_relative_to, x - dx, y - dy); + } else { + self.boxes[id.0].relative_to = Some((id_relative_to, x, y)); + } + + { + let a = self.lookup(id); + let b = self.lookup(id_relative_to); + assert_eq!((a.0 - b.0, a.1 - b.1), (x, y)); + } + } + // }}} + // {{{ Margins + #[inline] + pub fn margin(&mut self, id: LayoutBoxId, t: i32, r: i32, b: i32, l: i32) -> LayoutBoxId { + let inner = self.boxes[id.0]; + let out = self.make_box( + (inner.width as i32 + l + r) as u32, + (inner.height as i32 + t + b) as u32, + ); + self.edit_to_relative(id, out, l, t); + out + } + + #[inline] + pub fn margin_xy(&mut self, inner: LayoutBoxId, x: i32, y: i32) -> LayoutBoxId { + self.margin(inner, y, x, y, x) + } + + #[inline] + pub fn margin_uniform(&mut self, inner: LayoutBoxId, amount: i32) -> LayoutBoxId { + self.margin(inner, amount, amount, amount, amount) + } + // }}} + // {{{ Glueing + #[inline] + pub fn glue_horizontally( + &mut self, + first_id: LayoutBoxId, + second_id: LayoutBoxId, + ) -> LayoutBoxId { + let first = self.boxes[first_id.0]; + let second = self.boxes[second_id.0]; + let id = self.make_box(first.width.max(second.width), first.height + second.height); + + self.edit_to_relative(first_id, id, 0, 0); + self.edit_to_relative(second_id, id, 0, first.height as i32); + id + } + + #[inline] + pub fn glue_vertically( + &mut self, + first_id: LayoutBoxId, + second_id: LayoutBoxId, + ) -> LayoutBoxId { + let first = self.boxes[first_id.0]; + let second = self.boxes[second_id.0]; + let id = self.make_box(first.width + second.width, first.height.max(second.height)); + + self.edit_to_relative(first_id, id, 0, 0); + self.edit_to_relative(second_id, id, first.width as i32, 0); + id + } + // }}} + // {{{ Repeating + pub fn repeated_evenly( + &mut self, + id: LayoutBoxId, + amount: (u32, u32), + ) -> (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); + + ( + outer_id, + (0..amount.0 * amount.1).into_iter().map(move |i| { + let (y, x) = i.div_rem_euclid(&amount.0); + ((x * inner.width) as i32, (y * inner.height) as i32) + }), + ) + } + // }}} + // {{{ Lookup box + pub fn lookup(&self, id: LayoutBoxId) -> (i32, i32, u32, u32) { + 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) + } else { + (0, 0, current.width, current.height) + } + } + + #[inline] + pub fn width(&self, id: LayoutBoxId) -> u32 { + self.boxes[id.0].width + } + + #[inline] + pub fn height(&self, id: LayoutBoxId) -> u32 { + self.boxes[id.0].height + } + + #[inline] + pub fn position_relative_to(&self, id: LayoutBoxId, pos: (i32, i32)) -> (i32, i32) { + let current = self.lookup(id); + ((pos.0 as i32 + current.0), (pos.1 as i32 + current.1)) + } + // }}} +} + +impl LayoutDrawer { + pub fn new(layout: LayoutManager, canvas: BitmapCanvas) -> Self { + Self { layout, canvas } + } + + // {{{ Drawing + // {{{ Draw pixel + pub fn set_pixel(&mut self, id: LayoutBoxId, pos: (u32, u32), color: (u8, u8, u8, u8)) { + let pos = self + .layout + .position_relative_to(id, (pos.0 as i32, pos.1 as i32)); + self.canvas.set_pixel((pos.0 as u32, pos.1 as u32), color); + } + // }}} + // {{{ Draw RGB image + /// Draws a bitmap image + pub fn blit_rbg(&mut self, id: LayoutBoxId, pos: (i32, i32), 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]) { + let pos = self.layout.position_relative_to(id, pos); + self.canvas.blit_rbga(pos, dims, src); + } + // }}} + // {{{ Fill + /// Fills with solid color + pub fn fill(&mut self, id: LayoutBoxId, color: (u8, u8, u8, u8)) { + let current = self.layout.lookup(id); + self.canvas + .fill((current.0, current.1), (current.2, current.3), color); + } + // }}} + // {{{ Draw text + /// Render text + pub fn text( + &mut self, + id: LayoutBoxId, + pos: (i32, i32), + face: Face, + size: u32, + text: &str, + color: (u8, u8, u8, u8), + ) -> Result<(), Error> { + let pos = self.layout.position_relative_to(id, pos); + self.canvas.text(pos, face, size, text, color) + } + // }}} + // }}} +} +// }}} diff --git a/src/chart.rs b/src/chart.rs index 2442121..3d21f5b 100644 --- a/src/chart.rs +++ b/src/chart.rs @@ -1,7 +1,8 @@ use std::path::PathBuf; +use image::{ImageBuffer, Rgb}; use serde::{Deserialize, Serialize}; -use sqlx::{prelude::FromRow, SqlitePool}; +use sqlx::SqlitePool; use crate::context::Error; @@ -78,7 +79,7 @@ impl TryFrom for Side { } // }}} // {{{ Song -#[derive(Debug, Clone, FromRow)] +#[derive(Debug, Clone)] pub struct Song { pub id: u32, pub title: String, @@ -91,7 +92,13 @@ pub struct Song { } // }}} // {{{ Chart -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy)] +pub struct Jacket { + pub raw: &'static [u8], + pub bitmap: &'static ImageBuffer, Vec>, +} + +#[derive(Debug, Clone)] pub struct Chart { pub id: u32, pub song_id: u32, @@ -104,7 +111,7 @@ pub struct Chart { pub note_count: u32, pub chart_constant: u32, - pub cached_jacket: Option<&'static [u8]>, + pub cached_jacket: Option, } impl Chart { diff --git a/src/commands/chart.rs b/src/commands/chart.rs index 989df40..d4ca796 100644 --- a/src/commands/chart.rs +++ b/src/commands/chart.rs @@ -19,8 +19,8 @@ pub async fn chart( let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?; let attachement_name = "chart.png"; - let icon_attachement = match chart.cached_jacket { - Some(bytes) => Some(CreateAttachment::bytes(bytes, attachement_name)), + let icon_attachement = match chart.cached_jacket.as_ref() { + Some(jacket) => Some(CreateAttachment::bytes(jacket.raw, attachement_name)), None => None, }; diff --git a/src/commands/stats.rs b/src/commands/stats.rs index 1da3d34..9a8dc21 100644 --- a/src/commands/stats.rs +++ b/src/commands/stats.rs @@ -17,8 +17,11 @@ use poise::{ use sqlx::query_as; use crate::{ + bitmap::{BitmapCanvas, LayoutDrawer, LayoutManager}, + chart::{Chart, Song}, context::{Context, Error}, - score::{guess_song_and_chart, DbPlay, Score}, + jacket::BITMAP_IMAGE_SIZE, + score::{guess_song_and_chart, DbPlay, Play, Score}, user::{discord_it_to_discord_user, User}, }; @@ -27,7 +30,7 @@ use crate::{ #[poise::command( prefix_command, slash_command, - subcommands("chart"), + subcommands("chart", "b30"), subcommand_required )] pub async fn stats(_ctx: Context<'_>) -> Result<(), Error> { @@ -212,7 +215,6 @@ pub async fn plot( .iter() .map(|(t, s)| Circle::new((*t, *s), 3, BLUE.filled())), )?; - root.present()?; } @@ -228,3 +230,226 @@ pub async fn plot( Ok(()) } // }}} +// {{{ B30 +/// Show the 30 best scores +#[poise::command(prefix_command, slash_command)] +pub async fn b30(ctx: Context<'_>) -> Result<(), Error> { + let user = match User::from_context(&ctx).await { + Ok(user) => user, + Err(_) => { + ctx.say("You are not an user in my database, sorry!") + .await?; + return Ok(()); + } + }; + + let plays: Vec = query_as( + " + SELECT id, chart_id, user_id, + created_at, MAX(score) as score, zeta_score, + creation_ptt, creation_zeta_ptt, far_notes, max_recall, discord_attachment_id + FROM plays p + WHERE user_id = ? + GROUP BY chart_id + ORDER BY score DESC + ", + ) + .bind(user.id) + .fetch_all(&ctx.data().db) + .await?; + + if plays.len() < 30 { + ctx.reply("Not enough plays found").await?; + return Ok(()); + } + + // TODO: consider not reallocating everything here + let mut plays: Vec<(Play, &Song, &Chart)> = plays + .into_iter() + .map(|play| { + let play = play.to_play(); + // TODO: change the .lookup to perform binary search or something + let (song, chart) = ctx.data().song_cache.lookup_chart(play.chart_id)?; + Ok((play, song, chart)) + }) + .collect::, Error>>()?; + + plays.sort_by_key(|(play, _, chart)| -play.score.play_rating(chart.chart_constant)); + plays.truncate(30); + + let mut layout = LayoutManager::default(); + let jacket_area = layout.make_box(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE); + let jacket_margin = 10; + let jacket_with_margin = + layout.margin(jacket_area, jacket_margin, jacket_margin, 5, 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 item_area = layout.glue_horizontally(top_area, bottom_area); + let item_with_margin = layout.margin_xy(item_area, 25, 20); + let (item_grid, item_origins) = layout.repeated_evenly(item_with_margin, (5, 6)); + let root = item_grid; + + // layout.normalize(root); + let width = layout.width(root); + let height = layout.height(root); + + 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; + + drawer.blit_rbg( + root, + ( + -((bg.width() - width) as i32) / 2, + -((bg.height() - height) as i32) / 2, + ), + bg.dimensions(), + bg.as_raw(), + ); + + for (i, origin) in item_origins.enumerate() { + drawer + .layout + .edit_to_relative(item_with_margin, item_grid, origin.0, origin.1); + + drawer.fill(top_area, (59, 78, 102, 255)); + + let (_play, song, chart) = &plays[i]; + + // {{{ Display jacket + let jacket = chart.cached_jacket.as_ref().ok_or_else(|| { + format!( + "Cannot find jacket for chart {} [{:?}]", + song.title, chart.difficulty + ) + })?; + + drawer.blit_rbg( + jacket_area, + (0, 0), + jacket.bitmap.dimensions(), + &jacket.bitmap.as_raw(), + ); + // }}} + // {{{ Display difficulty background + let diff_bg = &asset_cache.diff_backgrounds[chart.difficulty.to_index()]; + drawer.blit_rbga( + jacket_area, + ( + BITMAP_IMAGE_SIZE as i32 - (diff_bg.width() as i32) / 2, + -(diff_bg.height() as i32) / 2, + ), + diff_bg.dimensions(), + &diff_bg.as_raw(), + ); + // }}} + // {{{ Display difficulty text + let x_offset = if chart.level.ends_with("+") { + 3 + } else if chart.level == "11" { + -2 + } else { + 0 + }; + // jacket_area.draw_text( + // &chart.level, + // &TextStyle::from(("Exo", 30).into_font()) + // .color(&WHITE) + // .with_anchor::(Pos { + // h_pos: HPos::Center, + // v_pos: VPos::Center, + // }) + // .into_text_style(&jacket_area), + // (BITMAP_IMAGE_SIZE as i32 + x_offset, 2), + // )?; + // }}} + // {{{ Display chart name + // Draw background + drawer.fill(bottom_area, (0x82, 0x71, 0xA7, 255)); + + 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))?; + // }}} + // {{{ Display index + let bg = &asset_cache.count_background; + + // Draw background + drawer.blit_rbga(item_area, (-8, jacket_margin as i32), bg.dimensions(), bg); + + // 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); + + let tx = 7; + let ty = (jacket_margin + bg.height() as i32 / 2) - 3; + + // Draw drop shadow + // area.draw_text( + // &format!("#{}", i + 1), + // &text_style.color(&BLACK), + // (tx + 2, ty + 2), + // )?; + + // Draw main text + // area.draw_text(&format!("#{}", i + 1), &text_style.color(&WHITE), (tx, ty))?; + // }}} + } + + let mut out_buffer = Vec::new(); + let image: ImageBuffer, _> = + ImageBuffer::from_raw(width, height, drawer.canvas.buffer).unwrap(); + + let mut cursor = Cursor::new(&mut out_buffer); + image.write_to(&mut cursor, image::ImageFormat::Png)?; + + let reply = CreateReply::default().attachment(CreateAttachment::bytes(out_buffer, "b30.png")); + ctx.send(reply).await?; + + Ok(()) +} +// }}} diff --git a/src/jacket.rs b/src/jacket.rs index d6c26e7..4267bd2 100644 --- a/src/jacket.rs +++ b/src/jacket.rs @@ -1,13 +1,13 @@ use std::{fs, path::PathBuf, str::FromStr}; -use image::{GenericImageView, Rgba}; +use freetype::{Face, Library}; +use image::{imageops::FilterType, GenericImageView, ImageBuffer, Rgb, Rgba}; use kd_tree::{KdMap, KdPoint}; use num::Integer; -use serde::{Deserialize, Serialize}; -use serde_with::serde_as; use crate::{ - chart::{Difficulty, SongCache}, + bitmap::BitmapCanvas, + chart::{Difficulty, Jacket, SongCache}, context::Error, score::guess_chart_name, }; @@ -15,11 +15,10 @@ 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; -#[serde_as] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct ImageVec { - #[serde_as(as = "[_; IMAGE_VEC_DIM]")] pub colors: [f32; IMAGE_VEC_DIM], } @@ -44,16 +43,16 @@ impl ImageVec { let mut count = 0; for (_, _, pixel) in cropped.pixels() { - r += pixel.0[0] as u64; - g += pixel.0[1] as u64; - b += pixel.0[2] as u64; + r += (pixel.0[0] as u64).pow(2); + g += (pixel.0[1] as u64).pow(2); + b += (pixel.0[2] as u64).pow(2); count += 1; } let count = count as f64; - let r = r as f64 / count; - let g = g as f64 / count; - let b = b as f64 / count; + let r = (r as f64 / count).sqrt(); + let g = (g as f64 / count).sqrt(); + let b = (b as f64 / count).sqrt(); colors[i as usize * 3 + 0] = r as f32; colors[i as usize * 3 + 1] = g as f32; colors[i as usize * 3 + 2] = b as f32; @@ -77,9 +76,11 @@ impl KdPoint for ImageVec { } } -#[derive(Serialize, Deserialize)] pub struct JacketCache { tree: KdMap, + pub b30_background: ImageBuffer, Vec>, + pub count_background: ImageBuffer, Vec>, + pub diff_backgrounds: [ImageBuffer, Vec>; 5], } impl JacketCache { @@ -96,7 +97,7 @@ impl JacketCache { let mut jackets = Vec::new(); let entries = fs::read_dir(data_dir.join("songs")).expect("Couldn't read songs directory"); - for entry in entries { + for (i, entry) in entries.enumerate() { let dir = entry?; let raw_dir_name = dir.file_name(); let dir_name = raw_dir_name.to_str().unwrap(); @@ -127,6 +128,11 @@ impl JacketCache { jackets.push((file.path(), song.id)); let contents = fs::read(file.path())?.leak(); + let bitmap = Box::leak(Box::new( + image::load_from_memory(contents)? + .resize(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE, FilterType::Nearest) + .into_rgb8(), + )); if name == "base" { let item = song_cache.lookup_mut(song.id).unwrap(); @@ -156,14 +162,20 @@ impl JacketCache { if !specialized_path.exists() && !dest.exists() { std::os::unix::fs::symlink(file.path(), dest) .expect("Could not symlink jacket"); - chart.cached_jacket = Some(contents); + chart.cached_jacket = Some(Jacket { + raw: contents, + bitmap, + }); } } } else if difficulty.is_some() { std::os::unix::fs::symlink(file.path(), chart.jacket_path(data_dir)) .expect("Could not symlink jacket"); let chart = song_cache.lookup_chart_mut(chart.id).unwrap(); - chart.cached_jacket = Some(contents); + chart.cached_jacket = Some(Jacket { + raw: contents, + bitmap, + }); } } } @@ -180,8 +192,36 @@ impl JacketCache { } } + let assets_dir = data_dir.join("assets"); + + let lib = Library::init()?; + let saira_font = lib.new_face(assets_dir.join("saira-variable.ttf"), 0)?; + let mut canvas = BitmapCanvas::new(0, 0); + canvas.text( + (0, 0), + saira_font, + 20, + "Yo, this is a test!", + (0, 0, 0, 0xff), + )?; + let result = Self { tree: KdMap::build_by_ordered_float(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 de9d412..8b88cc5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,10 @@ #![warn(clippy::str_to_string)] #![feature(iter_map_windows)] #![feature(let_chains)] +#![feature(array_try_map)] #![feature(async_closure)] +mod bitmap; mod chart; mod commands; mod context; diff --git a/src/score.rs b/src/score.rs index 127d709..11bc09e 100644 --- a/src/score.rs +++ b/src/score.rs @@ -153,8 +153,16 @@ impl Score { // Compute score from note breakdown subpairs let pf_score = Score::compute_naive(note_count, pures, fars); - let fl_score = Score::compute_naive(note_count, note_count - losts - fars, fars); - let lp_score = Score::compute_naive(note_count, pures, note_count - losts - pures); + let fl_score = Score::compute_naive( + note_count, + note_count.checked_sub(losts + fars).unwrap_or(0), + fars, + ); + let lp_score = Score::compute_naive( + note_count, + pures, + note_count.checked_sub(losts + pures).unwrap_or(0), + ); if no_shiny_scores.len() == 1 { // {{{ Score is fixed, gotta figure out the exact distribution @@ -450,14 +458,14 @@ impl Play { pub fn distribution(&self, note_count: u32) -> Option<(u32, u32, u32, u32)> { if let Some(fars) = self.far_notes { let (_, shinies, units) = self.score.analyse(note_count); - let (pures, rem) = (units - fars).div_rem_euclid(&2); + let (pures, rem) = units.checked_sub(fars)?.div_rem_euclid(&2); if rem == 1 { println!("The impossible happened: got an invalid amount of far notes!"); return None; } - let lost = note_count - fars - pures; - let non_max_pures = pures - shinies; + let lost = note_count.checked_sub(fars + pures)?; + let non_max_pures = pures.checked_sub(shinies)?; Some((shinies, non_max_pures, fars, lost)) } else { None @@ -474,7 +482,7 @@ impl Play { return None; } - let non_max_pures = chart.note_count + 10_000_000 - score; + let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?; if non_max_pures == 0 { Some("MPM".to_string()) } else { @@ -507,8 +515,8 @@ impl Play { author: Option<&poise::serenity_prelude::User>, ) -> Result<(CreateEmbed, Option), Error> { let attachement_name = format!("{:?}-{:?}-{:?}.png", song.id, self.score.0, index); - let icon_attachement = match chart.cached_jacket { - Some(bytes) => Some(CreateAttachment::bytes(bytes, &attachement_name)), + let icon_attachement = match chart.cached_jacket.as_ref() { + Some(jacket) => Some(CreateAttachment::bytes(jacket.raw, &attachement_name)), None => None, }; @@ -527,16 +535,16 @@ impl Play { true, ) .field("Grade", self.score.grade(), true) - .field("ζ-Score", format!("{} (+?)", self.zeta_score), true) + .field("ξ-Score", format!("{} (+?)", self.zeta_score), true) .field( - "ζ-Rating", + "ξ-Rating", format!( "{:.2} (+?)", (self.zeta_score.play_rating(chart.chart_constant)) as f32 / 100. ), true, ) - .field("ζ-Grade", self.zeta_score.grade(), true) + .field("ξ-Grade", self.zeta_score.grade(), true) .field( "Status", self.status(chart).unwrap_or("?".to_string()),