use std::collections::HashMap;
use crate::color::parse_color;
use crate::models::{Animation, Composition, PaletteRef, Particle, Sprite, TtpObject, Variant};
use crate::palettes;
use crate::tokenizer::tokenize;
#[derive(Debug, Clone)]
pub struct TokenUsage {
pub token: String,
pub count: usize,
pub percentage: f64,
pub color: Option<String>,
pub color_name: Option<String>,
}
#[derive(Debug)]
pub struct SpriteExplanation {
pub name: String,
pub width: usize,
pub height: usize,
pub total_cells: usize,
pub palette_ref: String,
pub tokens: Vec<TokenUsage>,
pub transparent_count: usize,
pub transparency_ratio: f64,
pub consistent_rows: bool,
pub issues: Vec<String>,
}
#[derive(Debug)]
pub struct PaletteExplanation {
pub name: String,
pub color_count: usize,
pub colors: Vec<(String, String, Option<String>)>,
pub is_builtin: bool,
}
#[derive(Debug)]
pub struct AnimationExplanation {
pub name: String,
pub frames: Vec<String>,
pub frame_count: usize,
pub duration_ms: u32,
pub loops: bool,
}
#[derive(Debug)]
pub struct CompositionExplanation {
pub name: String,
pub base: Option<String>,
pub size: Option<[u32; 2]>,
pub cell_size: [u32; 2],
pub sprite_count: usize,
pub layer_count: usize,
}
#[derive(Debug)]
pub struct VariantExplanation {
pub name: String,
pub base: String,
pub override_count: usize,
pub overrides: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
pub struct ParticleExplanation {
pub name: String,
pub sprite: String,
pub rate: f64,
pub lifetime: [u32; 2],
pub has_gravity: bool,
pub has_fade: bool,
}
#[derive(Debug, Clone)]
pub struct TransformExplanation {
pub name: String,
pub is_parameterized: bool,
pub params: Vec<String>,
pub generates_animation: bool,
pub frame_count: Option<u32>,
pub transform_type: String,
}
#[derive(Debug)]
pub enum Explanation {
Sprite(SpriteExplanation),
Palette(PaletteExplanation),
Transform(TransformExplanation),
Animation(AnimationExplanation),
Composition(CompositionExplanation),
Variant(VariantExplanation),
Particle(ParticleExplanation),
}
pub fn explain_sprite(
sprite: &Sprite,
palette_colors: Option<&HashMap<String, String>>,
) -> SpriteExplanation {
let mut token_counts: HashMap<String, usize> = HashMap::new();
let mut total_cells = 0;
let mut first_row_width: Option<usize> = None;
let mut consistent_rows = true;
let mut issues = Vec::new();
for (row_idx, row) in sprite.grid.iter().enumerate() {
let (tokens, warnings) = tokenize(row);
let row_width = tokens.len();
match first_row_width {
None => first_row_width = Some(row_width),
Some(expected) if row_width != expected => {
consistent_rows = false;
issues.push(format!(
"Row {} has {} tokens (expected {})",
row_idx + 1,
row_width,
expected
));
}
_ => {}
}
for token in tokens {
*token_counts.entry(token).or_insert(0) += 1;
total_cells += 1;
}
for warning in warnings {
issues.push(warning.message);
}
}
let width = first_row_width.unwrap_or(0);
let height = sprite.grid.len();
let transparent_count = token_counts.get("{_}").copied().unwrap_or(0);
let transparency_ratio =
if total_cells > 0 { (transparent_count as f64 / total_cells as f64) * 100.0 } else { 0.0 };
let mut tokens: Vec<TokenUsage> = token_counts
.iter()
.map(|(token, &count)| {
let percentage =
if total_cells > 0 { (count as f64 / total_cells as f64) * 100.0 } else { 0.0 };
let color = palette_colors.and_then(|c| c.get(token).cloned());
let color_name = color.as_ref().and_then(|c| describe_color(c));
TokenUsage { token: token.clone(), count, percentage, color, color_name }
})
.collect();
tokens.sort_by(|a, b| b.count.cmp(&a.count));
let palette_ref = match &sprite.palette {
PaletteRef::Named(name) => name.clone(),
PaletteRef::Inline(_) => "inline".to_string(),
};
SpriteExplanation {
name: sprite.name.clone(),
width,
height,
total_cells,
palette_ref,
tokens,
transparent_count,
transparency_ratio,
consistent_rows,
issues,
}
}
pub fn explain_palette(name: &str, colors: &HashMap<String, String>) -> PaletteExplanation {
let mut color_list: Vec<(String, String, Option<String>)> = colors
.iter()
.map(|(token, color)| {
let name = describe_color(color);
(token.clone(), color.clone(), name)
})
.collect();
color_list.sort_by(|a, b| a.0.cmp(&b.0));
PaletteExplanation {
name: name.to_string(),
color_count: colors.len(),
colors: color_list,
is_builtin: false,
}
}
pub fn explain_animation(animation: &Animation) -> AnimationExplanation {
AnimationExplanation {
name: animation.name.clone(),
frames: animation.frames.clone(),
frame_count: animation.frames.len(),
duration_ms: animation.duration_ms(),
loops: animation.loops(),
}
}
pub fn explain_composition(composition: &Composition) -> CompositionExplanation {
CompositionExplanation {
name: composition.name.clone(),
base: composition.base.clone(),
size: composition.size,
cell_size: composition.cell_size(),
sprite_count: composition.sprites.len(),
layer_count: composition.layers.len(),
}
}
pub fn explain_variant(variant: &Variant) -> VariantExplanation {
let overrides: Vec<(String, String)> =
variant.palette.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
VariantExplanation {
name: variant.name.clone(),
base: variant.base.clone(),
override_count: variant.palette.len(),
overrides,
}
}
pub fn explain_particle(particle: &Particle) -> ParticleExplanation {
ParticleExplanation {
name: particle.name.clone(),
sprite: particle.sprite.clone(),
rate: particle.emitter.rate,
lifetime: particle.emitter.lifetime,
has_gravity: particle.emitter.gravity.is_some(),
has_fade: particle.emitter.fade.unwrap_or(false),
}
}
pub fn explain_transform(transform: &crate::models::TransformDef) -> TransformExplanation {
let transform_type = if transform.generates_animation() {
"keyframe animation".to_string()
} else if transform.is_cycling() {
"cycling".to_string()
} else if transform.is_simple() {
"sequence".to_string()
} else if transform.compose.is_some() {
"parallel composition".to_string()
} else {
"custom".to_string()
};
TransformExplanation {
name: transform.name.clone(),
is_parameterized: transform.is_parameterized(),
params: transform.params.clone().unwrap_or_default(),
generates_animation: transform.generates_animation(),
frame_count: transform.frames,
transform_type,
}
}
pub fn explain_object(
obj: &TtpObject,
palette_colors: Option<&HashMap<String, String>>,
) -> Explanation {
match obj {
TtpObject::Sprite(sprite) => Explanation::Sprite(explain_sprite(sprite, palette_colors)),
TtpObject::Palette(palette) => {
Explanation::Palette(explain_palette(&palette.name, &palette.colors))
}
TtpObject::Animation(anim) => Explanation::Animation(explain_animation(anim)),
TtpObject::Composition(comp) => Explanation::Composition(explain_composition(comp)),
TtpObject::Variant(variant) => Explanation::Variant(explain_variant(variant)),
TtpObject::Particle(particle) => Explanation::Particle(explain_particle(particle)),
TtpObject::Transform(transform) => Explanation::Transform(explain_transform(transform)),
}
}
pub fn describe_color(hex: &str) -> Option<String> {
let rgba = parse_color(hex).ok()?;
if rgba[3] == 0 {
return Some("transparent".to_string());
}
let r = rgba[0];
let g = rgba[1];
let b = rgba[2];
if r == g && g == b {
return Some(
match r {
0 => "black",
255 => "white",
0..=63 => "dark gray",
64..=127 => "gray",
128..=191 => "light gray",
192..=254 => "very light gray",
}
.to_string(),
);
}
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let hue = if max == min {
0.0 } else {
let delta = (max - min) as f64;
let h = if max == r {
((g as f64 - b as f64) / delta) % 6.0
} else if max == g {
((b as f64 - r as f64) / delta) + 2.0
} else {
((r as f64 - g as f64) / delta) + 4.0
};
h * 60.0
};
let hue = if hue < 0.0 { hue + 360.0 } else { hue };
let lightness = (max as f64 + min as f64) / 510.0; let saturation = if max == min {
0.0
} else if lightness <= 0.5 {
(max - min) as f64 / (max + min) as f64
} else {
(max - min) as f64 / (510.0 - max as f64 - min as f64)
};
let base_color = match hue as u32 {
0..=14 | 346..=360 => "red",
15..=44 => "orange",
45..=74 => "yellow",
75..=154 => "green",
155..=184 => "cyan",
185..=254 => "blue",
255..=284 => "purple",
285..=345 => "magenta",
_ => "red",
};
let modifier = if saturation < 0.2 {
"grayish "
} else if lightness < 0.2 {
"dark "
} else if lightness > 0.8 {
"light "
} else if saturation > 0.8 && lightness > 0.4 && lightness < 0.6 {
"bright "
} else {
""
};
Some(format!("{}{}", modifier, base_color))
}
pub fn format_sprite_explanation(exp: &SpriteExplanation) -> String {
let mut output = String::new();
output.push_str(&format!("Sprite: {}\n", exp.name));
output.push_str(&format!(
"Size: {}x{} pixels ({} cells)\n",
exp.width, exp.height, exp.total_cells
));
output.push_str(&format!("Palette: {}\n", exp.palette_ref));
output.push('\n');
output.push_str("TOKENS USED\n");
output.push_str("-----------\n");
for usage in &exp.tokens {
let color_desc = match (&usage.color, &usage.color_name) {
(Some(hex), Some(name)) => format!(" {} ({})", hex, name),
(Some(hex), None) => format!(" {}", hex),
_ => String::new(),
};
output.push_str(&format!(
" {:12} {:>4}x ({:>5.1}%){}",
usage.token, usage.count, usage.percentage, color_desc
));
output.push('\n');
}
output.push('\n');
output.push_str("STRUCTURE\n");
output.push_str("---------\n");
output.push_str(&format!(
" Transparency: {:.1}% ({} cells)\n",
exp.transparency_ratio, exp.transparent_count
));
output.push_str(&format!(
" Row consistency: {}\n",
if exp.consistent_rows { "yes" } else { "no" }
));
output.push_str(&format!(" Unique tokens: {}\n", exp.tokens.len()));
if !exp.issues.is_empty() {
output.push('\n');
output.push_str("ISSUES\n");
output.push_str("------\n");
for issue in &exp.issues {
output.push_str(&format!(" - {}\n", issue));
}
}
output
}
pub fn format_palette_explanation(exp: &PaletteExplanation) -> String {
let mut output = String::new();
output.push_str(&format!("Palette: {}\n", exp.name));
output.push_str(&format!("Colors: {}\n", exp.color_count));
if exp.is_builtin {
output.push_str("Type: built-in\n");
}
output.push('\n');
output.push_str("COLOR MAPPINGS\n");
output.push_str("--------------\n");
for (token, hex, name) in &exp.colors {
let desc = name.as_ref().map(|n| format!(" ({})", n)).unwrap_or_default();
output.push_str(&format!(" {:12} => {}{}\n", token, hex, desc));
}
output
}
pub fn format_animation_explanation(exp: &AnimationExplanation) -> String {
let mut output = String::new();
output.push_str(&format!("Animation: {}\n", exp.name));
output.push_str(&format!("Frames: {}\n", exp.frame_count));
output.push_str(&format!("Duration: {}ms per frame\n", exp.duration_ms));
output.push_str(&format!("Loops: {}\n", if exp.loops { "yes" } else { "no" }));
output.push('\n');
output.push_str("FRAME SEQUENCE\n");
output.push_str("--------------\n");
for (i, frame) in exp.frames.iter().enumerate() {
output.push_str(&format!(" {}: {}\n", i + 1, frame));
}
output
}
pub fn format_composition_explanation(exp: &CompositionExplanation) -> String {
let mut output = String::new();
output.push_str(&format!("Composition: {}\n", exp.name));
if let Some(base) = &exp.base {
output.push_str(&format!("Base sprite: {}\n", base));
}
if let Some(size) = exp.size {
output.push_str(&format!("Canvas size: {}x{}\n", size[0], size[1]));
}
output.push_str(&format!("Cell size: {}x{}\n", exp.cell_size[0], exp.cell_size[1]));
output.push_str(&format!("Sprite mappings: {}\n", exp.sprite_count));
output.push_str(&format!("Layers: {}\n", exp.layer_count));
output
}
pub fn format_variant_explanation(exp: &VariantExplanation) -> String {
let mut output = String::new();
output.push_str(&format!("Variant: {}\n", exp.name));
output.push_str(&format!("Base sprite: {}\n", exp.base));
output.push_str(&format!("Color overrides: {}\n", exp.override_count));
output.push('\n');
if !exp.overrides.is_empty() {
output.push_str("PALETTE OVERRIDES\n");
output.push_str("-----------------\n");
for (token, color) in &exp.overrides {
let desc = describe_color(color).map(|n| format!(" ({})", n)).unwrap_or_default();
output.push_str(&format!(" {:12} => {}{}\n", token, color, desc));
}
}
output
}
pub fn format_explanation(exp: &Explanation) -> String {
match exp {
Explanation::Sprite(s) => format_sprite_explanation(s),
Explanation::Palette(p) => format_palette_explanation(p),
Explanation::Animation(a) => format_animation_explanation(a),
Explanation::Composition(c) => format_composition_explanation(c),
Explanation::Variant(v) => format_variant_explanation(v),
Explanation::Particle(p) => format_particle_explanation(p),
Explanation::Transform(t) => format_transform_explanation(t),
}
}
fn format_transform_explanation(t: &TransformExplanation) -> String {
let mut lines = vec![format!("Transform: {} ({})", t.name, t.transform_type)];
if t.is_parameterized {
lines.push(format!(" Parameters: {}", t.params.join(", ")));
}
if t.generates_animation {
if let Some(frames) = t.frame_count {
lines.push(format!(" Generates {} animation frames", frames));
}
}
lines.join("\n")
}
fn format_particle_explanation(p: &ParticleExplanation) -> String {
let mut lines = vec![format!("Particle System: {}", p.name)];
lines.push(format!(" Sprite: {}", p.sprite));
lines.push(format!(" Rate: {} particles/frame", p.rate));
lines.push(format!(" Lifetime: {}-{} frames", p.lifetime[0], p.lifetime[1]));
if p.has_gravity {
lines.push(" Gravity: enabled".to_string());
}
if p.has_fade {
lines.push(" Fade: enabled".to_string());
}
lines.join("\n")
}
pub fn resolve_palette_colors(
palette_ref: &PaletteRef,
known_palettes: &HashMap<String, HashMap<String, String>>,
) -> Option<HashMap<String, String>> {
match palette_ref {
PaletteRef::Named(name) => {
if name.starts_with('@') {
let builtin_name = name.strip_prefix('@').unwrap_or(name);
return palettes::get_builtin(builtin_name).map(|p| p.colors.clone());
}
known_palettes.get(name).cloned()
}
PaletteRef::Inline(colors) => Some(colors.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_describe_color_transparent() {
assert_eq!(describe_color("#00000000"), Some("transparent".to_string()));
}
#[test]
fn test_describe_color_black_white() {
assert_eq!(describe_color("#000000"), Some("black".to_string()));
assert_eq!(describe_color("#FFFFFF"), Some("white".to_string()));
}
#[test]
fn test_describe_color_primary() {
assert!(describe_color("#FF0000").unwrap().contains("red"));
assert!(describe_color("#00FF00").unwrap().contains("green"));
assert!(describe_color("#0000FF").unwrap().contains("blue"));
}
#[test]
fn test_explain_sprite_basic() {
let sprite = Sprite {
name: "test".to_string(),
size: Some([2, 2]),
palette: PaletteRef::Inline(HashMap::from([
("{_}".to_string(), "#00000000".to_string()),
("{x}".to_string(), "#FF0000".to_string()),
])),
grid: vec!["{_}{x}".to_string(), "{x}{_}".to_string()],
metadata: None,
..Default::default()
};
let colors = HashMap::from([
("{_}".to_string(), "#00000000".to_string()),
("{x}".to_string(), "#FF0000".to_string()),
]);
let exp = explain_sprite(&sprite, Some(&colors));
assert_eq!(exp.name, "test");
assert_eq!(exp.width, 2);
assert_eq!(exp.height, 2);
assert_eq!(exp.total_cells, 4);
assert_eq!(exp.transparent_count, 2);
assert!((exp.transparency_ratio - 50.0).abs() < 0.01);
assert!(exp.consistent_rows);
assert!(exp.issues.is_empty());
}
#[test]
fn test_explain_sprite_inconsistent_rows() {
let sprite = Sprite {
name: "uneven".to_string(),
size: None,
palette: PaletteRef::Named("test".to_string()),
grid: vec!["{a}{b}{c}".to_string(), "{a}{b}".to_string()],
metadata: None,
..Default::default()
};
let exp = explain_sprite(&sprite, None);
assert!(!exp.consistent_rows);
assert!(!exp.issues.is_empty());
}
#[test]
fn test_explain_palette_basic() {
let colors = HashMap::from([
("{_}".to_string(), "#00000000".to_string()),
("{x}".to_string(), "#FF0000".to_string()),
]);
let exp = explain_palette("test", &colors);
assert_eq!(exp.name, "test");
assert_eq!(exp.color_count, 2);
assert!(!exp.is_builtin);
}
#[test]
fn test_format_sprite_explanation() {
let exp = SpriteExplanation {
name: "test".to_string(),
width: 8,
height: 8,
total_cells: 64,
palette_ref: "hero".to_string(),
tokens: vec![
TokenUsage {
token: "{_}".to_string(),
count: 32,
percentage: 50.0,
color: Some("#00000000".to_string()),
color_name: Some("transparent".to_string()),
},
TokenUsage {
token: "{x}".to_string(),
count: 32,
percentage: 50.0,
color: Some("#FF0000".to_string()),
color_name: Some("red".to_string()),
},
],
transparent_count: 32,
transparency_ratio: 50.0,
consistent_rows: true,
issues: vec![],
};
let output = format_sprite_explanation(&exp);
assert!(output.contains("Sprite: test"));
assert!(output.contains("Size: 8x8"));
assert!(output.contains("Palette: hero"));
assert!(output.contains("{_}"));
assert!(output.contains("{x}"));
assert!(output.contains("transparent"));
}
}