Skip to content

Commit a207cac

Browse files
authored
feat: temp hytale avatars (#130)
1 parent 8ac72d8 commit a207cac

File tree

4 files changed

+339
-30
lines changed

4 files changed

+339
-30
lines changed

src/rust/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ extern crate wasm_bindgen;
44

55
mod hytale;
66
mod minecraft;
7+
mod text_avatar;
78
mod utils;
89

910
use hytale::HytaleSkin;
@@ -204,6 +205,21 @@ pub fn get_rendered_image(
204205
}
205206
}
206207

208+
/// Render a text-based avatar with username initials and deterministic background color
209+
/// TEMPORARY: Used for Hytale until real skin support is implemented
210+
#[wasm_bindgen]
211+
pub fn render_text_avatar(username: String, size: u32) -> Result<Uint8Array, JsValue> {
212+
let image = text_avatar::render_text_avatar(&username, size);
213+
let dynamic = DynamicImage::ImageRgba8(image);
214+
215+
let estimated_size = (size * size * 2).max(4096) as usize;
216+
let mut result = Cursor::new(Vec::with_capacity(estimated_size));
217+
match dynamic.write_to(&mut result, image::ImageFormat::Png) {
218+
Ok(()) => Ok(Uint8Array::from(&result.get_ref()[..])),
219+
Err(_err) => Err(js_sys::Error::new("Couldn't render text avatar.").into()),
220+
}
221+
}
222+
207223
#[cfg(test)]
208224
mod tests {
209225
use super::*;

src/rust/text_avatar.rs

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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+
}

src/worker/index.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import { get_rendered_image } from '../../pkg/mcavatar';
88

99
import type { CraftheadRequest } from './request';
1010

11+
interface DirectRenderService {
12+
renderAvatar(request: CraftheadRequest): Response;
13+
}
14+
15+
type GameService = typeof mojangService | typeof hytaleService;
16+
1117
function decorateHeaders(interpreted: CraftheadRequest, headers: Headers, hitCache: boolean): Headers {
1218
const copiedHeaders = new Headers(headers);
1319

@@ -61,7 +67,7 @@ async function renderImage(skin: Response, request: CraftheadRequest): Promise<R
6167
});
6268
}
6369

64-
function getService(game: Game) {
70+
function getService(game: Game): GameService {
6571
switch (game) {
6672
case Game.Minecraft: {
6773
return mojangService;
@@ -75,6 +81,10 @@ function getService(game: Game) {
7581
}
7682
}
7783

84+
function hasRenderAvatar(service: GameService): service is GameService & DirectRenderService {
85+
return 'renderAvatar' in service;
86+
}
87+
7888
async function processRequest(request: Request, interpreted: CraftheadRequest): Promise<Response> {
7989
const service = getService(interpreted.game);
8090

@@ -100,6 +110,9 @@ async function processRequest(request: Request, interpreted: CraftheadRequest):
100110
case RequestedKind.Cube:
101111
case RequestedKind.Body:
102112
case RequestedKind.Bust: {
113+
if (hasRenderAvatar(service)) {
114+
return service.renderAvatar(interpreted);
115+
}
103116
const skin = await service.retrieveSkin(request, interpreted);
104117
return renderImage(skin, interpreted);
105118
}
@@ -112,7 +125,7 @@ async function processRequest(request: Request, interpreted: CraftheadRequest):
112125
return new Response(EMPTY, {
113126
status: 404,
114127
headers: {
115-
'X-Crafthead-Profile-Cache-Hit': 'invalid-profile',
128+
'X-Crafthead-Profile-Cache-Hit': cape.headers.get('X-Crafthead-Profile-Cache-Hit') || 'invalid-profile',
116129
},
117130
});
118131
}

0 commit comments

Comments
 (0)