Finished b30
Signed-off-by: prescientmoon <git@moonythm.dev>
Before Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 8.9 KiB |
Before Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 5.8 KiB |
Before Width: | Height: | Size: 5.5 KiB |
BIN
data/assets/grade_background.png
Normal file
After Width: | Height: | Size: 4 KiB |
BIN
data/assets/name_background.png
Normal file
After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 246 KiB |
BIN
data/assets/ptt_emblem.png
Normal file
After Width: | Height: | Size: 752 B |
BIN
data/assets/score_background.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
data/assets/status_background.png
Normal file
After Width: | Height: | Size: 5 KiB |
BIN
data/assets/top_background.png
Normal file
After Width: | Height: | Size: 47 KiB |
117
src/assets.rs
|
@ -2,28 +2,34 @@
|
||||||
use std::{cell::RefCell, env::var, path::PathBuf, str::FromStr, sync::OnceLock};
|
use std::{cell::RefCell, env::var, path::PathBuf, str::FromStr, sync::OnceLock};
|
||||||
|
|
||||||
use freetype::{Face, Library};
|
use freetype::{Face, Library};
|
||||||
|
use image::{imageops::FilterType, ImageBuffer, Rgb, Rgba};
|
||||||
|
|
||||||
|
use crate::chart::Difficulty;
|
||||||
|
|
||||||
#[inline]
|
#[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"))
|
PathBuf::from_str(&var("SHIMMERING_DATA_DIR").expect("Missing `SHIMMERING_DATA_DIR` env var"))
|
||||||
.expect("`SHIMMERING_DATA_DIR` is not a valid path")
|
.expect("`SHIMMERING_DATA_DIR` is not a valid path")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn get_font(name: &str, assets_dir: &PathBuf) -> RefCell<Face> {
|
pub fn get_assets_dir() -> PathBuf {
|
||||||
|
get_data_dir().join("assets")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn get_font(name: &str) -> RefCell<Face> {
|
||||||
let face = FREETYPE_LIB.with(|lib| {
|
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))
|
.expect(&format!("Could not load {} font", name))
|
||||||
});
|
});
|
||||||
RefCell::new(face)
|
RefCell::new(face)
|
||||||
}
|
}
|
||||||
|
|
||||||
thread_local! {
|
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 FREETYPE_LIB: Library = Library::init().unwrap();
|
||||||
pub static SAIRA_FONT: RefCell<Face> = ASSETS_DIR.with(|assets_dir| get_font("saira", assets_dir));
|
pub static SAIRA_FONT: RefCell<Face> = get_font("saira");
|
||||||
pub static EXO_FONT: RefCell<Face> = ASSETS_DIR.with(|assets_dir| get_font("exo", assets_dir));
|
pub static EXO_FONT: RefCell<Face> = get_font("exo");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
|
@ -31,3 +37,100 @@ pub fn should_skip_jacket_art() -> bool {
|
||||||
static CELL: OnceLock<bool> = OnceLock::new();
|
static CELL: OnceLock<bool> = OnceLock::new();
|
||||||
*CELL.get_or_init(|| var("SHIMMERING_NO_JACKETS").unwrap_or_default() == "1")
|
*CELL.get_or_init(|| var("SHIMMERING_NO_JACKETS").unwrap_or_default() == "1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_b30_background() -> &'static ImageBuffer<Rgb<u8>, Vec<u8>> {
|
||||||
|
static CELL: OnceLock<ImageBuffer<Rgb<u8>, Vec<u8>>> = 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<Rgba<u8>, Vec<u8>> {
|
||||||
|
static CELL: OnceLock<ImageBuffer<Rgba<u8>, Vec<u8>>> = 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<Rgba<u8>, Vec<u8>> {
|
||||||
|
static CELL: OnceLock<ImageBuffer<Rgba<u8>, Vec<u8>>> = 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<Rgba<u8>, Vec<u8>> {
|
||||||
|
static CELL: OnceLock<ImageBuffer<Rgba<u8>, Vec<u8>>> = 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<Rgba<u8>, Vec<u8>> {
|
||||||
|
static CELL: OnceLock<ImageBuffer<Rgba<u8>, Vec<u8>>> = 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<Rgb<u8>, Vec<u8>> {
|
||||||
|
static CELL: OnceLock<ImageBuffer<Rgb<u8>, Vec<u8>>> = 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<Rgb<u8>, Vec<u8>> {
|
||||||
|
static CELL: OnceLock<ImageBuffer<Rgb<u8>, Vec<u8>>> = 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<Rgba<u8>, Vec<u8>> {
|
||||||
|
static CELL: OnceLock<ImageBuffer<Rgba<u8>, Vec<u8>>> = 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<Rgba<u8>, Vec<u8>> {
|
||||||
|
static CELL: OnceLock<[ImageBuffer<Rgba<u8>, Vec<u8>>; 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()]
|
||||||
|
}
|
||||||
|
|
305
src/bitmap.rs
|
@ -2,15 +2,103 @@ use freetype::{
|
||||||
bitmap::PixelMode,
|
bitmap::PixelMode,
|
||||||
face::{KerningMode, LoadFlag},
|
face::{KerningMode, LoadFlag},
|
||||||
ffi::{FT_Err_Ok, FT_Set_Var_Design_Coordinates, FT_GLYPH_BBOX_PIXELS},
|
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 num::traits::Euclid;
|
||||||
|
|
||||||
use crate::{assets::FREETYPE_LIB, context::Error};
|
use crate::{assets::FREETYPE_LIB, context::Error};
|
||||||
|
|
||||||
// {{{ Config types
|
// {{{ Color
|
||||||
pub type Color = (u8, u8, u8, u8);
|
#[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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum Align {
|
pub enum Align {
|
||||||
Start,
|
Start,
|
||||||
|
@ -18,13 +106,32 @@ pub enum Align {
|
||||||
End,
|
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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct TextStyle {
|
pub struct TextStyle {
|
||||||
pub size: u32,
|
pub size: u32,
|
||||||
pub weight: u32,
|
pub weight: u32,
|
||||||
pub color: Color,
|
pub color: Color,
|
||||||
pub h_align: Align,
|
pub align: (Align, Align),
|
||||||
pub v_align: Align,
|
pub stroke: Option<(Color, f32)>,
|
||||||
|
pub drop_shadow: Option<(Color, Position)>,
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ BitmapCanvas
|
// {{{ BitmapCanvas
|
||||||
|
@ -48,7 +155,7 @@ impl BitmapCanvas {
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Draw RBG image
|
// {{{ Draw RBG image
|
||||||
/// Draws a bitmap 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;
|
let height = self.buffer.len() as u32 / 3 / self.width;
|
||||||
for dx in 0..iw {
|
for dx in 0..iw {
|
||||||
for dy in 0..ih {
|
for dy in 0..ih {
|
||||||
|
@ -59,7 +166,7 @@ impl BitmapCanvas {
|
||||||
let g = src[(dx + dy * iw) as usize * 3 + 1];
|
let g = src[(dx + dy * iw) as usize * 3 + 1];
|
||||||
let b = src[(dx + dy * iw) as usize * 3 + 2];
|
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);
|
self.set_pixel((x as u32, y as u32), color);
|
||||||
}
|
}
|
||||||
|
@ -69,7 +176,7 @@ impl BitmapCanvas {
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Draw RGBA image
|
// {{{ Draw RGBA image
|
||||||
/// Draws a bitmap image taking care of the alpha channel.
|
/// 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;
|
let height = self.buffer.len() as u32 / 3 / self.width;
|
||||||
for dx in 0..iw {
|
for dx in 0..iw {
|
||||||
for dy in 0..ih {
|
for dy in 0..ih {
|
||||||
|
@ -81,7 +188,7 @@ impl BitmapCanvas {
|
||||||
let b = src[(dx + dy * iw) as usize * 4 + 2];
|
let b = src[(dx + dy * iw) as usize * 4 + 2];
|
||||||
let a = src[(dx + dy * iw) as usize * 4 + 3];
|
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);
|
self.set_pixel((x as u32, y as u32), color);
|
||||||
}
|
}
|
||||||
|
@ -91,7 +198,7 @@ impl BitmapCanvas {
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Fill
|
// {{{ Fill
|
||||||
/// Fill with solid color
|
/// 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;
|
let height = self.buffer.len() as u32 / 3 / self.width;
|
||||||
for dx in 0..iw {
|
for dx in 0..iw {
|
||||||
for dy in 0..ih {
|
for dy in 0..ih {
|
||||||
|
@ -105,15 +212,13 @@ impl BitmapCanvas {
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Draw text
|
// {{{ Draw text
|
||||||
// TODO: perform gamma correction on the color interpolation.
|
pub fn plan_text_rendering(
|
||||||
/// Render text
|
|
||||||
pub fn text(
|
|
||||||
&mut self,
|
&mut self,
|
||||||
pos: (i32, i32),
|
pos: Position,
|
||||||
face: &mut Face,
|
face: &mut Face,
|
||||||
style: TextStyle,
|
style: TextStyle,
|
||||||
text: &str,
|
text: &str,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(Position, Rect, Vec<(i64, Glyph)>), Error> {
|
||||||
// {{{ Control weight
|
// {{{ Control weight
|
||||||
unsafe {
|
unsafe {
|
||||||
let raw = face.raw_mut() as *mut _;
|
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)?;
|
face.set_char_size((style.size << 6) as isize, 0, 0, 0)?;
|
||||||
|
|
||||||
// {{{ Compute layout
|
// {{{ Compute layout
|
||||||
|
@ -200,81 +306,102 @@ impl BitmapCanvas {
|
||||||
y_max = 0;
|
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
|
// {{{ Render glyphs
|
||||||
for (pos_x, glyph) in &data {
|
for (pos_x, glyph) in &data {
|
||||||
let b_glyph = glyph.to_bitmap(freetype::RenderMode::Normal, None)?;
|
let b_glyph = glyph.to_bitmap(freetype::RenderMode::Normal, None)?;
|
||||||
let bitmap = b_glyph.bitmap();
|
let bitmap = b_glyph.bitmap();
|
||||||
let pixel_mode = bitmap.pixel_mode()?;
|
let pixel_mode = bitmap.pixel_mode()?;
|
||||||
assert_eq!(pixel_mode, PixelMode::Gray);
|
assert_eq!(pixel_mode, PixelMode::Gray);
|
||||||
println!("starting to stroke");
|
|
||||||
|
|
||||||
// {{{ Blit border
|
let char_pos = (
|
||||||
let stroker = FREETYPE_LIB.with(|lib| lib.new_stroker())?;
|
pos.0 + *pos_x as i32 - bbox.x,
|
||||||
stroker.set(1 << 6, StrokerLineCap::Round, StrokerLineJoin::Round, 0);
|
pos.1 + bbox.height as i32 + bbox.y,
|
||||||
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 iw = sbitmap.width();
|
if let Some((shadow_color, offset)) = style.drop_shadow {
|
||||||
let ih = sbitmap.rows();
|
let char_pos = (char_pos.0 + offset.0, char_pos.1 + offset.1);
|
||||||
println!("pitch {}, width {}, height {}", sbitmap.pitch(), iw, ih);
|
self.blit_glyph(&b_glyph, &bitmap, char_pos, shadow_color);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// }}}
|
|
||||||
// {{{ 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 {
|
if let Some((stroke_color, stroke_width)) = style.stroke {
|
||||||
for dy in 0..ih {
|
// {{{ Create stroke
|
||||||
let x = pos.0 + *pos_x as i32 + dx as i32 + b_glyph.left();
|
let stroker = FREETYPE_LIB.with(|lib| lib.new_stroker())?;
|
||||||
let y = pos.1 + dy as i32 - b_glyph.top();
|
stroker.set(
|
||||||
if x >= 0 && (x as u32) < self.width && y >= 0 && (y as u32) < height {
|
float_to_ft_fixed(stroke_width),
|
||||||
let gray = src[(dx + dy * iw) as usize];
|
StrokerLineCap::Round,
|
||||||
|
StrokerLineJoin::Round,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
let r = style.color.0;
|
let sglyph = glyph.stroke(&stroker)?;
|
||||||
let g = style.color.1;
|
let sb_glyph = sglyph.to_bitmap(freetype::RenderMode::Normal, None)?;
|
||||||
let b = style.color.2;
|
let sbitmap = sb_glyph.bitmap();
|
||||||
let a = gray;
|
let spixel_mode = sbitmap.pixel_mode()?;
|
||||||
|
assert_eq!(spixel_mode, PixelMode::Gray);
|
||||||
|
// }}}
|
||||||
|
|
||||||
let color = (r, g, b, a);
|
self.blit_glyph(&sb_glyph, &sbitmap, char_pos, stroke_color);
|
||||||
|
|
||||||
self.set_pixel((x as u32, y as u32), color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// }}}
|
|
||||||
|
self.blit_glyph(&b_glyph, &bitmap, char_pos, style.color);
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
|
|
||||||
Ok(())
|
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]
|
#[inline]
|
||||||
pub fn new(width: u32, height: u32) -> Self {
|
pub fn new(width: u32, height: u32) -> Self {
|
||||||
|
@ -340,7 +467,7 @@ impl LayoutManager {
|
||||||
y: i32,
|
y: i32,
|
||||||
) {
|
) {
|
||||||
let current = self.boxes[id.0];
|
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
|
if let Some((current_points_to, dx, dy)) = current.relative_to
|
||||||
&& current_points_to != id_relative_to
|
&& current_points_to != id_relative_to
|
||||||
{
|
{
|
||||||
|
@ -352,7 +479,7 @@ impl LayoutManager {
|
||||||
{
|
{
|
||||||
let a = self.lookup(id);
|
let a = self.lookup(id);
|
||||||
let b = self.lookup(id_relative_to);
|
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,
|
&mut self,
|
||||||
id: LayoutBoxId,
|
id: LayoutBoxId,
|
||||||
amount: (u32, u32),
|
amount: (u32, u32),
|
||||||
) -> (LayoutBoxId, impl Iterator<Item = (i32, i32)>) {
|
) -> (LayoutBoxId, impl Iterator<Item = Position>) {
|
||||||
let inner = self.boxes[id.0];
|
let inner = self.boxes[id.0];
|
||||||
let outer_id = self.make_box(inner.width * amount.0, inner.height * amount.1);
|
let outer_id = self.make_box(inner.width * amount.0, inner.height * amount.1);
|
||||||
self.edit_to_relative(id, outer_id, 0, 0);
|
self.edit_to_relative(id, outer_id, 0, 0);
|
||||||
|
@ -429,13 +556,13 @@ impl LayoutManager {
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Lookup box
|
// {{{ 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];
|
let current = self.boxes[id.0];
|
||||||
if let Some((to, dx, dy)) = current.relative_to {
|
if let Some((to, dx, dy)) = current.relative_to {
|
||||||
let (x, y, _, _) = self.lookup(to);
|
let r = self.lookup(to);
|
||||||
(x + dx, y + dy, current.width, current.height)
|
Rect::new(r.x + dx, r.y + dy, current.width, current.height)
|
||||||
} else {
|
} 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
|
self.boxes[id.0].height
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// }}}
|
||||||
|
// {{{ Alignment
|
||||||
#[inline]
|
#[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);
|
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
|
// {{{ Draw RGB image
|
||||||
/// Draws a bitmap 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);
|
let pos = self.layout.position_relative_to(id, pos);
|
||||||
self.canvas.blit_rbg(pos, dims, src);
|
self.canvas.blit_rbg(pos, dims, src);
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Draw RGBA image
|
// {{{ Draw RGBA image
|
||||||
/// Draws a bitmap image taking care of the alpha channel.
|
/// 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);
|
let pos = self.layout.position_relative_to(id, pos);
|
||||||
self.canvas.blit_rbga(pos, dims, src);
|
self.canvas.blit_rbga(pos, dims, src);
|
||||||
}
|
}
|
||||||
|
@ -489,8 +623,11 @@ impl LayoutDrawer {
|
||||||
/// Fills with solid color
|
/// Fills with solid color
|
||||||
pub fn fill(&mut self, id: LayoutBoxId, color: Color) {
|
pub fn fill(&mut self, id: LayoutBoxId, color: Color) {
|
||||||
let current = self.layout.lookup(id);
|
let current = self.layout.lookup(id);
|
||||||
self.canvas
|
self.canvas.fill(
|
||||||
.fill((current.0, current.1), (current.2, current.3), color);
|
(current.x, current.y),
|
||||||
|
(current.width, current.height),
|
||||||
|
color,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Draw text
|
// {{{ Draw text
|
||||||
|
@ -498,7 +635,7 @@ impl LayoutDrawer {
|
||||||
pub fn text(
|
pub fn text(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: LayoutBoxId,
|
id: LayoutBoxId,
|
||||||
pos: (i32, i32),
|
pos: Position,
|
||||||
face: &mut Face,
|
face: &mut Face,
|
||||||
style: TextStyle,
|
style: TextStyle,
|
||||||
text: &str,
|
text: &str,
|
||||||
|
|
|
@ -8,7 +8,7 @@ use plotters::{
|
||||||
drawing::IntoDrawingArea,
|
drawing::IntoDrawingArea,
|
||||||
element::Circle,
|
element::Circle,
|
||||||
series::LineSeries,
|
series::LineSeries,
|
||||||
style::{Color, IntoFont, TextStyle, BLUE, WHITE},
|
style::{IntoFont, TextStyle, BLUE, WHITE},
|
||||||
};
|
};
|
||||||
use poise::{
|
use poise::{
|
||||||
serenity_prelude::{CreateAttachment, CreateMessage},
|
serenity_prelude::{CreateAttachment, CreateMessage},
|
||||||
|
@ -17,8 +17,12 @@ use poise::{
|
||||||
use sqlx::query_as;
|
use sqlx::query_as;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
assets::EXO_FONT,
|
assets::{
|
||||||
bitmap::{Align, BitmapCanvas, LayoutDrawer, LayoutManager},
|
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},
|
chart::{Chart, Song},
|
||||||
context::{Context, Error},
|
context::{Context, Error},
|
||||||
jacket::BITMAP_IMAGE_SIZE,
|
jacket::BITMAP_IMAGE_SIZE,
|
||||||
|
@ -214,7 +218,7 @@ pub async fn plot(
|
||||||
chart.draw_series(
|
chart.draw_series(
|
||||||
points
|
points
|
||||||
.iter()
|
.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()?;
|
root.present()?;
|
||||||
}
|
}
|
||||||
|
@ -280,16 +284,23 @@ pub async fn b30(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
|
||||||
let mut layout = LayoutManager::default();
|
let mut layout = LayoutManager::default();
|
||||||
let jacket_area = layout.make_box(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE);
|
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_margin = 10;
|
||||||
let jacket_with_margin =
|
let jacket_with_margin = layout.margin(
|
||||||
layout.margin(jacket_area, jacket_margin, jacket_margin, 5, jacket_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_left_area = layout.make_box(90, layout.height(jacket_with_margin));
|
||||||
let top_area = layout.glue_vertically(top_left_area, 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_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 (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);
|
// layout.normalize(root);
|
||||||
let width = layout.width(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 canvas = BitmapCanvas::new(width, height);
|
||||||
let mut drawer = LayoutDrawer::new(layout, canvas);
|
let mut drawer = LayoutDrawer::new(layout, canvas);
|
||||||
|
|
||||||
let asset_cache = &ctx.data().jacket_cache;
|
let bg = get_b30_background();
|
||||||
let bg = &asset_cache.b30_background;
|
|
||||||
|
|
||||||
drawer.blit_rbg(
|
drawer.blit_rbg(
|
||||||
root,
|
root,
|
||||||
(
|
// Align the center of the image with the center of the root
|
||||||
-((bg.width() - width) as i32) / 2,
|
Rect::from_image(bg).align(
|
||||||
-((bg.height() - height) as i32) / 2,
|
(Align::Center, Align::Center),
|
||||||
|
drawer.layout.lookup(root).center(),
|
||||||
),
|
),
|
||||||
bg.dimensions(),
|
bg.dimensions(),
|
||||||
bg.as_raw(),
|
bg.as_raw(),
|
||||||
|
@ -316,10 +327,74 @@ pub async fn b30(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
.layout
|
.layout
|
||||||
.edit_to_relative(item_with_margin, item_grid, origin.0, origin.1);
|
.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
|
// {{{ Display jacket
|
||||||
let jacket = chart.cached_jacket.as_ref().ok_or_else(|| {
|
let jacket = chart.cached_jacket.as_ref().ok_or_else(|| {
|
||||||
format!(
|
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(
|
drawer.blit_rbg(
|
||||||
jacket_area,
|
jacket_area,
|
||||||
(0, 0),
|
(0, 0),
|
||||||
|
@ -336,13 +412,15 @@ pub async fn b30(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
);
|
);
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Display difficulty background
|
// {{{ 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(
|
drawer.blit_rbga(
|
||||||
jacket_area,
|
jacket_with_border,
|
||||||
(
|
diff_bg_area.top_left(),
|
||||||
BITMAP_IMAGE_SIZE as i32 - (diff_bg.width() as i32) / 2,
|
|
||||||
-(diff_bg.height() as i32) / 2,
|
|
||||||
),
|
|
||||||
diff_bg.dimensions(),
|
diff_bg.dimensions(),
|
||||||
&diff_bg.as_raw(),
|
&diff_bg.as_raw(),
|
||||||
);
|
);
|
||||||
|
@ -356,92 +434,192 @@ pub async fn b30(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
|
||||||
// EXO_FONT.with_borrow_mut(|font| {
|
let diff_area_center = diff_bg_area.center();
|
||||||
// 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 tx = 10;
|
EXO_FONT.with_borrow_mut(|font| {
|
||||||
let ty = drawer.layout.height(bottom_area) as i32 / 2;
|
drawer.text(
|
||||||
|
jacket_with_border,
|
||||||
// let text = &song.title;
|
(diff_area_center.0 + x_offset, diff_area_center.1),
|
||||||
// let mut size = 30;
|
font,
|
||||||
// let mut text_style = TextStyle::from(("Exo", size).into_font().style(FontStyle::Bold))
|
crate::bitmap::TextStyle {
|
||||||
// .with_anchor::<RGBAColor>(Pos {
|
size: 25,
|
||||||
// h_pos: HPos::Left,
|
weight: 600,
|
||||||
// v_pos: VPos::Center,
|
color: Color::from_rgb_int(0xffffff),
|
||||||
// })
|
align: (Align::Center, Align::Center),
|
||||||
// .into_text_style(&bottom_area);
|
stroke: None,
|
||||||
//
|
drop_shadow: None,
|
||||||
// while text_style.font.layout_box(text).unwrap().1 .0 >= item_area.0 as i32 - 20 {
|
},
|
||||||
// size -= 3;
|
&chart.level,
|
||||||
// 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
|
// {{{ Display score background
|
||||||
let bg = &asset_cache.count_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(
|
||||||
drawer.blit_rbga(item_area, (-8, jacket_margin as i32), bg.dimensions(), bg);
|
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))
|
drawer.blit_rbga(
|
||||||
// .with_anchor::<RGBAColor>(Pos {
|
jacket_area,
|
||||||
// h_pos: HPos::Left,
|
status_bg_area.top_left(),
|
||||||
// v_pos: VPos::Center,
|
status_bg.dimensions(),
|
||||||
// })
|
&status_bg.as_raw(),
|
||||||
// .into_text_style(&area);
|
);
|
||||||
|
// }}}
|
||||||
|
// {{{ 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 x_offset = match status {
|
||||||
let ty = (jacket_margin + bg.height() as i32 / 2) - 3;
|
'P' => 2,
|
||||||
|
'M' => 2,
|
||||||
|
// TODO: ensure the F is rendered properly as well
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
// Draw drop shadow
|
let center = status_bg_area.center();
|
||||||
// area.draw_text(
|
|
||||||
// &format!("#{}", i + 1),
|
|
||||||
// &text_style.color(&BLACK),
|
|
||||||
// (tx + 2, ty + 2),
|
|
||||||
// )?;
|
|
||||||
|
|
||||||
// Draw main text
|
drawer.text(
|
||||||
// area.draw_text(&format!("#{}", i + 1), &text_style.color(&WHITE), (tx, ty))?;
|
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(),
|
||||||
|
);
|
||||||
// }}}
|
// }}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
use std::{fs, path::PathBuf, str::FromStr};
|
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 kd_tree::{KdMap, KdPoint};
|
||||||
use num::Integer;
|
use num::Integer;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
assets::should_skip_jacket_art,
|
assets::{get_assets_dir, should_skip_jacket_art},
|
||||||
chart::{Difficulty, Jacket, SongCache},
|
chart::{Difficulty, Jacket, SongCache},
|
||||||
context::Error,
|
context::Error,
|
||||||
score::guess_chart_name,
|
score::guess_chart_name,
|
||||||
|
@ -14,7 +14,7 @@ use crate::{
|
||||||
/// How many sub-segments to split each side into
|
/// How many sub-segments to split each side into
|
||||||
pub const SPLIT_FACTOR: u32 = 8;
|
pub const SPLIT_FACTOR: u32 = 8;
|
||||||
pub const IMAGE_VEC_DIM: usize = (SPLIT_FACTOR * SPLIT_FACTOR * 3) as usize;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ImageVec {
|
pub struct ImageVec {
|
||||||
|
@ -77,9 +77,6 @@ impl KdPoint for ImageVec {
|
||||||
|
|
||||||
pub struct JacketCache {
|
pub struct JacketCache {
|
||||||
tree: KdMap<ImageVec, u32>,
|
tree: KdMap<ImageVec, u32>,
|
||||||
pub b30_background: ImageBuffer<Rgb<u8>, Vec<u8>>,
|
|
||||||
pub count_background: ImageBuffer<Rgba<u8>, Vec<u8>>,
|
|
||||||
pub diff_backgrounds: [ImageBuffer<Rgba<u8>, Vec<u8>>; 5],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JacketCache {
|
impl JacketCache {
|
||||||
|
@ -87,7 +84,6 @@ impl JacketCache {
|
||||||
// This is a bit inefficient (using a hash set), but only runs once
|
// This is a bit inefficient (using a hash set), but only runs once
|
||||||
pub fn new(data_dir: &PathBuf, song_cache: &mut SongCache) -> Result<Self, Error> {
|
pub fn new(data_dir: &PathBuf, song_cache: &mut SongCache) -> Result<Self, Error> {
|
||||||
let jacket_dir = data_dir.join("jackets");
|
let jacket_dir = data_dir.join("jackets");
|
||||||
let assets_dir = data_dir.join("assets");
|
|
||||||
|
|
||||||
if jacket_dir.exists() {
|
if jacket_dir.exists() {
|
||||||
fs::remove_dir_all(&jacket_dir).expect("Could not delete jacket dir");
|
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");
|
fs::create_dir_all(&jacket_dir).expect("Could not create jacket dir");
|
||||||
|
|
||||||
let tree_entries = if should_skip_jacket_art() {
|
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 contents: &'static _ = fs::read(path)?.leak();
|
||||||
let image = image::load_from_memory(contents)?;
|
let image = image::load_from_memory(contents)?;
|
||||||
let bitmap: &'static _ = Box::leak(Box::new(
|
let bitmap: &'static _ = Box::leak(Box::new(
|
||||||
|
@ -210,21 +206,6 @@ impl JacketCache {
|
||||||
|
|
||||||
let result = Self {
|
let result = Self {
|
||||||
tree: KdMap::build_by_ordered_float(tree_entries),
|
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)
|
Ok(result)
|
||||||
|
|
|
@ -13,7 +13,7 @@ mod jacket;
|
||||||
mod score;
|
mod score;
|
||||||
mod user;
|
mod user;
|
||||||
|
|
||||||
use assets::DATA_DIR;
|
use assets::get_data_dir;
|
||||||
use context::{Error, UserContext};
|
use context::{Error, UserContext};
|
||||||
use poise::serenity_prelude::{self as serenity};
|
use poise::serenity_prelude::{self as serenity};
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
@ -33,7 +33,7 @@ async fn on_error(error: poise::FrameworkError<'_, UserContext, Error>) {
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn 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 cache_dir = var("SHIMMERING_CACHE_DIR").expect("Missing `SHIMMERING_CACHE_DIR` env var");
|
||||||
|
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
|
|
68
src/score.rs
|
@ -18,6 +18,34 @@ use crate::context::{Error, UserContext};
|
||||||
use crate::jacket::IMAGE_VEC_DIM;
|
use crate::jacket::IMAGE_VEC_DIM;
|
||||||
use crate::user::User;
|
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
|
// {{{ Score
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct Score(pub u32);
|
pub struct Score(pub u32);
|
||||||
|
@ -110,22 +138,22 @@ impl Score {
|
||||||
// {{{ Score => grade
|
// {{{ Score => grade
|
||||||
#[inline]
|
#[inline]
|
||||||
// TODO: Perhaps make an enum for this
|
// TODO: Perhaps make an enum for this
|
||||||
pub fn grade(self) -> &'static str {
|
pub fn grade(self) -> Grade {
|
||||||
let score = self.0;
|
let score = self.0;
|
||||||
if score > 9900000 {
|
if score > 9900000 {
|
||||||
"EX+"
|
Grade::EXP
|
||||||
} else if score > 9800000 {
|
} else if score > 9800000 {
|
||||||
"EX"
|
Grade::EX
|
||||||
} else if score > 9500000 {
|
} else if score > 9500000 {
|
||||||
"AA"
|
Grade::AA
|
||||||
} else if score > 9200000 {
|
} else if score > 9200000 {
|
||||||
"A"
|
Grade::A
|
||||||
} else if score > 8900000 {
|
} else if score > 8900000 {
|
||||||
"B"
|
Grade::B
|
||||||
} else if score > 8600000 {
|
} else if score > 8600000 {
|
||||||
"C"
|
Grade::C
|
||||||
} else {
|
} else {
|
||||||
"D"
|
Grade::D
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
|
@ -477,7 +505,6 @@ impl Play {
|
||||||
pub fn status(&self, chart: &Chart) -> Option<String> {
|
pub fn status(&self, chart: &Chart) -> Option<String> {
|
||||||
let score = self.score.0;
|
let score = self.score.0;
|
||||||
if score >= 10_000_000 {
|
if score >= 10_000_000 {
|
||||||
// Prevent subtracting with overflow
|
|
||||||
if score > chart.note_count + 10_000_000 {
|
if score > chart.note_count + 10_000_000 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
@ -502,6 +529,25 @@ impl Play {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn short_status(&self, chart: &Chart) -> Option<char> {
|
||||||
|
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
|
// {{{ Play to embed
|
||||||
/// Creates a discord embed for this play.
|
/// Creates a discord embed for this play.
|
||||||
|
@ -534,7 +580,7 @@ impl Play {
|
||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.field("Grade", self.score.grade(), true)
|
.field("Grade", format!("{}", self.score.grade()), true)
|
||||||
.field("ξ-Score", format!("{} (+?)", self.zeta_score), true)
|
.field("ξ-Score", format!("{} (+?)", self.zeta_score), true)
|
||||||
.field(
|
.field(
|
||||||
"ξ-Rating",
|
"ξ-Rating",
|
||||||
|
@ -544,7 +590,7 @@ impl Play {
|
||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.field("ξ-Grade", self.zeta_score.grade(), true)
|
.field("ξ-Grade", format!("{}", self.zeta_score.grade()), true)
|
||||||
.field(
|
.field(
|
||||||
"Status",
|
"Status",
|
||||||
self.status(chart).unwrap_or("?".to_string()),
|
self.status(chart).unwrap_or("?".to_string()),
|
||||||
|
|