The art as one text file the app reads (android/app/src/main/resources/art.txt, written by
bin/gen-art, a build output): every motion as its frames, and every character in every mood (and every
shape of the mouth) as the rectangles and groups it is, with the parts that repeat (the shadow, the weather)
written once. The app has one interpreter for it (art/ArtData.kt) and no drawing or formula of its own, so a
character added here is in the app at the next build with no Kotlin written.
Format, one item to a line, numbers as Rust prints them (they read back exactly):
characters ID ID ... every character, in the order the chooser steps through them
moods NAME ... the core's moods, and levels NAME ...: the four shapes of a mouth
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
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
track NAME PERIOD STEPS(0|1) FRAMES then FRAMES lines: PERCENT TX TY ROTATE SX SY OPACITY
def N nodes, then `end`: a part a scene uses
scene CHARACTER MOOD SHAPE nodes, then `end`: SHAPE is `-`, `flap` or a level
nodes: r X Y W H RRGGBB [ALPHA] | g TRACK OX OY ... e | eyes follow|fixed GX GY ... e
slot ... e | u N
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}
The shapes a mood is written in: the mouth's four levels and its beat, for speaking; one for the rest.
The name a level has in the file and in the app's MouthShape.
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}
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}