1use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use serde::Deserialize;
15
16pub const SIDECAR_EXT: &str = "atlas.json";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct AtlasFrame {
22 pub x: u32,
23 pub y: u32,
24 pub w: u32,
25 pub h: u32,
26}
27
28impl AtlasFrame {
29 pub fn uv(&self, sheet: (u32, u32)) -> [f32; 4] {
32 let (sw, sh) = (sheet.0 as f32, sheet.1 as f32);
33 [
34 self.x as f32 / sw,
35 self.y as f32 / sh,
36 (self.x + self.w) as f32 / sw,
37 (self.y + self.h) as f32 / sh,
38 ]
39 }
40
41 fn fits(&self, sheet: (u32, u32)) -> bool {
42 self.w > 0
43 && self.h > 0
44 && self.x.saturating_add(self.w) <= sheet.0
45 && self.y.saturating_add(self.h) <= sheet.1
46 }
47}
48
49#[derive(Debug, Clone, PartialEq)]
51pub struct AtlasAnimation {
52 pub frames: Vec<String>,
53 pub fps: f32,
54 pub looping: bool,
55}
56
57#[derive(Debug, Clone, PartialEq)]
59pub struct Atlas {
60 pub texture: String,
61 pub size: (u32, u32),
62 pub frames: BTreeMap<String, AtlasFrame>,
63 pub animations: BTreeMap<String, AtlasAnimation>,
64}
65
66impl Atlas {
67 pub fn frame(&self, name: &str) -> Result<AtlasFrame> {
69 self.frames.get(name).copied().ok_or_else(|| {
70 anyhow::anyhow!(
71 "atlas {:?} has no frame {name:?} (available: {})",
72 self.texture,
73 if self.frames.is_empty() {
74 "none".to_string()
75 } else {
76 self.names().join(", ")
77 }
78 )
79 })
80 }
81
82 pub fn names(&self) -> Vec<&str> {
83 self.frames.keys().map(String::as_str).collect()
84 }
85
86 pub fn animation(&self, name: &str) -> Result<&AtlasAnimation> {
88 self.animations.get(name).ok_or_else(|| {
89 anyhow::anyhow!(
90 "atlas {:?} has no animation {name:?} (available: {})",
91 self.texture,
92 if self.animations.is_empty() {
93 "none".to_string()
94 } else {
95 self.animation_names().join(", ")
96 }
97 )
98 })
99 }
100
101 pub fn animation_names(&self) -> Vec<&str> {
102 self.animations.keys().map(String::as_str).collect()
103 }
104}
105
106#[derive(Debug, Deserialize)]
107struct RawAtlas {
108 texture: String,
109 size: [u32; 2],
110 frames: BTreeMap<String, RawFrame>,
111 #[serde(default)]
112 animations: BTreeMap<String, RawAnimation>,
113}
114
115#[derive(Debug, Deserialize)]
116struct RawAnimation {
117 frames: Vec<String>,
118 #[serde(default = "default_fps")]
119 fps: f32,
120 #[serde(default = "default_looping", rename = "loop", alias = "looping")]
121 looping: bool,
122}
123
124fn default_fps() -> f32 {
125 10.0
126}
127
128fn default_looping() -> bool {
129 true
130}
131
132#[derive(Debug, Deserialize)]
133struct RawFrame {
134 x: u32,
135 y: u32,
136 w: u32,
137 h: u32,
138}
139
140impl From<RawFrame> for AtlasFrame {
141 fn from(raw: RawFrame) -> Self {
142 AtlasFrame {
143 x: raw.x,
144 y: raw.y,
145 w: raw.w,
146 h: raw.h,
147 }
148 }
149}
150
151pub fn parse_atlas(text: &str) -> Result<Atlas> {
154 let raw: RawAtlas =
155 serde_json::from_str(text).context("atlas is not valid JSON (texture/size/frames)")?;
156 if raw.texture.trim().is_empty() {
157 anyhow::bail!("atlas has an empty `texture`");
158 }
159 if raw.size[0] == 0 || raw.size[1] == 0 {
160 anyhow::bail!("atlas size {:?} must be positive", raw.size);
161 }
162 if raw.frames.is_empty() {
163 anyhow::bail!("atlas {:?} declares no frames", raw.texture);
164 }
165 let sheet = (raw.size[0], raw.size[1]);
166 let mut frames = BTreeMap::new();
167 for (name, frame) in raw.frames {
168 let frame = AtlasFrame::from(frame);
169 if !frame.fits(sheet) {
170 anyhow::bail!(
171 "atlas frame {name:?} ({},{} {}x{}) does not fit the {sheet:?} sheet",
172 frame.x,
173 frame.y,
174 frame.w,
175 frame.h
176 );
177 }
178 frames.insert(name, frame);
179 }
180 let mut animations = BTreeMap::new();
181 let available_frames = if frames.is_empty() {
182 "none".to_string()
183 } else {
184 frames
185 .keys()
186 .map(String::as_str)
187 .collect::<Vec<_>>()
188 .join(", ")
189 };
190 for (name, anim) in raw.animations {
191 if anim.frames.is_empty() {
192 anyhow::bail!("atlas animation {name:?} declares no frames");
193 }
194 if anim.fps <= 0.0 || !anim.fps.is_finite() {
195 anyhow::bail!(
196 "atlas animation {name:?} fps ({}) must be positive",
197 anim.fps
198 );
199 }
200 for frame_name in &anim.frames {
201 if !frames.contains_key(frame_name) {
202 anyhow::bail!(
203 "atlas animation {name:?} references nonexistent frame {frame_name:?} (available: {available_frames})"
204 );
205 }
206 }
207 animations.insert(
208 name,
209 AtlasAnimation {
210 frames: anim.frames,
211 fps: anim.fps,
212 looping: anim.looping,
213 },
214 );
215 }
216 Ok(Atlas {
217 texture: raw.texture,
218 size: sheet,
219 frames,
220 animations,
221 })
222}
223
224pub fn load_atlas(path: &Path) -> Result<Atlas> {
225 let text = std::fs::read_to_string(path)
226 .with_context(|| format!("cannot read atlas {}", path.display()))?;
227 parse_atlas(&text).with_context(|| format!("cannot parse atlas {}", path.display()))
228}
229
230pub fn sidecar_for(sheet: &Path) -> PathBuf {
232 let stem = sheet
233 .file_stem()
234 .and_then(|s| s.to_str())
235 .filter(|s| !s.is_empty())
236 .unwrap_or("sheet");
237 sheet
238 .parent()
239 .unwrap_or_else(|| Path::new(""))
240 .join(format!("{stem}.{SIDECAR_EXT}"))
241}
242
243pub fn sheet_for_sidecar(sidecar: &Path) -> PathBuf {
245 let name = sidecar
246 .file_name()
247 .and_then(|s| s.to_str())
248 .unwrap_or_default();
249 let stem = name
250 .strip_suffix(&format!(".{SIDECAR_EXT}"))
251 .unwrap_or(name);
252 sidecar
253 .parent()
254 .unwrap_or_else(|| Path::new(""))
255 .join(format!("{stem}.png"))
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 const SHEET: &str = r#"{
263 "texture": "sheet.png",
264 "size": [64, 32],
265 "frames": {
266 "player": { "x": 0, "y": 0, "w": 32, "h": 32 },
267 "enemy": { "x": 32, "y": 0, "w": 32, "h": 32 },
268 "coin": { "x": 0, "y": 16, "w": 16, "h": 16 }
269 }
270 }"#;
271
272 #[test]
273 fn parses_named_frames() {
274 let atlas = parse_atlas(SHEET).unwrap();
275 assert_eq!(atlas.texture, "sheet.png");
276 assert_eq!(atlas.size, (64, 32));
277 assert_eq!(atlas.frames.len(), 3);
278 assert_eq!(
279 atlas.frame("player").unwrap(),
280 AtlasFrame {
281 x: 0,
282 y: 0,
283 w: 32,
284 h: 32
285 }
286 );
287 assert_eq!(
288 atlas.frame("enemy").unwrap(),
289 AtlasFrame {
290 x: 32,
291 y: 0,
292 w: 32,
293 h: 32
294 }
295 );
296 assert_eq!(atlas.names(), vec!["coin", "enemy", "player"]);
297 }
298
299 #[test]
300 fn missing_frame_names_the_alternatives() {
301 let atlas = parse_atlas(SHEET).unwrap();
302 let err = atlas.frame("ghost").unwrap_err().to_string();
303 assert!(err.contains("no frame \"ghost\""), "got: {err}");
304 assert!(err.contains("player"), "got: {err}");
305 }
306
307 #[test]
308 fn uv_math_maps_pixels_to_normalized_rects() {
309 let atlas = parse_atlas(SHEET).unwrap();
310 let player = atlas.frame("player").unwrap().uv(atlas.size);
311 assert_eq!(player, [0.0, 0.0, 0.5, 1.0]);
312 let enemy = atlas.frame("enemy").unwrap().uv(atlas.size);
313 assert_eq!(enemy, [0.5, 0.0, 1.0, 1.0]);
314 let coin = atlas.frame("coin").unwrap().uv(atlas.size);
315 assert_eq!(coin, [0.0, 0.5, 0.25, 1.0]);
316 }
317
318 #[test]
319 fn bad_atlases_fail_loudly() {
320 assert!(parse_atlas("not json").is_err());
321 assert!(parse_atlas(r#"{"texture":"","size":[1,1],"frames":{}}"#).is_err());
322 assert!(parse_atlas(r#"{"texture":"a.png","size":[0,8],"frames":{}}"#).is_err());
323 assert!(parse_atlas(r#"{"texture":"a.png","size":[8,8],"frames":{}}"#).is_err());
324 let out_of_bounds = r#"{"texture":"a.png","size":[8,8],
325 "frames":{"big":{"x":4,"y":4,"w":8,"h":8}}}"#;
326 let err = parse_atlas(out_of_bounds).unwrap_err().to_string();
327 assert!(err.contains("does not fit"), "got: {err}");
328 }
329
330 #[test]
331 fn sidecar_and_sheet_paths_round_trip() {
332 let sheet = Path::new("/game/assets/sheet.png");
333 let sidecar = sidecar_for(sheet);
334 assert_eq!(sidecar, PathBuf::from("/game/assets/sheet.atlas.json"));
335 assert_eq!(sheet_for_sidecar(&sidecar), sheet);
336 }
337
338 #[test]
339 fn parses_animations_and_validates_frames() {
340 let json = r#"{
341 "texture": "sheet.png",
342 "size": [64, 32],
343 "frames": {
344 "f1": { "x": 0, "y": 0, "w": 32, "h": 32 },
345 "f2": { "x": 32, "y": 0, "w": 32, "h": 32 }
346 },
347 "animations": {
348 "walk": {
349 "frames": ["f1", "f2"],
350 "fps": 12.0,
351 "loop": true
352 },
353 "jump": {
354 "frames": ["f1"],
355 "fps": 8.0,
356 "loop": false
357 }
358 }
359 }"#;
360 let atlas = parse_atlas(json).unwrap();
361 assert_eq!(atlas.animation_names(), vec!["jump", "walk"]);
362 let walk = atlas.animation("walk").unwrap();
363 assert_eq!(walk.frames, vec!["f1", "f2"]);
364 assert_eq!(walk.fps, 12.0);
365 assert!(walk.looping);
366
367 let jump = atlas.animation("jump").unwrap();
368 assert_eq!(jump.frames, vec!["f1"]);
369 assert_eq!(jump.fps, 8.0);
370 assert!(!jump.looping);
371
372 let err = atlas.animation("idle").unwrap_err().to_string();
373 assert!(err.contains("no animation \"idle\""), "got: {err}");
374 assert!(err.contains("walk"), "got: {err}");
375 }
376
377 #[test]
378 fn invalid_animations_fail_loudly() {
379 let missing_frame = r#"{
380 "texture": "sheet.png",
381 "size": [64, 32],
382 "frames": { "f1": { "x": 0, "y": 0, "w": 32, "h": 32 } },
383 "animations": { "walk": { "frames": ["ghost"], "fps": 10.0 } }
384 }"#;
385 let err = parse_atlas(missing_frame).unwrap_err().to_string();
386 assert!(err.contains("nonexistent frame \"ghost\""), "got: {err}");
387 assert!(err.contains("f1"), "got: {err}");
388
389 let empty_frames = r#"{
390 "texture": "sheet.png",
391 "size": [64, 32],
392 "frames": { "f1": { "x": 0, "y": 0, "w": 32, "h": 32 } },
393 "animations": { "walk": { "frames": [], "fps": 10.0 } }
394 }"#;
395 let err = parse_atlas(empty_frames).unwrap_err().to_string();
396 assert!(err.contains("declares no frames"), "got: {err}");
397
398 let zero_fps = r#"{
399 "texture": "sheet.png",
400 "size": [64, 32],
401 "frames": { "f1": { "x": 0, "y": 0, "w": 32, "h": 32 } },
402 "animations": { "walk": { "frames": ["f1"], "fps": 0.0 } }
403 }"#;
404 let err = parse_atlas(zero_fps).unwrap_err().to_string();
405 assert!(err.contains("must be positive"), "got: {err}");
406 }
407}