Skip to main content

rusterix/
avatar.rs

1use crate::Texture;
2use theframework::prelude::*;
3
4/// Screen-relative direction for avatar perspectives.
5#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy, Debug)]
6pub enum AvatarDirection {
7    Front,
8    Back,
9    Left,
10    Right,
11}
12
13/// Frames for a single perspective direction.
14#[derive(Serialize, Deserialize, Clone, Debug)]
15pub struct AvatarAnimationFrame {
16    pub texture: Texture,
17    #[serde(default)]
18    pub weapon_main_anchor: Option<(i16, i16)>,
19    #[serde(default)]
20    pub weapon_off_anchor: Option<(i16, i16)>,
21}
22
23impl AvatarAnimationFrame {
24    pub fn new(texture: Texture) -> Self {
25        Self {
26            texture,
27            weapon_main_anchor: None,
28            weapon_off_anchor: None,
29        }
30    }
31}
32
33#[derive(Deserialize)]
34#[serde(untagged)]
35enum AvatarFrameSerde {
36    Texture(Texture),
37    Frame(AvatarAnimationFrame),
38}
39
40fn deserialize_avatar_frames<'de, D>(deserializer: D) -> Result<Vec<AvatarAnimationFrame>, D::Error>
41where
42    D: serde::Deserializer<'de>,
43{
44    let frames = Vec::<AvatarFrameSerde>::deserialize(deserializer)?;
45    Ok(frames
46        .into_iter()
47        .map(|f| match f {
48            AvatarFrameSerde::Texture(texture) => AvatarAnimationFrame::new(texture),
49            AvatarFrameSerde::Frame(frame) => frame,
50        })
51        .collect())
52}
53
54#[derive(Serialize, Deserialize, Clone, Debug)]
55pub struct AvatarPerspective {
56    pub direction: AvatarDirection,
57    #[serde(default, deserialize_with = "deserialize_avatar_frames")]
58    pub frames: Vec<AvatarAnimationFrame>,
59    #[serde(default)]
60    pub weapon_main_anchor: Option<(i16, i16)>,
61    #[serde(default)]
62    pub weapon_off_anchor: Option<(i16, i16)>,
63}
64
65impl Default for AvatarPerspective {
66    fn default() -> Self {
67        Self {
68            direction: AvatarDirection::Front,
69            frames: vec![],
70            weapon_main_anchor: None,
71            weapon_off_anchor: None,
72        }
73    }
74}
75
76/// A named animation with perspectives, each holding its own frames.
77#[derive(Serialize, Deserialize, Clone, Debug)]
78pub struct AvatarAnimation {
79    pub id: Uuid,
80    pub name: String,
81    /// Playback time scale: 1.0 = normal, >1.0 = slower, <1.0 = faster.
82    #[serde(default = "AvatarAnimation::default_speed")]
83    pub speed: f32,
84    pub perspectives: Vec<AvatarPerspective>,
85}
86
87impl Default for AvatarAnimation {
88    fn default() -> Self {
89        Self {
90            id: Uuid::new_v4(),
91            name: "Unnamed".to_string(),
92            speed: 1.0,
93            perspectives: vec![],
94        }
95    }
96}
97
98impl AvatarAnimation {
99    fn default_speed() -> f32 {
100        1.0
101    }
102}
103
104/// Number of perspective directions supported by an avatar.
105#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Debug, Hash)]
106pub enum AvatarPerspectiveCount {
107    /// Single direction (Front only)
108    One,
109    /// Four directions (Front, Back, Left, Right)
110    Four,
111}
112
113impl Default for AvatarPerspectiveCount {
114    fn default() -> Self {
115        Self::One
116    }
117}
118
119/// The data for a character instance.
120#[derive(Serialize, Deserialize, Clone, Debug)]
121pub struct Avatar {
122    pub id: Uuid,
123    pub name: String,
124    pub resolution: u16,
125    pub perspective_count: AvatarPerspectiveCount,
126    pub animations: Vec<AvatarAnimation>,
127}
128
129impl Default for Avatar {
130    fn default() -> Self {
131        Self {
132            id: Uuid::new_v4(),
133            name: "Unnamed".to_string(),
134            resolution: 24,
135            perspective_count: AvatarPerspectiveCount::One,
136            animations: vec![],
137        }
138    }
139}
140
141impl Avatar {
142    /// Sets the resolution and resizes all existing frame textures to the new size.
143    pub fn set_resolution(&mut self, new_resolution: u16) {
144        if new_resolution == self.resolution || new_resolution == 0 {
145            return;
146        }
147        let size = new_resolution as usize;
148        for animation in &mut self.animations {
149            for perspective in &mut animation.perspectives {
150                for frame in &mut perspective.frames {
151                    frame.texture = frame.texture.resized(size, size);
152                }
153            }
154        }
155        self.resolution = new_resolution;
156    }
157
158    /// Sets the perspective count, adding or removing perspective entries in each animation.
159    pub fn set_perspective_count(&mut self, count: AvatarPerspectiveCount) {
160        if count == self.perspective_count {
161            return;
162        }
163
164        let size = self.resolution as usize;
165
166        let needed: &[AvatarDirection] = match count {
167            AvatarPerspectiveCount::One => &[AvatarDirection::Front],
168            AvatarPerspectiveCount::Four => &[
169                AvatarDirection::Front,
170                AvatarDirection::Back,
171                AvatarDirection::Left,
172                AvatarDirection::Right,
173            ],
174        };
175
176        for anim in &mut self.animations {
177            // Determine frame count from existing perspectives (use first, fallback 1)
178            let frame_count = anim
179                .perspectives
180                .first()
181                .map(|p| p.frames.len())
182                .unwrap_or(1)
183                .max(1);
184
185            // Add missing perspectives with matching frame count
186            for dir in needed {
187                if !anim.perspectives.iter().any(|p| p.direction == *dir) {
188                    let frames = (0..frame_count)
189                        .map(|_| {
190                            AvatarAnimationFrame::new(Texture::new(
191                                vec![0; size * size * 4],
192                                size,
193                                size,
194                            ))
195                        })
196                        .collect();
197                    anim.perspectives.push(AvatarPerspective {
198                        direction: *dir,
199                        frames,
200                        weapon_main_anchor: None,
201                        weapon_off_anchor: None,
202                    });
203                }
204            }
205
206            // Remove perspectives not in the needed set
207            anim.perspectives.retain(|p| needed.contains(&p.direction));
208        }
209
210        self.perspective_count = count;
211    }
212
213    /// Sets the frame count for the given animation, allocating or truncating textures.
214    /// Frame count is clamped to a minimum of 1.
215    pub fn set_animation_frame_count(&mut self, animation_id: &Uuid, count: usize) {
216        let count = count.max(1);
217        let size = self.resolution as usize;
218
219        if let Some(anim) = self.animations.iter_mut().find(|a| a.id == *animation_id) {
220            // Ensure perspectives exist based on perspective_count
221            let needed: &[AvatarDirection] = match self.perspective_count {
222                AvatarPerspectiveCount::One => &[AvatarDirection::Front],
223                AvatarPerspectiveCount::Four => &[
224                    AvatarDirection::Front,
225                    AvatarDirection::Back,
226                    AvatarDirection::Left,
227                    AvatarDirection::Right,
228                ],
229            };
230
231            // Add missing perspectives
232            for dir in needed {
233                if !anim.perspectives.iter().any(|p| p.direction == *dir) {
234                    anim.perspectives.push(AvatarPerspective {
235                        direction: *dir,
236                        frames: vec![],
237                        weapon_main_anchor: None,
238                        weapon_off_anchor: None,
239                    });
240                }
241            }
242
243            // Resize frames in each perspective
244            for perspective in &mut anim.perspectives {
245                let current = perspective.frames.len();
246                if count > current {
247                    for _ in current..count {
248                        perspective
249                            .frames
250                            .push(AvatarAnimationFrame::new(Texture::new(
251                                vec![0; size * size * 4],
252                                size,
253                                size,
254                            )));
255                    }
256                } else if count < current {
257                    perspective.frames.truncate(count);
258                }
259            }
260        }
261    }
262
263    /// Returns the frame count for the given animation (from the first perspective, or 0).
264    pub fn get_animation_frame_count(&self, animation_id: &Uuid) -> usize {
265        self.animations
266            .iter()
267            .find(|a| a.id == *animation_id)
268            .and_then(|a| a.perspectives.first())
269            .map(|p| p.frames.len())
270            .unwrap_or(0)
271    }
272}
273
274/// Marker recolor configuration used by avatar builds.
275#[derive(Clone, Copy, Debug)]
276pub struct AvatarMarkerColors {
277    pub skin_light: [u8; 4],
278    pub skin_dark: [u8; 4],
279    pub torso: [u8; 4],
280    pub arms: [u8; 4],
281    pub legs: [u8; 4],
282    pub hair: [u8; 4],
283    pub eyes: [u8; 4],
284    pub hands: [u8; 4],
285    pub feet: [u8; 4],
286}
287
288impl Default for AvatarMarkerColors {
289    fn default() -> Self {
290        Self {
291            skin_light: [255, 224, 189, 255],
292            skin_dark: [205, 133, 63, 255],
293            torso: [70, 90, 140, 255],
294            arms: [85, 105, 155, 255],
295            legs: [50, 60, 90, 255],
296            hair: [70, 50, 30, 255],
297            eyes: [30, 80, 120, 255],
298            hands: [255, 210, 170, 255],
299            feet: [80, 70, 60, 255],
300        }
301    }
302}
303
304/// Runtime avatar shading options.
305#[derive(Clone, Copy, Debug, PartialEq, Eq)]
306pub struct AvatarShadingOptions {
307    /// Enables/disables generated ramp shading for avatar markers.
308    pub enabled: bool,
309    /// Enables/disables generated ramp shading specifically for skin markers.
310    pub skin_enabled: bool,
311}
312
313impl Default for AvatarShadingOptions {
314    fn default() -> Self {
315        Self {
316            enabled: true,
317            skin_enabled: false,
318        }
319    }
320}
321
322/// Output image data for an avatar frame.
323#[derive(Clone, Debug)]
324pub struct AvatarBuildOutput {
325    pub size: u32,
326    pub rgba: Vec<u8>,
327}
328
329/// Request for building a single avatar frame.
330pub struct AvatarBuildRequest<'a> {
331    pub avatar: &'a Avatar,
332    pub animation_name: Option<&'a str>,
333    pub direction: AvatarDirection,
334    pub frame_index: usize,
335    pub marker_colors: AvatarMarkerColors,
336    pub shading: AvatarShadingOptions,
337}
338
339/// Stub avatar builder.
340/// This currently recolors marker pixels on selected frame data.
341pub struct AvatarBuilder;
342
343impl AvatarBuilder {
344    pub fn build_current_stub(req: AvatarBuildRequest<'_>) -> Option<AvatarBuildOutput> {
345        let anim = req
346            .animation_name
347            .and_then(|name| {
348                req.avatar
349                    .animations
350                    .iter()
351                    .find(|a| a.name.eq_ignore_ascii_case(name))
352            })
353            .or_else(|| req.avatar.animations.first())?;
354
355        let persp = anim
356            .perspectives
357            .iter()
358            .find(|p| p.direction == req.direction)
359            .or_else(|| {
360                anim.perspectives
361                    .iter()
362                    .find(|p| p.direction == AvatarDirection::Front)
363            })
364            .or_else(|| anim.perspectives.first())?;
365
366        if persp.frames.is_empty() {
367            return None;
368        }
369
370        let frame = persp.frames.get(req.frame_index % persp.frames.len())?;
371
372        // SceneVM avatar data is square; normalize here for the stub path.
373        let target_size = frame.texture.width.max(frame.texture.height);
374        let mut rgba = if frame.texture.width == frame.texture.height {
375            frame.texture.data.clone()
376        } else {
377            frame.texture.resized(target_size, target_size).data
378        };
379
380        Self::recolor_markers(&mut rgba, req.marker_colors, req.shading, target_size);
381
382        Some(AvatarBuildOutput {
383            size: target_size as u32,
384            rgba,
385        })
386    }
387
388    fn recolor_markers(
389        rgba: &mut [u8],
390        colors: AvatarMarkerColors,
391        shading: AvatarShadingOptions,
392        size: usize,
393    ) {
394        const SKIN_LIGHT: [u8; 3] = [255, 0, 255];
395        const SKIN_DARK: [u8; 3] = [200, 0, 200];
396        const TORSO: [u8; 3] = [0, 0, 255];
397        const ARMS: [u8; 3] = [0, 120, 255];
398        const LEGS: [u8; 3] = [0, 255, 0];
399        const HAIR: [u8; 3] = [255, 255, 0];
400        const EYES: [u8; 3] = [0, 255, 255];
401        const HANDS: [u8; 3] = [255, 128, 0];
402        const FEET: [u8; 3] = [255, 80, 0];
403
404        let skin_light_ramp = Self::build_shade_ramp(colors.skin_light);
405        let skin_dark_ramp = Self::build_shade_ramp(colors.skin_dark);
406        let torso_ramp = Self::build_shade_ramp(colors.torso);
407        let arms_ramp = Self::build_shade_ramp(colors.arms);
408        let legs_ramp = Self::build_shade_ramp(colors.legs);
409        let hair_ramp = Self::build_shade_ramp(colors.hair);
410        let eyes_ramp = Self::build_shade_ramp(colors.eyes);
411        let hands_ramp = Self::build_shade_ramp(colors.hands);
412        let feet_ramp = Self::build_shade_ramp(colors.feet);
413
414        // Compute per-marker vertical bounds so each body part ramps across its own size.
415        let mut min_y = [usize::MAX; 9];
416        let mut max_y = [0usize; 9];
417        for (i, px) in rgba.chunks_exact(4).enumerate() {
418            if px[3] == 0 || size == 0 {
419                continue;
420            }
421            let src = [px[0], px[1], px[2]];
422            let channel = if src == SKIN_LIGHT {
423                Some(0usize)
424            } else if src == SKIN_DARK {
425                Some(1usize)
426            } else if src == TORSO {
427                Some(2usize)
428            } else if src == ARMS {
429                Some(3usize)
430            } else if src == LEGS {
431                Some(4usize)
432            } else if src == HAIR {
433                Some(5usize)
434            } else if src == EYES {
435                Some(6usize)
436            } else if src == HANDS {
437                Some(7usize)
438            } else if src == FEET {
439                Some(8usize)
440            } else {
441                None
442            };
443            if let Some(channel) = channel {
444                let y = i / size;
445                min_y[channel] = min_y[channel].min(y);
446                max_y[channel] = max_y[channel].max(y);
447            }
448        }
449
450        for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
451            if px[3] == 0 || size == 0 {
452                continue;
453            }
454            let x = i % size;
455            let y = i / size;
456            let src = [px[0], px[1], px[2]];
457            let (ramp, channel_seed) = if src == SKIN_LIGHT {
458                (&skin_light_ramp, 0u32)
459            } else if src == SKIN_DARK {
460                (&skin_dark_ramp, 1u32)
461            } else if src == TORSO {
462                (&torso_ramp, 2u32)
463            } else if src == ARMS {
464                (&arms_ramp, 3u32)
465            } else if src == LEGS {
466                (&legs_ramp, 4u32)
467            } else if src == HAIR {
468                (&hair_ramp, 5u32)
469            } else if src == EYES {
470                (&eyes_ramp, 6u32)
471            } else if src == HANDS {
472                (&hands_ramp, 7u32)
473            } else if src == FEET {
474                (&feet_ramp, 8u32)
475            } else {
476                continue;
477            };
478            let channel = channel_seed as usize;
479            let y0 = min_y[channel];
480            let y1 = max_y[channel];
481            let yf_local = if y0 == usize::MAX || y1 <= y0 {
482                0.5
483            } else {
484                (y.saturating_sub(y0)) as f32 / (y1 - y0) as f32
485            };
486            let is_skin = channel <= 1;
487            let use_ramp = shading.enabled && (shading.skin_enabled || !is_skin);
488            let shade_idx = if use_ramp {
489                Self::shade_index_for_pixel(x, y, yf_local, channel_seed)
490            } else {
491                1 // Flat base color (mid)
492            };
493            px.copy_from_slice(&ramp[shade_idx]);
494        }
495    }
496
497    #[inline]
498    fn build_shade_ramp(base: [u8; 4]) -> [[u8; 4]; 4] {
499        // Bright to dark. These are generated at runtime from one base color.
500        [
501            Self::modulate_rgb(base, 1.18),
502            Self::modulate_rgb(base, 1.00),
503            Self::modulate_rgb(base, 0.82),
504            Self::modulate_rgb(base, 0.64),
505        ]
506    }
507
508    #[inline]
509    fn modulate_rgb(base: [u8; 4], factor: f32) -> [u8; 4] {
510        let r = (base[0] as f32 * factor).clamp(0.0, 255.0) as u8;
511        let g = (base[1] as f32 * factor).clamp(0.0, 255.0) as u8;
512        let b = (base[2] as f32 * factor).clamp(0.0, 255.0) as u8;
513        [r, g, b, base[3]]
514    }
515
516    #[inline]
517    fn shade_index_for_pixel(x: usize, y: usize, yf_local: f32, channel_seed: u32) -> usize {
518        // 4x4 Bayer threshold for stable, pixel-art-friendly variation.
519        const BAYER4: [f32; 16] = [
520            0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0,
521        ];
522        let d = BAYER4[(y & 3) * 4 + (x & 3)] / 15.0; // 0..1
523        let yf = yf_local.clamp(0.0, 1.0); // top(0) -> bottom(1) in local marker bounds
524        let channel_bias = (channel_seed % 3) as f32 * 0.03;
525        let t = (yf * 2.7 + d * 0.6 + channel_bias).clamp(0.0, 3.0);
526        t as usize
527    }
528}