|
| 1 | +//! Text-based avatar rendering for Hytale |
| 2 | +//! |
| 3 | +//! TEMPORARY: This module renders username initials as avatars until real Hytale |
| 4 | +//! skin support is implemented. Once Hytale skin textures are available, this |
| 5 | +//! can be replaced with proper skin-based rendering like Minecraft. |
| 6 | +
|
| 7 | +use image::{Rgba, RgbaImage}; |
| 8 | + |
| 9 | +/// Simple 5x7 pixel font for uppercase letters and digits |
| 10 | +/// Each character is represented as 7 rows of 5 bits (stored as u8) |
| 11 | +#[rustfmt::skip] |
| 12 | +const FONT_5X7: [([u8; 7], char); 36] = [ |
| 13 | + ([0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001], 'A'), |
| 14 | + ([0b11110, 0b10001, 0b11110, 0b10001, 0b10001, 0b10001, 0b11110], 'B'), |
| 15 | + ([0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110], 'C'), |
| 16 | + ([0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110], 'D'), |
| 17 | + ([0b11111, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000, 0b11111], 'E'), |
| 18 | + ([0b11111, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000, 0b10000], 'F'), |
| 19 | + ([0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110], 'G'), |
| 20 | + ([0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001, 0b10001], 'H'), |
| 21 | + ([0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], 'I'), |
| 22 | + ([0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100], 'J'), |
| 23 | + ([0b10001, 0b10010, 0b11100, 0b10010, 0b10001, 0b10001, 0b10001], 'K'), |
| 24 | + ([0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111], 'L'), |
| 25 | + ([0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001], 'M'), |
| 26 | + ([0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001], 'N'), |
| 27 | + ([0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110], 'O'), |
| 28 | + ([0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000], 'P'), |
| 29 | + ([0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101], 'Q'), |
| 30 | + ([0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001], 'R'), |
| 31 | + ([0b01110, 0b10001, 0b10000, 0b01110, 0b00001, 0b10001, 0b01110], 'S'), |
| 32 | + ([0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100], 'T'), |
| 33 | + ([0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110], 'U'), |
| 34 | + ([0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100], 'V'), |
| 35 | + ([0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b10101, 0b01010], 'W'), |
| 36 | + ([0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001], 'X'), |
| 37 | + ([0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100], 'Y'), |
| 38 | + ([0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111], 'Z'), |
| 39 | + ([0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], '0'), |
| 40 | + ([0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], '1'), |
| 41 | + ([0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111], '2'), |
| 42 | + ([0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110], '3'), |
| 43 | + ([0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], '4'), |
| 44 | + ([0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110], '5'), |
| 45 | + ([0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110], '6'), |
| 46 | + ([0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000], '7'), |
| 47 | + ([0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110], '8'), |
| 48 | + ([0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100], '9'), |
| 49 | +]; |
| 50 | + |
| 51 | +/// Get the font data for a character |
| 52 | +fn get_char_data(c: char) -> Option<[u8; 7]> { |
| 53 | + let upper = c.to_ascii_uppercase(); |
| 54 | + FONT_5X7 |
| 55 | + .iter() |
| 56 | + .find(|(_, ch)| *ch == upper) |
| 57 | + .map(|(data, _)| *data) |
| 58 | +} |
| 59 | + |
| 60 | +/// Simple hash function for generating deterministic colors |
| 61 | +fn hash_username(username: &str) -> u32 { |
| 62 | + let mut hash: u32 = 0; |
| 63 | + for byte in username.to_lowercase().bytes() { |
| 64 | + hash = hash.wrapping_mul(31).wrapping_add(byte as u32); |
| 65 | + } |
| 66 | + hash |
| 67 | +} |
| 68 | + |
| 69 | +/// Calculate relative luminance of an RGB color (0.0 to 1.0) |
| 70 | +/// Uses sRGB luminance coefficients per WCAG guidelines |
| 71 | +fn relative_luminance(r: u8, g: u8, b: u8) -> f32 { |
| 72 | + let r = r as f32 / 255.0; |
| 73 | + let g = g as f32 / 255.0; |
| 74 | + let b = b as f32 / 255.0; |
| 75 | + 0.2126 * r + 0.7152 * g + 0.0722 * b |
| 76 | +} |
| 77 | + |
| 78 | +/// Choose contrasting text color (white or dark) based on background luminance |
| 79 | +fn contrasting_text_color(bg: Rgba<u8>) -> Rgba<u8> { |
| 80 | + let luminance = relative_luminance(bg[0], bg[1], bg[2]); |
| 81 | + // Use white text on dark backgrounds, dark text on light backgrounds |
| 82 | + // Threshold of 0.5 provides good contrast in both cases |
| 83 | + if luminance > 0.5 { |
| 84 | + Rgba([30, 30, 30, 255]) // Dark gray for light backgrounds |
| 85 | + } else { |
| 86 | + Rgba([255, 255, 255, 255]) // White for dark backgrounds |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +/// Generate a pleasing background color from username hash |
| 91 | +/// Uses HSL-like approach to get saturated colors |
| 92 | +fn username_to_color(username: &str) -> Rgba<u8> { |
| 93 | + let hash = hash_username(username); |
| 94 | + |
| 95 | + // Use hash to determine hue (0-360 degrees mapped to color) |
| 96 | + let hue: f32 = (hash % 360) as f32; |
| 97 | + // Fixed saturation and lightness for pleasing colors |
| 98 | + let saturation: f32 = 0.65; |
| 99 | + let lightness: f32 = 0.45; |
| 100 | + |
| 101 | + // HSL to RGB conversion |
| 102 | + let c: f32 = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; |
| 103 | + let x: f32 = c * (1.0 - ((hue / 60.0) % 2.0 - 1.0).abs()); |
| 104 | + let m: f32 = lightness - c / 2.0; |
| 105 | + |
| 106 | + let (r, g, b): (f32, f32, f32) = match (hue / 60.0) as u32 { |
| 107 | + 0 => (c, x, 0.0), |
| 108 | + 1 => (x, c, 0.0), |
| 109 | + 2 => (0.0, c, x), |
| 110 | + 3 => (0.0, x, c), |
| 111 | + 4 => (x, 0.0, c), |
| 112 | + _ => (c, 0.0, x), |
| 113 | + }; |
| 114 | + |
| 115 | + Rgba([ |
| 116 | + ((r + m) * 255.0) as u8, |
| 117 | + ((g + m) * 255.0) as u8, |
| 118 | + ((b + m) * 255.0) as u8, |
| 119 | + 255, |
| 120 | + ]) |
| 121 | +} |
| 122 | + |
| 123 | +/// Extract initials from username |
| 124 | +/// "CherryJimbo" -> "CJ", "james" -> "J", "AB" -> "AB" |
| 125 | +fn extract_initials(username: &str) -> String { |
| 126 | + let chars: Vec<char> = username.chars().collect(); |
| 127 | + |
| 128 | + if chars.is_empty() { |
| 129 | + return "?".to_string(); |
| 130 | + } |
| 131 | + |
| 132 | + // For short usernames (2 chars or less), just return them uppercased |
| 133 | + if chars.len() <= 2 { |
| 134 | + return chars.iter().map(|c| c.to_ascii_uppercase()).collect(); |
| 135 | + } |
| 136 | + |
| 137 | + // Find capital letters for CamelCase detection |
| 138 | + let mut initials = String::new(); |
| 139 | + let mut prev_was_lower = false; |
| 140 | + |
| 141 | + for (i, c) in chars.iter().enumerate() { |
| 142 | + if i == 0 { |
| 143 | + // Always include first character |
| 144 | + initials.push(c.to_ascii_uppercase()); |
| 145 | + prev_was_lower = c.is_ascii_lowercase(); |
| 146 | + } else if c.is_ascii_uppercase() && prev_was_lower && initials.len() < 2 { |
| 147 | + // CamelCase transition |
| 148 | + initials.push(*c); |
| 149 | + } else { |
| 150 | + prev_was_lower = c.is_ascii_lowercase(); |
| 151 | + } |
| 152 | + |
| 153 | + if initials.len() >= 2 { |
| 154 | + break; |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + initials |
| 159 | +} |
| 160 | + |
| 161 | +/// Draw a single character at the specified position with scaling |
| 162 | +fn draw_char(image: &mut RgbaImage, c: char, x: i32, y: i32, scale: u32, color: Rgba<u8>) { |
| 163 | + if let Some(char_data) = get_char_data(c) { |
| 164 | + for (row_idx, row) in char_data.iter().enumerate() { |
| 165 | + for col in 0..5 { |
| 166 | + if (row >> (4 - col)) & 1 == 1 { |
| 167 | + // Draw scaled pixel |
| 168 | + for sy in 0..scale { |
| 169 | + for sx in 0..scale { |
| 170 | + let px = x + (col * scale as i32) + sx as i32; |
| 171 | + let py = y + (row_idx as u32 * scale) as i32 + sy as i32; |
| 172 | + if px >= 0 |
| 173 | + && py >= 0 && (px as u32) < image.width() |
| 174 | + && (py as u32) < image.height() |
| 175 | + { |
| 176 | + image.put_pixel(px as u32, py as u32, color); |
| 177 | + } |
| 178 | + } |
| 179 | + } |
| 180 | + } |
| 181 | + } |
| 182 | + } |
| 183 | + } |
| 184 | +} |
| 185 | + |
| 186 | +/// Render a text avatar with username initials |
| 187 | +pub fn render_text_avatar(username: &str, size: u32) -> RgbaImage { |
| 188 | + let bg_color = username_to_color(username); |
| 189 | + let text_color = contrasting_text_color(bg_color); |
| 190 | + |
| 191 | + let initials = extract_initials(username); |
| 192 | + let num_chars = initials.len(); |
| 193 | + |
| 194 | + // Create image with background color |
| 195 | + let mut image = RgbaImage::from_pixel(size, size, bg_color); |
| 196 | + |
| 197 | + // Calculate scale based on size |
| 198 | + // Font is 5x7, we want it to be about 60% of the image height |
| 199 | + let target_height = (size as f32 * 0.6) as u32; |
| 200 | + let scale = (target_height / 7).max(1); |
| 201 | + |
| 202 | + let char_width = 5 * scale; |
| 203 | + let char_height = 7 * scale; |
| 204 | + let spacing = scale; // Space between characters |
| 205 | + |
| 206 | + // Calculate total text width |
| 207 | + let total_width = if num_chars == 1 { |
| 208 | + char_width |
| 209 | + } else { |
| 210 | + char_width * 2 + spacing |
| 211 | + }; |
| 212 | + |
| 213 | + // Center the text |
| 214 | + let start_x = ((size - total_width) / 2) as i32; |
| 215 | + let start_y = ((size - char_height) / 2) as i32; |
| 216 | + |
| 217 | + // Draw each character |
| 218 | + for (i, c) in initials.chars().enumerate() { |
| 219 | + let x = start_x + (i as i32 * (char_width as i32 + spacing as i32)); |
| 220 | + draw_char(&mut image, c, x, start_y, scale, text_color); |
| 221 | + } |
| 222 | + |
| 223 | + image |
| 224 | +} |
| 225 | + |
| 226 | +#[cfg(test)] |
| 227 | +mod tests { |
| 228 | + use super::*; |
| 229 | + |
| 230 | + #[test] |
| 231 | + fn test_extract_initials_camelcase() { |
| 232 | + assert_eq!(extract_initials("CherryJimbo"), "CJ"); |
| 233 | + } |
| 234 | + |
| 235 | + #[test] |
| 236 | + fn test_extract_initials_single() { |
| 237 | + assert_eq!(extract_initials("james"), "J"); |
| 238 | + } |
| 239 | + |
| 240 | + #[test] |
| 241 | + fn test_extract_initials_short() { |
| 242 | + assert_eq!(extract_initials("AB"), "AB"); |
| 243 | + } |
| 244 | + |
| 245 | + #[test] |
| 246 | + fn test_extract_initials_empty() { |
| 247 | + assert_eq!(extract_initials(""), "?"); |
| 248 | + } |
| 249 | + |
| 250 | + #[test] |
| 251 | + fn test_username_color_deterministic() { |
| 252 | + let color1 = username_to_color("test"); |
| 253 | + let color2 = username_to_color("test"); |
| 254 | + assert_eq!(color1, color2); |
| 255 | + } |
| 256 | + |
| 257 | + #[test] |
| 258 | + fn test_username_color_different() { |
| 259 | + let color1 = username_to_color("alice"); |
| 260 | + let color2 = username_to_color("bob"); |
| 261 | + assert_ne!(color1, color2); |
| 262 | + } |
| 263 | + |
| 264 | + #[test] |
| 265 | + fn test_contrasting_text_dark_bg() { |
| 266 | + // Dark background should get white text |
| 267 | + let dark_bg = Rgba([30, 30, 30, 255]); |
| 268 | + let text = contrasting_text_color(dark_bg); |
| 269 | + assert_eq!(text, Rgba([255, 255, 255, 255])); |
| 270 | + } |
| 271 | + |
| 272 | + #[test] |
| 273 | + fn test_contrasting_text_light_bg() { |
| 274 | + // Light background should get dark text |
| 275 | + let light_bg = Rgba([200, 200, 200, 255]); |
| 276 | + let text = contrasting_text_color(light_bg); |
| 277 | + assert_eq!(text, Rgba([30, 30, 30, 255])); |
| 278 | + } |
| 279 | +} |
0 commit comments