1//! The art as one text file the app reads (`android/app/src/main/resources/art.txt`, written by 2//! `bin/gen-art`, a build output): every motion as its frames, and every character in every mood (and every 3//! shape of the mouth) as the rectangles and groups it is, with the parts that repeat (the shadow, the weather) 4//! written once. The app has one interpreter for it (`art/ArtData.kt`) and no drawing or formula of its own, so a 5//! character added here is in the app at the next build with no Kotlin written. 6//! 7//! Format, one item to a line, numbers as Rust prints them (they read back exactly): 8//! 9//! ```text 10//! characters ID ID ... every character, in the order the chooser steps through them 11//! moods NAME ... the core's moods, and levels NAME ...: the four shapes of a mouth 12//! grid view=X,Y,W,H center=X,Y half=W gaze=RX,RY,STEP box=X,Y,SIDE the drawing area, the middle touch is measured from, how far the pupils go, and the square a body stands in 13//! vowels all=aeiouy wide=a round=ou open_until=0.7 the vowels, which opens the mouth how, and how long it stays open in a gap 14//! track NAME PERIOD STEPS(0|1) FRAMES then FRAMES lines: PERCENT TX TY ROTATE SX SY OPACITY 15//! def N nodes, then `end`: a part a scene uses 16//! scene CHARACTER MOOD SHAPE nodes, then `end`: SHAPE is `-`, `flap` or a level 17//! nodes: r X Y W H RRGGBB [ALPHA] | g TRACK OX OY ... e | eyes follow|fixed GX GY ... e 18//! slot ... e | u N 19//! ``` 20 21use std::collections::{BTreeMap, HashMap}; 22use std::fmt::Write; 23 24use whiskers_pet::Mood; 25 26use crate::character::Character; 27use crate::motion::Motion; 28use crate::node::{Color, Group, Look, Node, Role}; 29use crate::scene::{MOODS, Mouth}; 30 31fn f(v: f32) -> String { 32 format!("{v:?}") 33} 34 35fn rgb(c: Color) -> String { 36 format!("{:02x}{:02x}{:02x}", c.0, c.1, c.2) 37} 38 39fn node(n: &Node, out: &mut String, motions: &mut Vec<Motion>) { 40 match n { 41 Node::Rect(r) => { 42 let alpha = if r.alpha == 1.0 { String::new() } else { format!(" {}", f(r.alpha)) }; 43 let _ = writeln!(out, "r {} {} {} {} {}{alpha}", f(r.x), f(r.y), f(r.w), f(r.h), rgb(r.color)); 44 } 45 Node::Group(g) => group(g, out, motions), 46 Node::Slot(over) => { 47 out.push_str("slot\n"); 48 for child in over { 49 node(child, out, motions); 50 } 51 out.push_str("e\n"); 52 } 53 } 54} 55 56fn group(g: &Group, out: &mut String, motions: &mut Vec<Motion>) { 57 match g.role { 58 Role::Eyes(Look::Follow) => out.push_str("eyes follow\n"), 59 Role::Eyes(Look::Fixed(x, y)) => { 60 let _ = writeln!(out, "eyes fixed {} {}", f(x), f(y)); 61 } 62 Role::Plain => { 63 if !motions.contains(&g.motion) { 64 motions.push(g.motion); 65 } 66 let _ = writeln!(out, "g {} {} {}", g.motion.name(), f(g.origin.0), f(g.origin.1)); 67 } 68 } 69 for child in &g.children { 70 node(child, out, motions); 71 } 72 out.push_str("e\n"); 73} 74 75/// The shapes a mood is written in: the mouth's four levels and its beat, for speaking; one for the rest. 76fn variants(mood: Mood) -> Vec<(&'static str, Option<Mouth>)> { 77 if mood == Mood::Speaking { 78 let mut v = vec![("flap", None)]; 79 v.extend(Mouth::ALL.map(|m| (level_name(m), Some(m)))); 80 v 81 } else { 82 vec![("-", None)] 83 } 84} 85 86/// The name a level has in the file and in the app's `MouthShape`. 87pub fn level_name(m: Mouth) -> &'static str { 88 match m { 89 Mouth::Closed => "Closed", 90 Mouth::Small => "Small", 91 Mouth::Wide => "Wide", 92 Mouth::Round => "Round", 93 } 94} 95 96/// The whole file. 97pub fn generate() -> String { 98 let mut motions: Vec<Motion> = Vec::new(); 99 // Each scene's top-level parts as text; a part that two scenes share is written once as a `def`. 100 let mut scenes: Vec<(String, Vec<String>)> = Vec::new(); 101 for character in Character::all() { 102 for mood in MOODS { 103 for (shape, mouth) in variants(mood) { 104 let parts: Vec<String> = character 105 .scene(mood, mouth) 106 .iter() 107 .map(|n| { 108 let mut s = String::new(); 109 node(n, &mut s, &mut motions); 110 s 111 }) 112 .collect(); 113 scenes.push((format!("scene {} {mood:?} {shape}", character.id()), parts)); 114 } 115 } 116 } 117 let mut uses: HashMap<&str, usize> = HashMap::new(); 118 for (_, parts) in &scenes { 119 for p in parts { 120 *uses.entry(p.as_str()).or_default() += 1; 121 } 122 } 123 let mut ids: BTreeMap<&str, usize> = BTreeMap::new(); 124 let mut defs = String::new(); 125 for (_, parts) in &scenes { 126 for p in parts { 127 if uses[p.as_str()] > 1 && !ids.contains_key(p.as_str()) { 128 let id = ids.len(); 129 ids.insert(p.as_str(), id); 130 let _ = write!(defs, "def {id}\n{p}end\n"); 131 } 132 } 133 } 134 let mut out = String::new(); 135 out.push_str("# GENERATED by `cargo run -p whiskers-art --bin gen-art` from crates/whiskers-art. Do not edit.\n"); 136 let names: Vec<&str> = Character::all().iter().map(|c| c.id()).collect(); 137 let _ = writeln!(out, "characters {}", names.join(" ")); 138 let moods: Vec<String> = MOODS.iter().map(|m| format!("{m:?}")).collect(); 139 let _ = writeln!(out, "moods {}", moods.join(" ")); 140 let levels: Vec<&str> = Mouth::ALL.iter().map(|m| level_name(*m)).collect(); 141 let _ = writeln!(out, "levels {}", levels.join(" ")); 142 let (vx, vy, vw, vh) = crate::scene::VIEW; 143 let _ = writeln!( 144 out, 145 "grid view={},{},{},{} center={},{} half={} gaze={},{},{} box={},{},{}", 146 f(vx), f(vy), f(vw), f(vh), 147 f(crate::scene::CENTER.0), f(crate::scene::CENTER.1), 148 f(crate::scene::HALF_WIDTH), 149 f(crate::scene::GAZE_REACH.0), f(crate::scene::GAZE_REACH.1), f(crate::scene::GAZE_STEP), 150 f(crate::body::BOX.0), f(crate::body::BOX.1), f(crate::body::BOX.2), 151 ); 152 let _ = writeln!(out, "vowels all={} wide={} round={} open_until={}", pixel_mouth::VOWELS, pixel_mouth::WIDE_VOWELS, pixel_mouth::ROUND_VOWELS, f(pixel_mouth::OPEN_UNTIL)); 153 motions.sort_by_key(|m| m.name()); 154 for m in &motions { 155 let t = m.played(); 156 let _ = writeln!(out, "track {} {} {} {}", m.name(), f(t.period), u8::from(t.steps), t.frames.len()); 157 for (percent, xf) in &t.frames { 158 let _ = writeln!(out, "{} {} {} {} {} {} {}", f(*percent), f(xf.tx), f(xf.ty), f(xf.rotate), f(xf.sx), f(xf.sy), f(xf.opacity)); 159 } 160 } 161 out.push_str(&defs); 162 for (head, parts) in &scenes { 163 let _ = writeln!(out, "{head}"); 164 for p in parts { 165 match ids.get(p.as_str()) { 166 Some(id) => { 167 let _ = writeln!(out, "u {id}"); 168 } 169 None => out.push_str(p), 170 } 171 } 172 out.push_str("end\n"); 173 } 174 out 175} 176 177#[cfg(test)] 178mod tests { 179 use super::*; 180 181 #[test] 182 fn every_character_mood_and_shape_is_written_and_every_group_is_closed_and_has_its_track() { 183 let text = generate(); 184 let heads = text.lines().filter(|l| l.starts_with("scene ")).count(); 185 assert_eq!(heads, 13 * (10 + 5)); 186 let opens = text.lines().filter(|l| l.starts_with("g ") || l.starts_with("eyes ") || *l == "slot").count(); 187 let closes = text.lines().filter(|l| *l == "e").count(); 188 assert_eq!(opens, closes); 189 let tracks: Vec<&str> = text.lines().filter_map(|l| l.strip_prefix("track ")).filter_map(|l| l.split(' ').next()).collect(); 190 for line in text.lines().filter_map(|l| l.strip_prefix("g ")) { 191 assert!(tracks.contains(&line.split(' ').next().unwrap()), "{line}"); 192 } 193 assert!(!text.contains("NaN") && !text.contains("inf")); 194 } 195 196 #[test] 197 fn a_part_two_scenes_share_is_written_once() { 198 let text = generate(); 199 assert!(text.lines().filter(|l| l.starts_with("def ")).count() > 10); 200 // The 13 characters' idle scenes all start with the same shadow, as a use of one def. 201 let shadow = text.lines().find(|l| l.starts_with("r 3.0 15.0 9.0 1.0 000000")).map(|_| ()).is_some(); 202 assert!(shadow, "the shadow is written once, inside a def"); 203 assert!(text.len() < 400_000, "{} bytes", text.len()); 204 } 205}