Skip to main content

pixelsrc/
models.rs

1//! Data models for Pixelsrc objects (palettes, sprites, etc.)
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// A value that can be either a literal value or a CSS variable reference.
7///
8/// Used for composition layer properties like `opacity` and `blend` that can
9/// use `var()` syntax to reference CSS custom properties.
10///
11/// # Examples
12///
13/// ```
14/// use pixelsrc::models::VarOr;
15///
16/// // Can be deserialized from either a literal or a var() string
17/// let literal: VarOr<f64> = serde_json::from_str("0.5").unwrap();
18/// let var_ref: VarOr<f64> = serde_json::from_str("\"var(--opacity)\"").unwrap();
19///
20/// assert!(matches!(literal, VarOr::Value(0.5)));
21/// assert!(matches!(var_ref, VarOr::Var(_)));
22/// ```
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(untagged)]
25pub enum VarOr<T> {
26    /// A literal value
27    Value(T),
28    /// A CSS variable reference (e.g., "var(--name)" or "var(--name, fallback)")
29    Var(String),
30}
31
32impl<T: Default> Default for VarOr<T> {
33    fn default() -> Self {
34        VarOr::Value(T::default())
35    }
36}
37
38impl<T> VarOr<T> {
39    /// Returns true if this is a var() reference
40    pub fn is_var(&self) -> bool {
41        matches!(self, VarOr::Var(_))
42    }
43
44    /// Returns true if this is a literal value
45    pub fn is_value(&self) -> bool {
46        matches!(self, VarOr::Value(_))
47    }
48
49    /// Returns the literal value if present
50    pub fn as_value(&self) -> Option<&T> {
51        match self {
52            VarOr::Value(v) => Some(v),
53            VarOr::Var(_) => None,
54        }
55    }
56
57    /// Returns the var() string if present
58    pub fn as_var(&self) -> Option<&str> {
59        match self {
60            VarOr::Value(_) => None,
61            VarOr::Var(s) => Some(s),
62        }
63    }
64}
65
66impl<T: Copy> VarOr<T> {
67    /// Get the value, returning None if it's a var() reference
68    pub fn value(&self) -> Option<T> {
69        match self {
70            VarOr::Value(v) => Some(*v),
71            VarOr::Var(_) => None,
72        }
73    }
74}
75
76impl From<f64> for VarOr<f64> {
77    fn from(v: f64) -> Self {
78        VarOr::Value(v)
79    }
80}
81
82impl From<String> for VarOr<f64> {
83    fn from(s: String) -> Self {
84        VarOr::Var(s)
85    }
86}
87
88/// A duration value that can be either a raw millisecond number or a CSS time string.
89///
90/// # Examples
91///
92/// ```
93/// use pixelsrc::models::Duration;
94///
95/// // Can be deserialized from either format
96/// let ms: Duration = serde_json::from_str("100").unwrap();
97/// let css: Duration = serde_json::from_str("\"500ms\"").unwrap();
98///
99/// assert_eq!(ms.as_milliseconds(), Some(100));
100/// assert_eq!(css.as_milliseconds(), Some(500));
101/// ```
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103#[serde(untagged)]
104pub enum Duration {
105    /// Raw milliseconds (backwards compatible)
106    Milliseconds(u32),
107    /// CSS time string (e.g., "500ms", "1s", "0.5s")
108    CssString(String),
109}
110
111impl Duration {
112    /// Parse the duration and return milliseconds.
113    ///
114    /// Returns `None` if the CSS string cannot be parsed.
115    pub fn as_milliseconds(&self) -> Option<u32> {
116        match self {
117            Duration::Milliseconds(ms) => Some(*ms),
118            Duration::CssString(s) => parse_css_duration(s),
119        }
120    }
121}
122
123impl Default for Duration {
124    fn default() -> Self {
125        Duration::Milliseconds(100)
126    }
127}
128
129impl std::fmt::Display for Duration {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        match self {
132            Duration::Milliseconds(ms) => write!(f, "{}", ms),
133            Duration::CssString(s) => write!(f, "\"{}\"", s),
134        }
135    }
136}
137
138impl From<u32> for Duration {
139    fn from(ms: u32) -> Self {
140        Duration::Milliseconds(ms)
141    }
142}
143
144impl From<&str> for Duration {
145    fn from(s: &str) -> Self {
146        Duration::CssString(s.to_string())
147    }
148}
149
150/// Parse a CSS duration string into milliseconds.
151///
152/// Supports:
153/// - `<number>ms` - milliseconds (e.g., "500ms")
154/// - `<number>s` - seconds (e.g., "1.5s")
155fn parse_css_duration(s: &str) -> Option<u32> {
156    let s = s.trim().to_lowercase();
157
158    if let Some(ms_str) = s.strip_suffix("ms") {
159        ms_str.trim().parse::<f64>().ok().map(|v| v as u32)
160    } else if let Some(s_str) = s.strip_suffix('s') {
161        s_str.trim().parse::<f64>().ok().map(|v| (v * 1000.0) as u32)
162    } else {
163        // Try parsing as raw number (assume milliseconds)
164        s.parse::<f64>().ok().map(|v| v as u32)
165    }
166}
167
168/// A CSS-style keyframe defining properties at a specific point in an animation.
169///
170/// Used with percentage keys (e.g., "0%", "50%", "100%") or "from"/"to" aliases.
171///
172/// # Examples
173///
174/// ```
175/// use pixelsrc::models::CssKeyframe;
176///
177/// // Keyframe with sprite and opacity
178/// let kf: CssKeyframe = serde_json::from_str(r#"{
179///     "sprite": "walk_1",
180///     "opacity": 1.0
181/// }"#).unwrap();
182/// assert_eq!(kf.sprite, Some("walk_1".to_string()));
183/// ```
184#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
185pub struct CssKeyframe {
186    /// Sprite to display at this keyframe
187    #[serde(skip_serializing_if = "Option::is_none", default)]
188    pub sprite: Option<String>,
189    /// CSS transform string (e.g., "rotate(45deg) scale(2)")
190    #[serde(skip_serializing_if = "Option::is_none", default)]
191    pub transform: Option<String>,
192    /// Opacity at this keyframe (0.0 to 1.0)
193    #[serde(skip_serializing_if = "Option::is_none", default)]
194    pub opacity: Option<f64>,
195    /// Position offset at this keyframe `[x, y]`
196    #[serde(skip_serializing_if = "Option::is_none", default)]
197    pub offset: Option<[i32; 2]>,
198}
199
200/// A named palette defining color tokens.
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
202pub struct Palette {
203    pub name: String,
204    pub colors: HashMap<String, String>,
205}
206
207/// Reference to a palette - either a named reference or inline definition.
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
209#[serde(untagged)]
210pub enum PaletteRef {
211    Named(String),
212    Inline(HashMap<String, String>),
213}
214
215impl Default for PaletteRef {
216    fn default() -> Self {
217        PaletteRef::Named(String::new())
218    }
219}
220
221/// Transform specification - can be string or object in JSON.
222///
223/// Supports both simple string syntax (`"mirror-h"`, `"rotate:90"`) and
224/// object syntax for complex parameters (`{"op": "tile", "w": 3, "h": 2}`).
225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
226#[serde(untagged)]
227pub enum TransformSpec {
228    /// String syntax: "mirror-h", "rotate:90", "tile:3x2"
229    String(String),
230    /// Object syntax: {"op": "tile", "w": 3, "h": 2}
231    Object {
232        op: String,
233        #[serde(flatten)]
234        params: HashMap<String, serde_json::Value>,
235    },
236}
237
238/// Nine-slice region definition for scalable sprites.
239///
240/// Nine-slice (or 9-patch) sprites have fixed corners and stretchable edges/center,
241/// allowing them to be scaled without distorting the corners. Common for UI elements
242/// like buttons, panels, and dialog boxes.
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
244pub struct NineSlice {
245    /// Left border width in pixels
246    pub left: u32,
247    /// Right border width in pixels
248    pub right: u32,
249    /// Top border height in pixels
250    pub top: u32,
251    /// Bottom border height in pixels
252    pub bottom: u32,
253}
254
255/// A sprite definition.
256///
257/// A sprite can either have a `grid` directly, or reference another sprite via `source`
258/// with optional transforms applied. The `grid` and `source` fields are mutually exclusive.
259#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
260pub struct Sprite {
261    pub name: String,
262    #[serde(skip_serializing_if = "Option::is_none", default)]
263    pub size: Option<[u32; 2]>,
264    pub palette: PaletteRef,
265    /// The grid data (mutually exclusive with `source`)
266    #[serde(default)]
267    pub grid: Vec<String>,
268    /// Reference to another sprite by name (mutually exclusive with `grid`)
269    #[serde(skip_serializing_if = "Option::is_none", default)]
270    pub source: Option<String>,
271    /// Transforms to apply when resolving this sprite
272    #[serde(skip_serializing_if = "Option::is_none", default)]
273    pub transform: Option<Vec<TransformSpec>>,
274    /// Sprite metadata for game engine integration (origin, collision boxes)
275    #[serde(skip_serializing_if = "Option::is_none", default)]
276    pub metadata: Option<SpriteMetadata>,
277    /// Nine-slice region definition for scalable UI sprites
278    #[serde(skip_serializing_if = "Option::is_none", default)]
279    pub nine_slice: Option<NineSlice>,
280}
281
282/// A palette cycle definition for animating colors without changing frames.
283///
284/// Palette cycling rotates colors through a set of tokens, creating animated
285/// effects like shimmering water, flickering fire, or pulsing energy without
286/// needing multiple sprite frames.
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
288pub struct PaletteCycle {
289    /// Tokens whose colors will be cycled (e.g., ["{water1}", "{water2}", "{water3}"])
290    pub tokens: Vec<String>,
291    /// Duration per cycle step in milliseconds (default: animation duration)
292    #[serde(skip_serializing_if = "Option::is_none", default)]
293    pub duration: Option<u32>,
294}
295
296/// A frame tag for game engine integration - identifies named ranges of frames.
297#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
298pub struct FrameTag {
299    /// Start frame index (0-based, inclusive)
300    pub start: u32,
301    /// End frame index (0-based, inclusive)
302    pub end: u32,
303    /// Whether this tag's frames should loop (overrides animation default)
304    #[serde(skip_serializing_if = "Option::is_none", default)]
305    pub r#loop: Option<bool>,
306    /// Tag-specific FPS override
307    #[serde(skip_serializing_if = "Option::is_none", default)]
308    pub fps: Option<u32>,
309}
310
311/// A collision box (hit/hurt/collide/trigger region).
312///
313/// Used for game engine integration to define collision regions on sprites.
314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
315pub struct CollisionBox {
316    /// Box X position relative to sprite origin
317    pub x: i32,
318    /// Box Y position relative to sprite origin
319    pub y: i32,
320    /// Box width in pixels
321    pub w: u32,
322    /// Box height in pixels
323    pub h: u32,
324}
325
326/// Sprite metadata for game engine integration.
327///
328/// Contains origin point, collision boxes, and attachment points for sprites.
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
330pub struct SpriteMetadata {
331    /// Sprite origin point `[x, y]` - used for positioning and rotation
332    #[serde(skip_serializing_if = "Option::is_none", default)]
333    pub origin: Option<[i32; 2]>,
334    /// Collision boxes (hit, hurt, collide, trigger, etc.)
335    #[serde(skip_serializing_if = "Option::is_none", default)]
336    pub boxes: Option<HashMap<String, CollisionBox>>,
337    /// Where this sprite connects to parent/previous segment in a chain `[x, y]`
338    #[serde(skip_serializing_if = "Option::is_none", default)]
339    pub attach_in: Option<[i32; 2]>,
340    /// Where the next segment attaches to this sprite `[x, y]`
341    #[serde(skip_serializing_if = "Option::is_none", default)]
342    pub attach_out: Option<[i32; 2]>,
343}
344
345/// Per-frame metadata for animations.
346///
347/// Allows defining frame-specific collision boxes that change during animation.
348#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
349pub struct FrameMetadata {
350    /// Per-frame collision boxes (can override or nullify sprite-level boxes)
351    /// Use `null` value to disable a box for this frame
352    #[serde(skip_serializing_if = "Option::is_none", default)]
353    pub boxes: Option<HashMap<String, Option<CollisionBox>>>,
354}
355
356/// Motion follow mode for secondary motion attachments.
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
358#[serde(rename_all = "lowercase")]
359pub enum FollowMode {
360    /// Chain follows parent position changes
361    #[default]
362    Position,
363    /// Chain reacts to parent velocity (more dynamic)
364    Velocity,
365    /// Chain follows parent rotation
366    Rotation,
367}
368
369/// Keyframe data for an attachment offset at a specific frame.
370#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
371pub struct AttachmentKeyframe {
372    /// Offset from the base anchor position `[x, y]`
373    pub offset: [i32; 2],
374}
375
376/// An animation attachment for secondary motion (hair, capes, tails).
377///
378/// Attachments follow the parent animation with configurable delay,
379/// creating natural-looking motion for appendages.
380#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
381pub struct Attachment {
382    /// Identifier for this attachment
383    pub name: String,
384    /// Attachment point `[x, y]` on parent sprite
385    pub anchor: [i32; 2],
386    /// Array of sprite names forming the chain
387    pub chain: Vec<String>,
388    /// Frame delay between chain segments (default: 1)
389    #[serde(skip_serializing_if = "Option::is_none", default)]
390    pub delay: Option<u32>,
391    /// Motion follow mode
392    #[serde(skip_serializing_if = "Option::is_none", default)]
393    pub follow: Option<FollowMode>,
394    /// Oscillation damping (0.0-1.0, default: 0.8)
395    #[serde(skip_serializing_if = "Option::is_none", default)]
396    pub damping: Option<f32>,
397    /// Spring stiffness (0.0-1.0, default: 0.5)
398    #[serde(skip_serializing_if = "Option::is_none", default)]
399    pub stiffness: Option<f32>,
400    /// Render order relative to parent (negative = behind)
401    #[serde(skip_serializing_if = "Option::is_none", default)]
402    pub z_index: Option<i32>,
403    /// Keyframe data for explicit positioning per frame (keyed by frame number as string)
404    #[serde(skip_serializing_if = "Option::is_none", default)]
405    pub keyframes: Option<HashMap<String, AttachmentKeyframe>>,
406}
407
408impl Attachment {
409    /// Default frame delay between chain segments.
410    pub const DEFAULT_DELAY: u32 = 1;
411    /// Default oscillation damping.
412    pub const DEFAULT_DAMPING: f32 = 0.8;
413    /// Default spring stiffness.
414    pub const DEFAULT_STIFFNESS: f32 = 0.5;
415
416    /// Returns the frame delay between chain segments.
417    pub fn delay(&self) -> u32 {
418        self.delay.unwrap_or(Self::DEFAULT_DELAY)
419    }
420
421    /// Returns the follow mode for this attachment.
422    pub fn follow_mode(&self) -> FollowMode {
423        self.follow.clone().unwrap_or_default()
424    }
425
426    /// Returns the damping factor.
427    pub fn damping(&self) -> f32 {
428        self.damping.unwrap_or(Self::DEFAULT_DAMPING)
429    }
430
431    /// Returns the stiffness factor.
432    pub fn stiffness(&self) -> f32 {
433        self.stiffness.unwrap_or(Self::DEFAULT_STIFFNESS)
434    }
435
436    /// Returns the z-index for render ordering (default: 0).
437    pub fn z_index(&self) -> i32 {
438        self.z_index.unwrap_or(0)
439    }
440
441    /// Returns whether this attachment uses keyframed motion.
442    pub fn is_keyframed(&self) -> bool {
443        self.keyframes.is_some()
444    }
445}
446
447/// An animation definition (Phase 3).
448///
449/// Supports two formats:
450/// - **Frame array format** (legacy): `frames: ["sprite1", "sprite2", ...]`
451/// - **CSS keyframes format** (CSS-13): `keyframes: {"0%": {...}, "100%": {...}}`
452///
453/// The `frames` and `keyframes` fields are mutually exclusive.
454#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
455pub struct Animation {
456    pub name: String,
457    /// Frame sprite names (mutually exclusive with `source` and `keyframes`)
458    #[serde(default)]
459    pub frames: Vec<String>,
460    /// CSS-style percentage-based keyframes (mutually exclusive with `frames`)
461    ///
462    /// Keys are percentages ("0%", "50%", "100%") or aliases ("from" = "0%", "to" = "100%").
463    /// Each keyframe can specify sprite, transform, opacity, and offset.
464    ///
465    /// # Example
466    /// ```json
467    /// {
468    ///   "type": "animation",
469    ///   "name": "fade_walk",
470    ///   "keyframes": {
471    ///     "0%": { "sprite": "walk_1", "opacity": 0.0 },
472    ///     "50%": { "sprite": "walk_2", "opacity": 1.0 },
473    ///     "100%": { "sprite": "walk_1", "opacity": 0.0 }
474    ///   },
475    ///   "duration": "500ms",
476    ///   "timing_function": "ease-in-out"
477    /// }
478    /// ```
479    #[serde(skip_serializing_if = "Option::is_none", default)]
480    pub keyframes: Option<HashMap<String, CssKeyframe>>,
481    /// Reference to another animation by name (mutually exclusive with `frames`)
482    #[serde(skip_serializing_if = "Option::is_none", default)]
483    pub source: Option<String>,
484    /// Transforms to apply when resolving this animation
485    #[serde(skip_serializing_if = "Option::is_none", default)]
486    pub transform: Option<Vec<TransformSpec>>,
487    /// Duration per frame (for frames format) or total animation duration (for keyframes format).
488    /// Accepts both raw milliseconds (100) and CSS time strings ("500ms", "1s").
489    #[serde(skip_serializing_if = "Option::is_none", default)]
490    pub duration: Option<Duration>,
491    /// CSS timing function for keyframes interpolation (e.g., "linear", "ease", "ease-in-out",
492    /// "cubic-bezier(0.25, 0.1, 0.25, 1.0)", "steps(4, jump-end)")
493    #[serde(skip_serializing_if = "Option::is_none", default)]
494    pub timing_function: Option<String>,
495    #[serde(skip_serializing_if = "Option::is_none", default)]
496    pub r#loop: Option<bool>,
497    /// Palette cycles for color animation effects (water, fire, energy, etc.)
498    #[serde(skip_serializing_if = "Option::is_none", default)]
499    pub palette_cycle: Option<Vec<PaletteCycle>>,
500    /// Frame tags for game engine integration - maps tag name to frame range
501    #[serde(skip_serializing_if = "Option::is_none", default)]
502    pub tags: Option<HashMap<String, FrameTag>>,
503    /// Per-frame metadata (collision boxes that vary per frame)
504    #[serde(skip_serializing_if = "Option::is_none", default)]
505    pub frame_metadata: Option<Vec<FrameMetadata>>,
506    /// Attachments for secondary motion (hair, capes, tails)
507    #[serde(skip_serializing_if = "Option::is_none", default)]
508    pub attachments: Option<Vec<Attachment>>,
509}
510
511/// A variant is a palette-only modification of a base sprite.
512///
513/// Variants allow creating color variations of sprites without duplicating
514/// the grid data. The variant copies the base sprite's grid and applies
515/// palette overrides.
516#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
517pub struct Variant {
518    pub name: String,
519    pub base: String,
520    pub palette: HashMap<String, String>,
521    /// Transforms to apply when resolving this variant
522    #[serde(skip_serializing_if = "Option::is_none", default)]
523    pub transform: Option<Vec<TransformSpec>>,
524}
525
526impl Animation {
527    /// Default duration per frame in milliseconds.
528    pub const DEFAULT_DURATION_MS: u32 = 100;
529
530    /// Returns the duration in milliseconds (default: 100ms).
531    ///
532    /// For frame-based animations, this is the duration per frame.
533    /// For CSS keyframe animations, this is the total animation duration.
534    pub fn duration_ms(&self) -> u32 {
535        self.duration
536            .as_ref()
537            .and_then(|d| d.as_milliseconds())
538            .unwrap_or(Self::DEFAULT_DURATION_MS)
539    }
540
541    /// Returns whether the animation should loop (default: true).
542    pub fn loops(&self) -> bool {
543        self.r#loop.unwrap_or(true)
544    }
545
546    /// Returns whether this animation uses CSS-style keyframes.
547    pub fn is_css_keyframes(&self) -> bool {
548        self.keyframes.is_some() && !self.keyframes.as_ref().unwrap().is_empty()
549    }
550
551    /// Returns whether this animation uses frame array format.
552    pub fn is_frame_based(&self) -> bool {
553        !self.frames.is_empty()
554    }
555
556    /// Returns the CSS keyframes, or None if using frame-based format.
557    pub fn css_keyframes(&self) -> Option<&HashMap<String, CssKeyframe>> {
558        self.keyframes.as_ref()
559    }
560
561    /// Returns whether this animation uses palette cycling.
562    pub fn has_palette_cycle(&self) -> bool {
563        self.palette_cycle.as_ref().map(|cycles| !cycles.is_empty()).unwrap_or(false)
564    }
565
566    /// Returns the palette cycles, or an empty slice if none.
567    pub fn palette_cycles(&self) -> &[PaletteCycle] {
568        self.palette_cycle.as_deref().unwrap_or(&[])
569    }
570
571    /// Returns whether this animation has secondary motion attachments.
572    pub fn has_attachments(&self) -> bool {
573        self.attachments.as_ref().map(|a| !a.is_empty()).unwrap_or(false)
574    }
575
576    /// Returns the attachments, or an empty slice if none.
577    pub fn attachments(&self) -> &[Attachment] {
578        self.attachments.as_deref().unwrap_or(&[])
579    }
580
581    /// Parse the keyframe percentage key to a normalized value (0.0 to 1.0).
582    ///
583    /// Supports:
584    /// - Percentage strings: "0%", "50%", "100%"
585    /// - Aliases: "from" (= 0%), "to" (= 100%)
586    ///
587    /// Returns `None` if the key cannot be parsed.
588    pub fn parse_keyframe_percent(key: &str) -> Option<f64> {
589        let key = key.trim().to_lowercase();
590
591        match key.as_str() {
592            "from" => Some(0.0),
593            "to" => Some(1.0),
594            _ => {
595                if let Some(pct_str) = key.strip_suffix('%') {
596                    pct_str.trim().parse::<f64>().ok().map(|v| (v / 100.0).clamp(0.0, 1.0))
597                } else {
598                    None
599                }
600            }
601        }
602    }
603
604    /// Returns the sorted keyframe entries as (normalized_percent, keyframe) pairs.
605    ///
606    /// The keyframes are sorted by their percentage value from 0.0 to 1.0.
607    pub fn sorted_keyframes(&self) -> Vec<(f64, &CssKeyframe)> {
608        let Some(keyframes) = &self.keyframes else {
609            return vec![];
610        };
611
612        let mut entries: Vec<_> = keyframes
613            .iter()
614            .filter_map(|(key, kf)| Self::parse_keyframe_percent(key).map(|pct| (pct, kf)))
615            .collect();
616
617        entries.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
618        entries
619    }
620}
621
622impl PaletteCycle {
623    /// Returns the duration per cycle step in milliseconds.
624    /// Falls back to the provided default (typically animation duration).
625    pub fn duration_ms(&self, default: u32) -> u32 {
626        self.duration.unwrap_or(default)
627    }
628
629    /// Returns the number of cycle steps (= number of tokens).
630    pub fn cycle_length(&self) -> usize {
631        self.tokens.len()
632    }
633}
634
635/// A layer within a composition.
636#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
637pub struct CompositionLayer {
638    #[serde(skip_serializing_if = "Option::is_none", default)]
639    pub name: Option<String>,
640    #[serde(skip_serializing_if = "Option::is_none", default)]
641    pub fill: Option<String>,
642    #[serde(skip_serializing_if = "Option::is_none", default)]
643    pub map: Option<Vec<String>>,
644    /// Transforms to apply to this layer
645    #[serde(skip_serializing_if = "Option::is_none", default)]
646    pub transform: Option<Vec<TransformSpec>>,
647    /// Blend mode for this layer (ATF-10). Default: "normal"
648    /// Supports var() syntax for CSS variable references (CSS-9).
649    #[serde(skip_serializing_if = "Option::is_none", default)]
650    pub blend: Option<String>,
651    /// Layer opacity from 0.0 (transparent) to 1.0 (opaque). Default: 1.0
652    /// Supports var() syntax for CSS variable references (CSS-9).
653    /// Can be a number (0.5) or a var() string ("var(--layer-opacity)").
654    #[serde(skip_serializing_if = "Option::is_none", default)]
655    pub opacity: Option<VarOr<f64>>,
656}
657
658/// A composition that layers sprites onto a canvas.
659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
660pub struct Composition {
661    pub name: String,
662    #[serde(skip_serializing_if = "Option::is_none", default)]
663    pub base: Option<String>,
664    #[serde(skip_serializing_if = "Option::is_none", default)]
665    pub size: Option<[u32; 2]>,
666    #[serde(skip_serializing_if = "Option::is_none", default)]
667    pub cell_size: Option<[u32; 2]>,
668    pub sprites: HashMap<String, Option<String>>,
669    pub layers: Vec<CompositionLayer>,
670}
671
672impl Composition {
673    /// Default cell size when not specified: 1x1 pixels.
674    pub const DEFAULT_CELL_SIZE: [u32; 2] = [1, 1];
675
676    /// Returns the cell size for tiling (default: [1, 1]).
677    pub fn cell_size(&self) -> [u32; 2] {
678        self.cell_size.unwrap_or(Self::DEFAULT_CELL_SIZE)
679    }
680}
681
682/// Velocity range for particle emitter (ATF-16)
683#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
684pub struct VelocityRange {
685    /// X velocity range [min, max]
686    pub x: [f64; 2],
687    /// Y velocity range [min, max]
688    pub y: [f64; 2],
689}
690
691impl Default for VelocityRange {
692    fn default() -> Self {
693        Self { x: [0.0, 0.0], y: [0.0, 0.0] }
694    }
695}
696
697/// Particle emitter configuration (ATF-16)
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
699pub struct ParticleEmitter {
700    /// Particles to emit per frame
701    #[serde(default = "default_rate")]
702    pub rate: f64,
703    /// Particle lifetime in frames [min, max]
704    #[serde(default = "default_lifetime")]
705    pub lifetime: [u32; 2],
706    /// Initial velocity range
707    #[serde(skip_serializing_if = "Option::is_none", default)]
708    pub velocity: Option<VelocityRange>,
709    /// Gravity acceleration (pixels per frame^2)
710    #[serde(skip_serializing_if = "Option::is_none", default)]
711    pub gravity: Option<f64>,
712    /// Whether particles fade out over lifetime
713    #[serde(skip_serializing_if = "Option::is_none", default)]
714    pub fade: Option<bool>,
715    /// Rotation range in degrees [min, max]
716    #[serde(skip_serializing_if = "Option::is_none", default)]
717    pub rotation: Option<[f64; 2]>,
718    /// Random seed for reproducible effects
719    #[serde(skip_serializing_if = "Option::is_none", default)]
720    pub seed: Option<u64>,
721}
722
723fn default_rate() -> f64 {
724    1.0
725}
726
727fn default_lifetime() -> [u32; 2] {
728    [10, 20]
729}
730
731impl Default for ParticleEmitter {
732    fn default() -> Self {
733        Self {
734            rate: default_rate(),
735            lifetime: default_lifetime(),
736            velocity: None,
737            gravity: None,
738            fade: None,
739            rotation: None,
740            seed: None,
741        }
742    }
743}
744
745/// A particle system definition (ATF-16)
746///
747/// Particle systems emit sprites with randomized motion for effects
748/// like sparks, dust, rain, snow, fire, etc.
749#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
750pub struct Particle {
751    /// Name of this particle system
752    pub name: String,
753    /// Reference to the sprite to emit as particles
754    pub sprite: String,
755    /// Emitter configuration
756    pub emitter: ParticleEmitter,
757}
758
759/// Easing function for keyframe interpolation.
760///
761/// Controls how values transition between keyframes.
762#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
763#[serde(rename_all = "kebab-case")]
764pub enum Easing {
765    /// Constant speed
766    #[default]
767    Linear,
768    /// Slow start, fast end (acceleration)
769    EaseIn,
770    /// Fast start, slow end (deceleration)
771    EaseOut,
772    /// Slow start and end (smooth S-curve)
773    EaseInOut,
774    /// Overshoots and settles
775    Bounce,
776    /// Spring-like oscillation
777    Elastic,
778}
779
780impl Easing {
781    /// Apply the easing function to a normalized time value (0.0 to 1.0).
782    ///
783    /// Returns the eased value (also 0.0 to 1.0, but may exceed bounds for bounce/elastic).
784    pub fn apply(&self, t: f64) -> f64 {
785        let t = t.clamp(0.0, 1.0);
786        match self {
787            Easing::Linear => t,
788            Easing::EaseIn => t * t,
789            Easing::EaseOut => 1.0 - (1.0 - t).powi(2),
790            Easing::EaseInOut => {
791                if t < 0.5 {
792                    2.0 * t * t
793                } else {
794                    1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
795                }
796            }
797            Easing::Bounce => {
798                let n1 = 7.5625;
799                let d1 = 2.75;
800                let t = 1.0 - t;
801                let bounce = if t < 1.0 / d1 {
802                    n1 * t * t
803                } else if t < 2.0 / d1 {
804                    let t = t - 1.5 / d1;
805                    n1 * t * t + 0.75
806                } else if t < 2.5 / d1 {
807                    let t = t - 2.25 / d1;
808                    n1 * t * t + 0.9375
809                } else {
810                    let t = t - 2.625 / d1;
811                    n1 * t * t + 0.984375
812                };
813                1.0 - bounce
814            }
815            Easing::Elastic => {
816                if t == 0.0 {
817                    0.0
818                } else if t == 1.0 {
819                    1.0
820                } else {
821                    let c4 = (2.0 * std::f64::consts::PI) / 3.0;
822                    2.0_f64.powf(-10.0 * t) * ((t * 10.0 - 0.75) * c4).sin() + 1.0
823                }
824            }
825        }
826    }
827
828    /// Parse an easing function from a string.
829    pub fn from_str(s: &str) -> Option<Easing> {
830        match s.to_lowercase().replace('_', "-").as_str() {
831            "linear" => Some(Easing::Linear),
832            "ease-in" | "easein" => Some(Easing::EaseIn),
833            "ease-out" | "easeout" => Some(Easing::EaseOut),
834            "ease-in-out" | "easeinout" => Some(Easing::EaseInOut),
835            "bounce" => Some(Easing::Bounce),
836            "elastic" => Some(Easing::Elastic),
837            _ => None,
838        }
839    }
840}
841
842/// A single keyframe defining values at a specific frame.
843#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
844pub struct Keyframe {
845    /// Frame index (0-based)
846    pub frame: u32,
847    /// Property values at this keyframe (property name -> value)
848    #[serde(flatten)]
849    pub values: HashMap<String, f64>,
850}
851
852/// Keyframes for a single property, either via expression or explicit values.
853#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
854pub struct PropertyKeyframes {
855    /// Mathematical expression for the property value.
856    /// Available variables: `frame`, `t` (normalized 0.0-1.0), `total_frames`, and user params.
857    /// Available functions: sin, cos, tan, pow, sqrt, min, max, abs, floor, ceil, round.
858    #[serde(skip_serializing_if = "Option::is_none", default)]
859    pub expr: Option<String>,
860    /// Explicit keyframe pairs: [[frame, value], ...]
861    #[serde(skip_serializing_if = "Option::is_none", default)]
862    pub keyframes: Option<Vec<[f64; 2]>>,
863    /// Per-property easing function (overrides transform-level easing)
864    #[serde(skip_serializing_if = "Option::is_none", default)]
865    pub easing: Option<Easing>,
866}
867
868/// Keyframe specification - array of keyframes or per-property expressions.
869#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
870#[serde(untagged)]
871pub enum KeyframeSpec {
872    /// Array of keyframes with frame numbers and values
873    Array(Vec<Keyframe>),
874    /// Per-property keyframe definitions with expressions or explicit values
875    Properties(HashMap<String, PropertyKeyframes>),
876}
877
878/// A user-defined transform.
879///
880/// Allows creating reusable, parameterized transforms with keyframe animation support.
881///
882/// # Examples
883///
884/// ## Named transform sequence
885/// ```json
886/// {
887///   "type": "transform",
888///   "name": "flip-glow",
889///   "ops": ["mirror-h", "outline"]
890/// }
891/// ```
892///
893/// ## Parameterized transform
894/// ```json
895/// {
896///   "type": "transform",
897///   "name": "padded-outline",
898///   "params": ["padding", "outline_width"],
899///   "ops": [
900///     {"op": "pad", "size": "${padding}"},
901///     {"op": "outline", "width": "${outline_width}"}
902///   ]
903/// }
904/// ```
905///
906/// ## Keyframe animation transform
907/// ```json
908/// {
909///   "type": "transform",
910///   "name": "hop",
911///   "frames": 8,
912///   "keyframes": [
913///     {"frame": 0, "shift-y": 0},
914///     {"frame": 4, "shift-y": -4},
915///     {"frame": 8, "shift-y": 0}
916///   ],
917///   "easing": "ease-out"
918/// }
919/// ```
920#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
921pub struct TransformDef {
922    /// Name of this user-defined transform
923    pub name: String,
924    /// Parameter names for parameterized transforms
925    #[serde(skip_serializing_if = "Option::is_none", default)]
926    pub params: Option<Vec<String>>,
927    /// Simple sequence of transform operations
928    #[serde(skip_serializing_if = "Option::is_none", default)]
929    pub ops: Option<Vec<TransformSpec>>,
930    /// Parallel composition of transforms (computed together per-frame)
931    #[serde(skip_serializing_if = "Option::is_none", default)]
932    pub compose: Option<Vec<TransformSpec>>,
933    /// Per-frame transform cycling
934    #[serde(skip_serializing_if = "Option::is_none", default)]
935    pub cycle: Option<Vec<Vec<TransformSpec>>>,
936    /// Number of frames for keyframe animation generation
937    #[serde(skip_serializing_if = "Option::is_none", default)]
938    pub frames: Option<u32>,
939    /// Keyframe data for animation generation
940    #[serde(skip_serializing_if = "Option::is_none", default)]
941    pub keyframes: Option<KeyframeSpec>,
942    /// Default easing function for keyframe interpolation
943    #[serde(skip_serializing_if = "Option::is_none", default)]
944    pub easing: Option<Easing>,
945}
946
947impl TransformDef {
948    /// Returns whether this is a simple ops-only transform (no keyframes/expressions).
949    pub fn is_simple(&self) -> bool {
950        self.ops.is_some()
951            && self.compose.is_none()
952            && self.cycle.is_none()
953            && self.keyframes.is_none()
954    }
955
956    /// Returns whether this transform generates animation frames.
957    pub fn generates_animation(&self) -> bool {
958        self.frames.is_some() && self.keyframes.is_some()
959    }
960
961    /// Returns whether this is a parameterized transform.
962    pub fn is_parameterized(&self) -> bool {
963        self.params.as_ref().map(|p| !p.is_empty()).unwrap_or(false)
964    }
965
966    /// Returns whether this is a cycling transform.
967    pub fn is_cycling(&self) -> bool {
968        self.cycle.as_ref().map(|c| !c.is_empty()).unwrap_or(false)
969    }
970}
971
972/// A Pixelsrc object - Palette, Sprite, Variant, Composition, Animation, Particle, or Transform.
973#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
974#[serde(tag = "type", rename_all = "lowercase")]
975pub enum TtpObject {
976    Palette(Palette),
977    Sprite(Sprite),
978    Variant(Variant),
979    Composition(Composition),
980    Animation(Animation),
981    Particle(Particle),
982    Transform(TransformDef),
983}
984
985/// A warning message from parsing/rendering.
986#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
987pub struct Warning {
988    pub message: String,
989    pub line: usize,
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995
996    #[test]
997    fn test_palette_roundtrip() {
998        let palette = Palette {
999            name: "mono".to_string(),
1000            colors: HashMap::from([
1001                ("{_}".to_string(), "#00000000".to_string()),
1002                ("{on}".to_string(), "#FFFFFF".to_string()),
1003            ]),
1004        };
1005        let json = serde_json::to_string(&palette).unwrap();
1006        let parsed: Palette = serde_json::from_str(&json).unwrap();
1007        assert_eq!(palette, parsed);
1008    }
1009
1010    #[test]
1011    fn test_sprite_with_inline_palette_roundtrip() {
1012        let sprite = Sprite {
1013            name: "dot".to_string(),
1014            size: None,
1015            palette: PaletteRef::Inline(HashMap::from([
1016                ("{_}".to_string(), "#00000000".to_string()),
1017                ("{x}".to_string(), "#FF0000".to_string()),
1018            ])),
1019            grid: vec!["{x}".to_string()],
1020            metadata: None,
1021            ..Default::default()
1022        };
1023        let json = serde_json::to_string(&sprite).unwrap();
1024        let parsed: Sprite = serde_json::from_str(&json).unwrap();
1025        assert_eq!(sprite, parsed);
1026    }
1027
1028    #[test]
1029    fn test_sprite_with_named_palette_roundtrip() {
1030        let sprite = Sprite {
1031            name: "checker".to_string(),
1032            size: Some([4, 4]),
1033            palette: PaletteRef::Named("mono".to_string()),
1034            grid: vec!["{on}{off}{on}{off}".to_string(), "{off}{on}{off}{on}".to_string()],
1035            metadata: None,
1036            ..Default::default()
1037        };
1038        let json = serde_json::to_string(&sprite).unwrap();
1039        let parsed: Sprite = serde_json::from_str(&json).unwrap();
1040        assert_eq!(sprite, parsed);
1041    }
1042
1043    #[test]
1044    fn test_ttp_object_palette_roundtrip() {
1045        let obj = TtpObject::Palette(Palette {
1046            name: "test".to_string(),
1047            colors: HashMap::from([("{a}".to_string(), "#FF0000".to_string())]),
1048        });
1049        let json = serde_json::to_string(&obj).unwrap();
1050        assert!(json.contains(r#""type":"palette""#));
1051        let parsed: TtpObject = serde_json::from_str(&json).unwrap();
1052        assert_eq!(obj, parsed);
1053    }
1054
1055    #[test]
1056    fn test_ttp_object_sprite_roundtrip() {
1057        let obj = TtpObject::Sprite(Sprite {
1058            name: "test".to_string(),
1059            size: None,
1060            palette: PaletteRef::Named("colors".to_string()),
1061            grid: vec!["{a}{b}".to_string()],
1062            metadata: None,
1063            ..Default::default()
1064        });
1065        let json = serde_json::to_string(&obj).unwrap();
1066        assert!(json.contains(r#""type":"sprite""#));
1067        let parsed: TtpObject = serde_json::from_str(&json).unwrap();
1068        assert_eq!(obj, parsed);
1069    }
1070
1071    #[test]
1072    fn test_warning_roundtrip() {
1073        let warning = Warning { message: "Row 1 has 3 tokens, expected 4".to_string(), line: 5 };
1074        let json = serde_json::to_string(&warning).unwrap();
1075        let parsed: Warning = serde_json::from_str(&json).unwrap();
1076        assert_eq!(warning, parsed);
1077    }
1078
1079    #[test]
1080    fn test_minimal_dot_fixture() {
1081        // {"type": "sprite", "name": "dot", "palette": {"{_}": "#00000000", "{x}": "#FF0000"}, "grid": ["{x}"]}
1082        let json = r##"{"type": "sprite", "name": "dot", "palette": {"{_}": "#00000000", "{x}": "#FF0000"}, "grid": ["{x}"]}"##;
1083        let obj: TtpObject = serde_json::from_str(json).unwrap();
1084        match obj {
1085            TtpObject::Sprite(sprite) => {
1086                assert_eq!(sprite.name, "dot");
1087                assert!(sprite.size.is_none());
1088                assert_eq!(sprite.grid, vec!["{x}"]);
1089                match sprite.palette {
1090                    PaletteRef::Inline(colors) => {
1091                        assert_eq!(colors.get("{x}"), Some(&"#FF0000".to_string()));
1092                    }
1093                    _ => panic!("Expected inline palette"),
1094                }
1095            }
1096            _ => panic!("Expected sprite"),
1097        }
1098    }
1099
1100    #[test]
1101    fn test_named_palette_fixture() {
1102        // {"type": "palette", "name": "mono", "colors": {"{_}": "#00000000", "{on}": "#FFFFFF", "{off}": "#000000"}}
1103        let json = r##"{"type": "palette", "name": "mono", "colors": {"{_}": "#00000000", "{on}": "#FFFFFF", "{off}": "#000000"}}"##;
1104        let obj: TtpObject = serde_json::from_str(json).unwrap();
1105        match obj {
1106            TtpObject::Palette(palette) => {
1107                assert_eq!(palette.name, "mono");
1108                assert_eq!(palette.colors.len(), 3);
1109                assert_eq!(palette.colors.get("{on}"), Some(&"#FFFFFF".to_string()));
1110            }
1111            _ => panic!("Expected palette"),
1112        }
1113
1114        // {"type": "sprite", "name": "checker", "palette": "mono", "grid": [...]}
1115        let json = r#"{"type": "sprite", "name": "checker", "palette": "mono", "grid": ["{on}{off}{on}{off}", "{off}{on}{off}{on}"]}"#;
1116        let obj: TtpObject = serde_json::from_str(json).unwrap();
1117        match obj {
1118            TtpObject::Sprite(sprite) => {
1119                assert_eq!(sprite.name, "checker");
1120                match sprite.palette {
1121                    PaletteRef::Named(name) => assert_eq!(name, "mono"),
1122                    _ => panic!("Expected named palette reference"),
1123                }
1124            }
1125            _ => panic!("Expected sprite"),
1126        }
1127    }
1128
1129    #[test]
1130    fn test_composition_basic_parse() {
1131        let json = r#"{"type": "composition", "name": "test_comp", "sprites": {".": null, "X": "sprite_x"}, "layers": [{"name": "layer1", "map": ["X."]}]}"#;
1132        let obj: TtpObject = serde_json::from_str(json).unwrap();
1133        match obj {
1134            TtpObject::Composition(comp) => {
1135                assert_eq!(comp.name, "test_comp");
1136                assert!(comp.base.is_none());
1137                assert!(comp.size.is_none());
1138                assert!(comp.cell_size.is_none());
1139                assert_eq!(comp.sprites.len(), 2);
1140                assert_eq!(comp.sprites.get("."), Some(&None));
1141                assert_eq!(comp.sprites.get("X"), Some(&Some("sprite_x".to_string())));
1142                assert_eq!(comp.layers.len(), 1);
1143                assert_eq!(comp.layers[0].name, Some("layer1".to_string()));
1144                assert_eq!(comp.layers[0].map, Some(vec!["X.".to_string()]));
1145            }
1146            _ => panic!("Expected composition"),
1147        }
1148    }
1149
1150    #[test]
1151    fn test_composition_with_all_fields() {
1152        let json = r#"{"type": "composition", "name": "full_comp", "base": "hero_base", "size": [64, 64], "cell_size": [8, 8], "sprites": {".": null, "H": "hat"}, "layers": [{"name": "gear", "fill": "H", "map": ["H.", ".H"]}]}"#;
1153        let obj: TtpObject = serde_json::from_str(json).unwrap();
1154        match obj {
1155            TtpObject::Composition(comp) => {
1156                assert_eq!(comp.name, "full_comp");
1157                assert_eq!(comp.base, Some("hero_base".to_string()));
1158                assert_eq!(comp.size, Some([64, 64]));
1159                assert_eq!(comp.cell_size, Some([8, 8]));
1160                assert_eq!(comp.layers[0].fill, Some("H".to_string()));
1161            }
1162            _ => panic!("Expected composition"),
1163        }
1164    }
1165
1166    #[test]
1167    fn test_composition_roundtrip() {
1168        let comp = Composition {
1169            name: "roundtrip_test".to_string(),
1170            base: Some("base_sprite".to_string()),
1171            size: Some([32, 32]),
1172            cell_size: Some([4, 4]),
1173            sprites: HashMap::from([
1174                (".".to_string(), None),
1175                ("A".to_string(), Some("sprite_a".to_string())),
1176            ]),
1177            layers: vec![CompositionLayer {
1178                name: Some("layer1".to_string()),
1179                fill: None,
1180                map: Some(vec!["A.".to_string(), ".A".to_string()]),
1181                ..Default::default()
1182            }],
1183        };
1184        let obj = TtpObject::Composition(comp.clone());
1185        let json = serde_json::to_string(&obj).unwrap();
1186        assert!(json.contains(r#""type":"composition""#));
1187        let parsed: TtpObject = serde_json::from_str(&json).unwrap();
1188        match parsed {
1189            TtpObject::Composition(parsed_comp) => {
1190                assert_eq!(comp, parsed_comp);
1191            }
1192            _ => panic!("Expected composition"),
1193        }
1194    }
1195
1196    #[test]
1197    fn test_composition_default_cell_size() {
1198        // cell_size should default to None when not specified
1199        let json =
1200            r#"{"type": "composition", "name": "no_cell_size", "sprites": {}, "layers": []}"#;
1201        let obj: TtpObject = serde_json::from_str(json).unwrap();
1202        match obj {
1203            TtpObject::Composition(comp) => {
1204                assert!(comp.cell_size.is_none());
1205                // Helper method should return default [1, 1]
1206                assert_eq!(comp.cell_size(), [1, 1]);
1207            }
1208            _ => panic!("Expected composition"),
1209        }
1210    }
1211
1212    #[test]
1213    fn test_composition_cell_size_helper() {
1214        // When cell_size is specified, helper returns it
1215        let comp = Composition {
1216            name: "test".to_string(),
1217            base: None,
1218            size: None,
1219            cell_size: Some([8, 8]),
1220            sprites: HashMap::new(),
1221            layers: vec![],
1222        };
1223        assert_eq!(comp.cell_size(), [8, 8]);
1224
1225        // When cell_size is None, helper returns default [1, 1]
1226        let comp_default = Composition {
1227            name: "test_default".to_string(),
1228            base: None,
1229            size: None,
1230            cell_size: None,
1231            sprites: HashMap::new(),
1232            layers: vec![],
1233        };
1234        assert_eq!(comp_default.cell_size(), Composition::DEFAULT_CELL_SIZE);
1235        assert_eq!(comp_default.cell_size(), [1, 1]);
1236    }
1237
1238    #[test]
1239    fn test_animation_parse_full() {
1240        // Animation with all fields specified
1241        let json = r#"{"type": "animation", "name": "blink_anim", "frames": ["on", "off"], "duration": 500, "loop": true}"#;
1242        let obj: TtpObject = serde_json::from_str(json).unwrap();
1243        match obj {
1244            TtpObject::Animation(anim) => {
1245                assert_eq!(anim.name, "blink_anim");
1246                assert_eq!(anim.frames, vec!["on", "off"]);
1247                assert_eq!(anim.duration, Some(Duration::Milliseconds(500)));
1248                assert_eq!(anim.r#loop, Some(true));
1249                // Helper methods should return specified values
1250                assert_eq!(anim.duration_ms(), 500);
1251                assert!(anim.loops());
1252            }
1253            _ => panic!("Expected animation"),
1254        }
1255    }
1256
1257    #[test]
1258    fn test_animation_default_duration() {
1259        // Animation without duration - should default to 100ms
1260        let json = r#"{"type": "animation", "name": "walk", "frames": ["frame1", "frame2"]}"#;
1261        let obj: TtpObject = serde_json::from_str(json).unwrap();
1262        match obj {
1263            TtpObject::Animation(anim) => {
1264                assert_eq!(anim.name, "walk");
1265                assert!(anim.duration.is_none());
1266                assert_eq!(anim.duration_ms(), 100); // Default
1267            }
1268            _ => panic!("Expected animation"),
1269        }
1270    }
1271
1272    #[test]
1273    fn test_animation_default_loop() {
1274        // Animation without loop - should default to true
1275        let json = r#"{"type": "animation", "name": "idle", "frames": ["f1"]}"#;
1276        let obj: TtpObject = serde_json::from_str(json).unwrap();
1277        match obj {
1278            TtpObject::Animation(anim) => {
1279                assert!(anim.r#loop.is_none());
1280                assert!(anim.loops()); // Default is true
1281            }
1282            _ => panic!("Expected animation"),
1283        }
1284    }
1285
1286    #[test]
1287    fn test_animation_loop_false() {
1288        // Animation with loop=false
1289        let json =
1290            r#"{"type": "animation", "name": "death", "frames": ["f1", "f2"], "loop": false}"#;
1291        let obj: TtpObject = serde_json::from_str(json).unwrap();
1292        match obj {
1293            TtpObject::Animation(anim) => {
1294                assert_eq!(anim.r#loop, Some(false));
1295                assert!(!anim.loops());
1296            }
1297            _ => panic!("Expected animation"),
1298        }
1299    }
1300
1301    #[test]
1302    fn test_animation_roundtrip() {
1303        let anim = Animation {
1304            name: "test_anim".to_string(),
1305            frames: vec!["a".to_string(), "b".to_string(), "c".to_string()],
1306            duration: Some(Duration::Milliseconds(200)),
1307            r#loop: Some(false),
1308            palette_cycle: None,
1309            tags: None,
1310            frame_metadata: None,
1311            attachments: None,
1312            ..Default::default()
1313        };
1314        let obj = TtpObject::Animation(anim.clone());
1315        let json = serde_json::to_string(&obj).unwrap();
1316        assert!(json.contains(r#""type":"animation""#));
1317        let parsed: TtpObject = serde_json::from_str(&json).unwrap();
1318        match parsed {
1319            TtpObject::Animation(parsed_anim) => {
1320                assert_eq!(anim, parsed_anim);
1321            }
1322            _ => panic!("Expected animation"),
1323        }
1324    }
1325
1326    #[test]
1327    fn test_animation_with_palette_cycle() {
1328        // Animation with palette cycling for water effect
1329        let json = r#"{"type": "animation", "name": "water", "frames": ["water_tile"], "duration": 100, "palette_cycle": [{"tokens": ["{w1}", "{w2}", "{w3}"], "duration": 150}]}"#;
1330        let obj: TtpObject = serde_json::from_str(json).unwrap();
1331        match obj {
1332            TtpObject::Animation(anim) => {
1333                assert_eq!(anim.name, "water");
1334                assert!(anim.has_palette_cycle());
1335                let cycles = anim.palette_cycles();
1336                assert_eq!(cycles.len(), 1);
1337                assert_eq!(cycles[0].tokens, vec!["{w1}", "{w2}", "{w3}"]);
1338                assert_eq!(cycles[0].duration, Some(150));
1339                assert_eq!(cycles[0].duration_ms(100), 150);
1340                assert_eq!(cycles[0].cycle_length(), 3);
1341            }
1342            _ => panic!("Expected animation"),
1343        }
1344    }
1345
1346    #[test]
1347    fn test_animation_palette_cycle_default_duration() {
1348        // Palette cycle without explicit duration uses animation duration
1349        let json = r#"{"type": "animation", "name": "fire", "frames": ["flame"], "duration": 80, "palette_cycle": [{"tokens": ["{f1}", "{f2}"]}]}"#;
1350        let obj: TtpObject = serde_json::from_str(json).unwrap();
1351        match obj {
1352            TtpObject::Animation(anim) => {
1353                let cycles = anim.palette_cycles();
1354                assert_eq!(cycles.len(), 1);
1355                assert!(cycles[0].duration.is_none());
1356                // Should use animation duration as fallback
1357                assert_eq!(cycles[0].duration_ms(anim.duration_ms()), 80);
1358            }
1359            _ => panic!("Expected animation"),
1360        }
1361    }
1362
1363    #[test]
1364    fn test_animation_multiple_palette_cycles() {
1365        // Animation with multiple independent cycles
1366        let json = r#"{"type": "animation", "name": "scene", "frames": ["scene_frame"], "palette_cycle": [{"tokens": ["{water1}", "{water2}"], "duration": 200}, {"tokens": ["{fire1}", "{fire2}", "{fire3}"], "duration": 100}]}"#;
1367        let obj: TtpObject = serde_json::from_str(json).unwrap();
1368        match obj {
1369            TtpObject::Animation(anim) => {
1370                let cycles = anim.palette_cycles();
1371                assert_eq!(cycles.len(), 2);
1372                // Water cycle
1373                assert_eq!(cycles[0].tokens.len(), 2);
1374                assert_eq!(cycles[0].duration, Some(200));
1375                // Fire cycle
1376                assert_eq!(cycles[1].tokens.len(), 3);
1377                assert_eq!(cycles[1].duration, Some(100));
1378            }
1379            _ => panic!("Expected animation"),
1380        }
1381    }
1382
1383    #[test]
1384    fn test_animation_palette_cycle_roundtrip() {
1385        let anim = Animation {
1386            name: "cycle_test".to_string(),
1387            frames: vec!["sprite".to_string()],
1388            duration: Some(Duration::Milliseconds(100)),
1389            r#loop: Some(true),
1390            palette_cycle: Some(vec![PaletteCycle {
1391                tokens: vec!["{a}".to_string(), "{b}".to_string()],
1392                duration: Some(150),
1393            }]),
1394            tags: None,
1395            frame_metadata: None,
1396            attachments: None,
1397            ..Default::default()
1398        };
1399        let obj = TtpObject::Animation(anim.clone());
1400        let json = serde_json::to_string(&obj).unwrap();
1401        assert!(json.contains("palette_cycle"));
1402        let parsed: TtpObject = serde_json::from_str(&json).unwrap();
1403        match parsed {
1404            TtpObject::Animation(parsed_anim) => {
1405                assert_eq!(anim, parsed_anim);
1406            }
1407            _ => panic!("Expected animation"),
1408        }
1409    }
1410
1411    #[test]
1412    fn test_animation_no_palette_cycle() {
1413        // Animation without palette_cycle should have has_palette_cycle() return false
1414        let anim = Animation {
1415            name: "normal".to_string(),
1416            frames: vec!["f1".to_string()],
1417            duration: None,
1418            r#loop: None,
1419            palette_cycle: None,
1420            tags: None,
1421            frame_metadata: None,
1422            attachments: None,
1423            ..Default::default()
1424        };
1425        assert!(!anim.has_palette_cycle());
1426        assert!(anim.palette_cycles().is_empty());
1427    }
1428
1429    #[test]
1430    fn test_variant_parse_basic() {
1431        // Variant with single color override
1432        let json = r##"{"type": "variant", "name": "hero_red", "base": "hero", "palette": {"{skin}": "#FF0000"}}"##;
1433        let obj: TtpObject = serde_json::from_str(json).unwrap();
1434        match obj {
1435            TtpObject::Variant(variant) => {
1436                assert_eq!(variant.name, "hero_red");
1437                assert_eq!(variant.base, "hero");
1438                assert_eq!(variant.palette.len(), 1);
1439                assert_eq!(variant.palette.get("{skin}"), Some(&"#FF0000".to_string()));
1440            }
1441            _ => panic!("Expected variant"),
1442        }
1443    }
1444
1445    #[test]
1446    fn test_variant_parse_multiple_overrides() {
1447        // Variant with multiple color overrides
1448        let json = r##"{"type": "variant", "name": "hero_alt", "base": "hero", "palette": {"{skin}": "#00FF00", "{hair}": "#0000FF", "{eyes}": "#FFFF00"}}"##;
1449        let obj: TtpObject = serde_json::from_str(json).unwrap();
1450        match obj {
1451            TtpObject::Variant(variant) => {
1452                assert_eq!(variant.name, "hero_alt");
1453                assert_eq!(variant.base, "hero");
1454                assert_eq!(variant.palette.len(), 3);
1455                assert_eq!(variant.palette.get("{skin}"), Some(&"#00FF00".to_string()));
1456                assert_eq!(variant.palette.get("{hair}"), Some(&"#0000FF".to_string()));
1457                assert_eq!(variant.palette.get("{eyes}"), Some(&"#FFFF00".to_string()));
1458            }
1459            _ => panic!("Expected variant"),
1460        }
1461    }
1462
1463    #[test]
1464    fn test_variant_roundtrip() {
1465        let variant = Variant {
1466            name: "test_variant".to_string(),
1467            base: "base_sprite".to_string(),
1468            palette: HashMap::from([
1469                ("{a}".to_string(), "#FF0000".to_string()),
1470                ("{b}".to_string(), "#00FF00".to_string()),
1471            ]),
1472            ..Default::default()
1473        };
1474        let obj = TtpObject::Variant(variant.clone());
1475        let json = serde_json::to_string(&obj).unwrap();
1476        assert!(json.contains(r##""type":"variant""##));
1477        let parsed: TtpObject = serde_json::from_str(&json).unwrap();
1478        match parsed {
1479            TtpObject::Variant(parsed_variant) => {
1480                assert_eq!(variant, parsed_variant);
1481            }
1482            _ => panic!("Expected variant"),
1483        }
1484    }
1485
1486    #[test]
1487    fn test_variant_empty_palette() {
1488        // Variant with empty palette (inherits all colors from base)
1489        let json = r#"{"type": "variant", "name": "hero_copy", "base": "hero", "palette": {}}"#;
1490        let obj: TtpObject = serde_json::from_str(json).unwrap();
1491        match obj {
1492            TtpObject::Variant(variant) => {
1493                assert_eq!(variant.name, "hero_copy");
1494                assert_eq!(variant.base, "hero");
1495                assert!(variant.palette.is_empty());
1496            }
1497            _ => panic!("Expected variant"),
1498        }
1499    }
1500
1501    // ========================================================================
1502    // Particle System Tests (ATF-16)
1503    // ========================================================================
1504
1505    #[test]
1506    fn test_particle_parse_basic() {
1507        let json = r#"{
1508            "type": "particle",
1509            "name": "sparkle",
1510            "sprite": "spark",
1511            "emitter": {
1512                "rate": 5,
1513                "lifetime": [10, 20]
1514            }
1515        }"#;
1516        let obj: TtpObject = serde_json::from_str(json).unwrap();
1517        match obj {
1518            TtpObject::Particle(p) => {
1519                assert_eq!(p.name, "sparkle");
1520                assert_eq!(p.sprite, "spark");
1521                assert_eq!(p.emitter.rate, 5.0);
1522                assert_eq!(p.emitter.lifetime, [10, 20]);
1523            }
1524            _ => panic!("Expected particle"),
1525        }
1526    }
1527
1528    #[test]
1529    fn test_particle_parse_full() {
1530        let json = r#"{
1531            "type": "particle",
1532            "name": "rain",
1533            "sprite": "raindrop",
1534            "emitter": {
1535                "rate": 10,
1536                "lifetime": [30, 60],
1537                "velocity": {"x": [-1, 1], "y": [5, 8]},
1538                "gravity": 0.5,
1539                "fade": true,
1540                "rotation": [0, 360],
1541                "seed": 12345
1542            }
1543        }"#;
1544        let obj: TtpObject = serde_json::from_str(json).unwrap();
1545        match obj {
1546            TtpObject::Particle(p) => {
1547                assert_eq!(p.name, "rain");
1548                assert_eq!(p.sprite, "raindrop");
1549                assert_eq!(p.emitter.rate, 10.0);
1550                assert_eq!(p.emitter.lifetime, [30, 60]);
1551                let vel = p.emitter.velocity.unwrap();
1552                assert_eq!(vel.x, [-1.0, 1.0]);
1553                assert_eq!(vel.y, [5.0, 8.0]);
1554                assert_eq!(p.emitter.gravity, Some(0.5));
1555                assert_eq!(p.emitter.fade, Some(true));
1556                assert_eq!(p.emitter.rotation, Some([0.0, 360.0]));
1557                assert_eq!(p.emitter.seed, Some(12345));
1558            }
1559            _ => panic!("Expected particle"),
1560        }
1561    }
1562
1563    #[test]
1564    fn test_particle_roundtrip() {
1565        let particle = Particle {
1566            name: "dust".to_string(),
1567            sprite: "dust_mote".to_string(),
1568            emitter: ParticleEmitter {
1569                rate: 2.0,
1570                lifetime: [5, 15],
1571                velocity: Some(VelocityRange { x: [-2.0, 2.0], y: [-1.0, 0.0] }),
1572                gravity: Some(0.1),
1573                fade: Some(true),
1574                rotation: None,
1575                seed: Some(42),
1576            },
1577        };
1578        let obj = TtpObject::Particle(particle.clone());
1579        let json = serde_json::to_string(&obj).unwrap();
1580        assert!(json.contains(r#""type":"particle""#));
1581        let parsed: TtpObject = serde_json::from_str(&json).unwrap();
1582        match parsed {
1583            TtpObject::Particle(parsed_particle) => {
1584                assert_eq!(particle, parsed_particle);
1585            }
1586            _ => panic!("Expected particle"),
1587        }
1588    }
1589
1590    #[test]
1591    fn test_particle_emitter_defaults() {
1592        // Emitter with minimal fields should use defaults
1593        let json = r#"{
1594            "type": "particle",
1595            "name": "minimal",
1596            "sprite": "dot",
1597            "emitter": {}
1598        }"#;
1599        let obj: TtpObject = serde_json::from_str(json).unwrap();
1600        match obj {
1601            TtpObject::Particle(p) => {
1602                assert_eq!(p.emitter.rate, 1.0); // default
1603                assert_eq!(p.emitter.lifetime, [10, 20]); // default
1604                assert!(p.emitter.velocity.is_none());
1605                assert!(p.emitter.gravity.is_none());
1606                assert!(p.emitter.fade.is_none());
1607                assert!(p.emitter.rotation.is_none());
1608                assert!(p.emitter.seed.is_none());
1609            }
1610            _ => panic!("Expected particle"),
1611        }
1612    }
1613
1614    // ========== Hit/Hurt Boxes Tests (ATF-7) ==========
1615
1616    #[test]
1617    fn test_collision_box_roundtrip() {
1618        let box_data = CollisionBox { x: 4, y: 0, w: 24, h: 32 };
1619        let json = serde_json::to_string(&box_data).unwrap();
1620        let parsed: CollisionBox = serde_json::from_str(&json).unwrap();
1621        assert_eq!(box_data, parsed);
1622    }
1623
1624    #[test]
1625    fn test_sprite_metadata_roundtrip() {
1626        let metadata = SpriteMetadata {
1627            origin: Some([16, 32]),
1628            boxes: Some(HashMap::from([
1629                ("hurt".to_string(), CollisionBox { x: 4, y: 0, w: 24, h: 32 }),
1630                ("hit".to_string(), CollisionBox { x: 20, y: 8, w: 20, h: 16 }),
1631            ])),
1632            attach_in: None,
1633            attach_out: None,
1634        };
1635        let json = serde_json::to_string(&metadata).unwrap();
1636        let parsed: SpriteMetadata = serde_json::from_str(&json).unwrap();
1637        assert_eq!(metadata, parsed);
1638    }
1639
1640    #[test]
1641    fn test_sprite_with_metadata_parse() {
1642        // Sprite with metadata as specified in ATF-7
1643        let json = r#"{
1644            "type": "sprite",
1645            "name": "player_attack",
1646            "palette": "characters",
1647            "grid": ["{x}"],
1648            "metadata": {
1649                "origin": [16, 32],
1650                "boxes": {
1651                    "hurt": {"x": 4, "y": 0, "w": 24, "h": 32},
1652                    "hit": {"x": 20, "y": 8, "w": 20, "h": 16}
1653                }
1654            }
1655        }"#;
1656        let obj: TtpObject = serde_json::from_str(json).unwrap();
1657        match obj {
1658            TtpObject::Sprite(sprite) => {
1659                assert_eq!(sprite.name, "player_attack");
1660                assert!(sprite.metadata.is_some());
1661                let meta = sprite.metadata.unwrap();
1662                assert_eq!(meta.origin, Some([16, 32]));
1663                assert!(meta.boxes.is_some());
1664                let boxes = meta.boxes.unwrap();
1665                assert_eq!(boxes.len(), 2);
1666                assert!(boxes.contains_key("hurt"));
1667                assert!(boxes.contains_key("hit"));
1668                let hurt_box = &boxes["hurt"];
1669                assert_eq!(hurt_box.x, 4);
1670                assert_eq!(hurt_box.y, 0);
1671                assert_eq!(hurt_box.w, 24);
1672                assert_eq!(hurt_box.h, 32);
1673            }
1674            _ => panic!("Expected sprite"),
1675        }
1676    }
1677
1678    #[test]
1679    fn test_velocity_range_default() {
1680        let vel = VelocityRange::default();
1681        assert_eq!(vel.x, [0.0, 0.0]);
1682        assert_eq!(vel.y, [0.0, 0.0]);
1683    }
1684
1685    #[test]
1686    fn test_particle_emitter_default() {
1687        let emitter = ParticleEmitter::default();
1688        assert_eq!(emitter.rate, 1.0);
1689        assert_eq!(emitter.lifetime, [10, 20]);
1690        assert!(emitter.velocity.is_none());
1691        assert!(emitter.gravity.is_none());
1692        assert!(emitter.fade.is_none());
1693        assert!(emitter.rotation.is_none());
1694        assert!(emitter.seed.is_none());
1695    }
1696
1697    #[test]
1698    fn test_sprite_metadata_origin_only() {
1699        // Sprite with only origin, no boxes
1700        let json = r#"{
1701            "type": "sprite",
1702            "name": "centered_sprite",
1703            "palette": "default",
1704            "grid": ["{x}"],
1705            "metadata": {
1706                "origin": [8, 16]
1707            }
1708        }"#;
1709        let obj: TtpObject = serde_json::from_str(json).unwrap();
1710        match obj {
1711            TtpObject::Sprite(sprite) => {
1712                let meta = sprite.metadata.unwrap();
1713                assert_eq!(meta.origin, Some([8, 16]));
1714                assert!(meta.boxes.is_none());
1715            }
1716            _ => panic!("Expected sprite"),
1717        }
1718    }
1719
1720    #[test]
1721    fn test_sprite_metadata_boxes_only() {
1722        // Sprite with only boxes, no origin
1723        let json = r#"{
1724            "type": "sprite",
1725            "name": "collider",
1726            "palette": "default",
1727            "grid": ["{x}"],
1728            "metadata": {
1729                "boxes": {
1730                    "collide": {"x": 0, "y": 0, "w": 16, "h": 16}
1731                }
1732            }
1733        }"#;
1734        let obj: TtpObject = serde_json::from_str(json).unwrap();
1735        match obj {
1736            TtpObject::Sprite(sprite) => {
1737                let meta = sprite.metadata.unwrap();
1738                assert!(meta.origin.is_none());
1739                assert!(meta.boxes.is_some());
1740                assert!(meta.boxes.unwrap().contains_key("collide"));
1741            }
1742            _ => panic!("Expected sprite"),
1743        }
1744    }
1745
1746    #[test]
1747    fn test_animation_frame_metadata_parse() {
1748        // Animation with per-frame metadata as specified in ATF-7
1749        let json = r#"{
1750            "type": "animation",
1751            "name": "attack",
1752            "frames": ["f1", "f2", "f3"],
1753            "frame_metadata": [
1754                {"boxes": {"hit": null}},
1755                {"boxes": {"hit": {"x": 20, "y": 8, "w": 20, "h": 16}}},
1756                {"boxes": {"hit": {"x": 24, "y": 4, "w": 24, "h": 20}}}
1757            ]
1758        }"#;
1759        let obj: TtpObject = serde_json::from_str(json).unwrap();
1760        match obj {
1761            TtpObject::Animation(anim) => {
1762                assert_eq!(anim.name, "attack");
1763                assert_eq!(anim.frames.len(), 3);
1764                assert!(anim.frame_metadata.is_some());
1765                let frame_meta = anim.frame_metadata.unwrap();
1766                assert_eq!(frame_meta.len(), 3);
1767
1768                // Frame 0: hit box is null (disabled)
1769                let f0_boxes = frame_meta[0].boxes.as_ref().unwrap();
1770                assert!(f0_boxes.get("hit").unwrap().is_none());
1771
1772                // Frame 1: hit box is active
1773                let f1_boxes = frame_meta[1].boxes.as_ref().unwrap();
1774                let f1_hit = f1_boxes.get("hit").unwrap().as_ref().unwrap();
1775                assert_eq!(f1_hit.x, 20);
1776                assert_eq!(f1_hit.y, 8);
1777
1778                // Frame 2: hit box is active with different values
1779                let f2_boxes = frame_meta[2].boxes.as_ref().unwrap();
1780                let f2_hit = f2_boxes.get("hit").unwrap().as_ref().unwrap();
1781                assert_eq!(f2_hit.x, 24);
1782                assert_eq!(f2_hit.w, 24);
1783            }
1784            _ => panic!("Expected animation"),
1785        }
1786    }
1787
1788    #[test]
1789    fn test_sprite_without_metadata_roundtrip() {
1790        // Sprite without metadata should serialize without metadata field
1791        let sprite = Sprite {
1792            name: "simple".to_string(),
1793            size: None,
1794            palette: PaletteRef::Named("default".to_string()),
1795            grid: vec!["{x}".to_string()],
1796            metadata: None,
1797            ..Default::default()
1798        };
1799        let json = serde_json::to_string(&sprite).unwrap();
1800        // Should not contain "metadata" key when None
1801        assert!(!json.contains("metadata"));
1802        let parsed: Sprite = serde_json::from_str(&json).unwrap();
1803        assert_eq!(sprite, parsed);
1804    }
1805
1806    #[test]
1807    fn test_animation_without_frame_metadata_roundtrip() {
1808        // Animation without frame_metadata should serialize without the field
1809        let anim = Animation {
1810            name: "simple".to_string(),
1811            frames: vec!["f1".to_string()],
1812            duration: None,
1813            r#loop: None,
1814            palette_cycle: None,
1815            tags: None,
1816            frame_metadata: None,
1817            attachments: None,
1818            ..Default::default()
1819        };
1820        let json = serde_json::to_string(&anim).unwrap();
1821        // Should not contain "frame_metadata" key when None
1822        assert!(!json.contains("frame_metadata"));
1823        let parsed: Animation = serde_json::from_str(&json).unwrap();
1824        assert_eq!(anim, parsed);
1825    }
1826
1827    #[test]
1828    fn test_collision_box_negative_coordinates() {
1829        // Collision boxes can have negative x,y for positions relative to origin
1830        let box_data = CollisionBox { x: -8, y: -16, w: 16, h: 32 };
1831        let json = serde_json::to_string(&box_data).unwrap();
1832        let parsed: CollisionBox = serde_json::from_str(&json).unwrap();
1833        assert_eq!(box_data, parsed);
1834        assert_eq!(parsed.x, -8);
1835        assert_eq!(parsed.y, -16);
1836    }
1837
1838    // ========== Secondary Motion Tests (ATF-14) ==========
1839
1840    #[test]
1841    fn test_follow_mode_parse() {
1842        // Test parsing of follow modes
1843        assert_eq!(
1844            serde_json::from_str::<FollowMode>(r#""position""#).unwrap(),
1845            FollowMode::Position
1846        );
1847        assert_eq!(
1848            serde_json::from_str::<FollowMode>(r#""velocity""#).unwrap(),
1849            FollowMode::Velocity
1850        );
1851        assert_eq!(
1852            serde_json::from_str::<FollowMode>(r#""rotation""#).unwrap(),
1853            FollowMode::Rotation
1854        );
1855    }
1856
1857    #[test]
1858    fn test_follow_mode_default() {
1859        // Default should be Position
1860        assert_eq!(FollowMode::default(), FollowMode::Position);
1861    }
1862
1863    #[test]
1864    fn test_attachment_keyframe_roundtrip() {
1865        let keyframe = AttachmentKeyframe { offset: [5, -3] };
1866        let json = serde_json::to_string(&keyframe).unwrap();
1867        let parsed: AttachmentKeyframe = serde_json::from_str(&json).unwrap();
1868        assert_eq!(keyframe, parsed);
1869    }
1870
1871    #[test]
1872    fn test_attachment_basic_roundtrip() {
1873        let attachment = Attachment {
1874            name: "hair".to_string(),
1875            anchor: [12, 4],
1876            chain: vec!["hair_1".to_string(), "hair_2".to_string()],
1877            delay: None,
1878            follow: None,
1879            damping: None,
1880            stiffness: None,
1881            z_index: None,
1882            keyframes: None,
1883        };
1884        let json = serde_json::to_string(&attachment).unwrap();
1885        let parsed: Attachment = serde_json::from_str(&json).unwrap();
1886        assert_eq!(attachment, parsed);
1887    }
1888
1889    #[test]
1890    fn test_attachment_with_all_fields() {
1891        let attachment = Attachment {
1892            name: "cape".to_string(),
1893            anchor: [8, 8],
1894            chain: vec!["cape_top".to_string(), "cape_mid".to_string(), "cape_bottom".to_string()],
1895            delay: Some(2),
1896            follow: Some(FollowMode::Velocity),
1897            damping: Some(0.7),
1898            stiffness: Some(0.4),
1899            z_index: Some(-1),
1900            keyframes: None,
1901        };
1902        let json = serde_json::to_string(&attachment).unwrap();
1903        let parsed: Attachment = serde_json::from_str(&json).unwrap();
1904        assert_eq!(attachment, parsed);
1905    }
1906
1907    #[test]
1908    fn test_attachment_with_keyframes() {
1909        let mut keyframes = HashMap::new();
1910        keyframes.insert("0".to_string(), AttachmentKeyframe { offset: [0, 0] });
1911        keyframes.insert("1".to_string(), AttachmentKeyframe { offset: [2, 1] });
1912        keyframes.insert("2".to_string(), AttachmentKeyframe { offset: [3, 2] });
1913
1914        let attachment = Attachment {
1915            name: "hair".to_string(),
1916            anchor: [12, 4],
1917            chain: vec!["hair_1".to_string()],
1918            delay: None,
1919            follow: None,
1920            damping: None,
1921            stiffness: None,
1922            z_index: None,
1923            keyframes: Some(keyframes),
1924        };
1925
1926        let json = serde_json::to_string(&attachment).unwrap();
1927        let parsed: Attachment = serde_json::from_str(&json).unwrap();
1928        assert_eq!(attachment, parsed);
1929        assert!(parsed.is_keyframed());
1930    }
1931
1932    #[test]
1933    fn test_attachment_helper_methods() {
1934        // Test default values
1935        let attachment = Attachment {
1936            name: "test".to_string(),
1937            anchor: [0, 0],
1938            chain: vec!["sprite".to_string()],
1939            delay: None,
1940            follow: None,
1941            damping: None,
1942            stiffness: None,
1943            z_index: None,
1944            keyframes: None,
1945        };
1946
1947        assert_eq!(attachment.delay(), 1); // DEFAULT_DELAY
1948        assert_eq!(attachment.follow_mode(), FollowMode::Position);
1949        assert!((attachment.damping() - 0.8).abs() < 0.001); // DEFAULT_DAMPING
1950        assert!((attachment.stiffness() - 0.5).abs() < 0.001); // DEFAULT_STIFFNESS
1951        assert_eq!(attachment.z_index(), 0);
1952        assert!(!attachment.is_keyframed());
1953
1954        // Test with custom values
1955        let attachment_custom = Attachment {
1956            name: "custom".to_string(),
1957            anchor: [0, 0],
1958            chain: vec!["sprite".to_string()],
1959            delay: Some(3),
1960            follow: Some(FollowMode::Velocity),
1961            damping: Some(0.5),
1962            stiffness: Some(0.9),
1963            z_index: Some(-2),
1964            keyframes: None,
1965        };
1966
1967        assert_eq!(attachment_custom.delay(), 3);
1968        assert_eq!(attachment_custom.follow_mode(), FollowMode::Velocity);
1969        assert!((attachment_custom.damping() - 0.5).abs() < 0.001);
1970        assert!((attachment_custom.stiffness() - 0.9).abs() < 0.001);
1971        assert_eq!(attachment_custom.z_index(), -2);
1972    }
1973
1974    #[test]
1975    fn test_animation_with_attachments_parse() {
1976        // Animation with attachments as specified in ATF-14
1977        let json = r#"{
1978            "type": "animation",
1979            "name": "hero_walk",
1980            "frames": ["walk_1", "walk_2", "walk_3", "walk_4"],
1981            "duration": 100,
1982            "attachments": [
1983                {
1984                    "name": "hair",
1985                    "anchor": [12, 4],
1986                    "chain": ["hair_1", "hair_2", "hair_3"],
1987                    "delay": 1,
1988                    "follow": "position"
1989                },
1990                {
1991                    "name": "cape",
1992                    "anchor": [8, 8],
1993                    "chain": ["cape_top", "cape_mid", "cape_bottom"],
1994                    "delay": 2,
1995                    "follow": "velocity",
1996                    "z_index": -1
1997                }
1998            ]
1999        }"#;
2000        let obj: TtpObject = serde_json::from_str(json).unwrap();
2001        match obj {
2002            TtpObject::Animation(anim) => {
2003                assert_eq!(anim.name, "hero_walk");
2004                assert!(anim.attachments.is_some());
2005                let attachments = anim.attachments.unwrap();
2006                assert_eq!(attachments.len(), 2);
2007
2008                // Hair attachment
2009                let hair = &attachments[0];
2010                assert_eq!(hair.name, "hair");
2011                assert_eq!(hair.anchor, [12, 4]);
2012                assert_eq!(hair.chain.len(), 3);
2013                assert_eq!(hair.delay(), 1);
2014                assert_eq!(hair.follow_mode(), FollowMode::Position);
2015
2016                // Cape attachment
2017                let cape = &attachments[1];
2018                assert_eq!(cape.name, "cape");
2019                assert_eq!(cape.anchor, [8, 8]);
2020                assert_eq!(cape.chain.len(), 3);
2021                assert_eq!(cape.delay(), 2);
2022                assert_eq!(cape.follow_mode(), FollowMode::Velocity);
2023                assert_eq!(cape.z_index(), -1);
2024            }
2025            _ => panic!("Expected animation"),
2026        }
2027    }
2028
2029    #[test]
2030    fn test_animation_attachments_roundtrip() {
2031        let anim = Animation {
2032            name: "test_anim".to_string(),
2033            frames: vec!["f1".to_string(), "f2".to_string()],
2034            duration: Some(Duration::Milliseconds(100)),
2035            r#loop: Some(true),
2036            palette_cycle: None,
2037            tags: None,
2038            frame_metadata: None,
2039            attachments: Some(vec![Attachment {
2040                name: "tail".to_string(),
2041                anchor: [4, 8],
2042                chain: vec!["tail_1".to_string(), "tail_2".to_string()],
2043                delay: Some(1),
2044                follow: Some(FollowMode::Position),
2045                damping: Some(0.8),
2046                stiffness: Some(0.5),
2047                z_index: Some(1),
2048                keyframes: None,
2049            }]),
2050            ..Default::default()
2051        };
2052        let obj = TtpObject::Animation(anim.clone());
2053        let json = serde_json::to_string(&obj).unwrap();
2054        assert!(json.contains("attachments"));
2055        let parsed: TtpObject = serde_json::from_str(&json).unwrap();
2056        match parsed {
2057            TtpObject::Animation(parsed_anim) => {
2058                assert_eq!(anim, parsed_anim);
2059            }
2060            _ => panic!("Expected animation"),
2061        }
2062    }
2063
2064    #[test]
2065    fn test_animation_without_attachments_roundtrip() {
2066        // Animation without attachments should serialize without the field
2067        let anim = Animation {
2068            name: "simple".to_string(),
2069            frames: vec!["f1".to_string()],
2070            duration: None,
2071            r#loop: None,
2072            palette_cycle: None,
2073            tags: None,
2074            frame_metadata: None,
2075            attachments: None,
2076            ..Default::default()
2077        };
2078        let json = serde_json::to_string(&anim).unwrap();
2079        // Should not contain "attachments" key when None
2080        assert!(!json.contains("attachments"));
2081        let parsed: Animation = serde_json::from_str(&json).unwrap();
2082        assert_eq!(anim, parsed);
2083    }
2084
2085    #[test]
2086    fn test_sprite_metadata_with_attach_points() {
2087        // Chain sprite with attachment points as specified in ATF-14
2088        let json = r#"{
2089            "type": "sprite",
2090            "name": "hair_2",
2091            "palette": "character",
2092            "grid": ["{x}"],
2093            "metadata": {
2094                "attach_in": [4, 0],
2095                "attach_out": [4, 8]
2096            }
2097        }"#;
2098        let obj: TtpObject = serde_json::from_str(json).unwrap();
2099        match obj {
2100            TtpObject::Sprite(sprite) => {
2101                assert_eq!(sprite.name, "hair_2");
2102                assert!(sprite.metadata.is_some());
2103                let meta = sprite.metadata.unwrap();
2104                assert_eq!(meta.attach_in, Some([4, 0]));
2105                assert_eq!(meta.attach_out, Some([4, 8]));
2106            }
2107            _ => panic!("Expected sprite"),
2108        }
2109    }
2110
2111    #[test]
2112    fn test_sprite_metadata_attach_points_roundtrip() {
2113        let metadata = SpriteMetadata {
2114            origin: Some([8, 8]),
2115            boxes: None,
2116            attach_in: Some([4, 0]),
2117            attach_out: Some([4, 8]),
2118        };
2119        let json = serde_json::to_string(&metadata).unwrap();
2120        let parsed: SpriteMetadata = serde_json::from_str(&json).unwrap();
2121        assert_eq!(metadata, parsed);
2122    }
2123
2124    #[test]
2125    fn test_attachment_keyframed_parse() {
2126        // Attachment with explicit keyframes
2127        let json = r#"{
2128            "name": "hair",
2129            "anchor": [12, 4],
2130            "chain": ["hair_1", "hair_2"],
2131            "keyframes": {
2132                "0": {"offset": [0, 0]},
2133                "1": {"offset": [2, 1]},
2134                "2": {"offset": [3, 2]},
2135                "3": {"offset": [1, 1]}
2136            }
2137        }"#;
2138        let attachment: Attachment = serde_json::from_str(json).unwrap();
2139        assert_eq!(attachment.name, "hair");
2140        assert!(attachment.is_keyframed());
2141        let keyframes = attachment.keyframes.unwrap();
2142        assert_eq!(keyframes.len(), 4);
2143        assert_eq!(keyframes.get("0").unwrap().offset, [0, 0]);
2144        assert_eq!(keyframes.get("2").unwrap().offset, [3, 2]);
2145    }
2146
2147    // ========================================================================
2148    // Duration Tests (CSS-13)
2149    // ========================================================================
2150
2151    #[test]
2152    fn test_duration_milliseconds_parse() {
2153        let dur: Duration = serde_json::from_str("100").unwrap();
2154        assert_eq!(dur, Duration::Milliseconds(100));
2155        assert_eq!(dur.as_milliseconds(), Some(100));
2156    }
2157
2158    #[test]
2159    fn test_duration_css_string_ms() {
2160        let dur: Duration = serde_json::from_str(r#""500ms""#).unwrap();
2161        assert!(matches!(dur, Duration::CssString(_)));
2162        assert_eq!(dur.as_milliseconds(), Some(500));
2163    }
2164
2165    #[test]
2166    fn test_duration_css_string_seconds() {
2167        let dur: Duration = serde_json::from_str(r#""1.5s""#).unwrap();
2168        assert!(matches!(dur, Duration::CssString(_)));
2169        assert_eq!(dur.as_milliseconds(), Some(1500));
2170    }
2171
2172    #[test]
2173    fn test_duration_display() {
2174        assert_eq!(format!("{}", Duration::Milliseconds(100)), "100");
2175        assert_eq!(format!("{}", Duration::CssString("500ms".to_string())), "\"500ms\"");
2176    }
2177
2178    #[test]
2179    fn test_duration_default() {
2180        let dur = Duration::default();
2181        assert_eq!(dur, Duration::Milliseconds(100));
2182    }
2183
2184    #[test]
2185    fn test_duration_from_u32() {
2186        let dur: Duration = 250u32.into();
2187        assert_eq!(dur, Duration::Milliseconds(250));
2188    }
2189
2190    #[test]
2191    fn test_duration_from_str() {
2192        let dur: Duration = "1s".into();
2193        assert_eq!(dur, Duration::CssString("1s".to_string()));
2194        assert_eq!(dur.as_milliseconds(), Some(1000));
2195    }
2196
2197    // ========================================================================
2198    // CSS Keyframe Tests (CSS-13)
2199    // ========================================================================
2200
2201    #[test]
2202    fn test_css_keyframe_parse_basic() {
2203        let kf: CssKeyframe = serde_json::from_str(r#"{"sprite": "walk_1"}"#).unwrap();
2204        assert_eq!(kf.sprite, Some("walk_1".to_string()));
2205        assert!(kf.transform.is_none());
2206        assert!(kf.opacity.is_none());
2207        assert!(kf.offset.is_none());
2208    }
2209
2210    #[test]
2211    fn test_css_keyframe_parse_full() {
2212        let json = r#"{
2213            "sprite": "walk_1",
2214            "transform": "rotate(45deg) scale(2)",
2215            "opacity": 0.5,
2216            "offset": [10, -5]
2217        }"#;
2218        let kf: CssKeyframe = serde_json::from_str(json).unwrap();
2219        assert_eq!(kf.sprite, Some("walk_1".to_string()));
2220        assert_eq!(kf.transform, Some("rotate(45deg) scale(2)".to_string()));
2221        assert_eq!(kf.opacity, Some(0.5));
2222        assert_eq!(kf.offset, Some([10, -5]));
2223    }
2224
2225    #[test]
2226    fn test_css_keyframe_roundtrip() {
2227        let kf = CssKeyframe {
2228            sprite: Some("test".to_string()),
2229            transform: Some("scale(2)".to_string()),
2230            opacity: Some(0.8),
2231            offset: Some([5, 10]),
2232        };
2233        let json = serde_json::to_string(&kf).unwrap();
2234        let parsed: CssKeyframe = serde_json::from_str(&json).unwrap();
2235        assert_eq!(kf, parsed);
2236    }
2237
2238    // ========================================================================
2239    // Animation CSS Keyframes Tests (CSS-13)
2240    // ========================================================================
2241
2242    #[test]
2243    fn test_animation_css_keyframes_parse() {
2244        let json = r#"{
2245            "type": "animation",
2246            "name": "fade_walk",
2247            "keyframes": {
2248                "0%": {"sprite": "walk_1", "opacity": 0.0},
2249                "50%": {"sprite": "walk_2", "opacity": 1.0},
2250                "100%": {"sprite": "walk_1", "opacity": 0.0}
2251            },
2252            "duration": "500ms",
2253            "timing_function": "ease-in-out"
2254        }"#;
2255        let obj: TtpObject = serde_json::from_str(json).unwrap();
2256        match obj {
2257            TtpObject::Animation(anim) => {
2258                assert_eq!(anim.name, "fade_walk");
2259                assert!(anim.is_css_keyframes());
2260                assert!(!anim.is_frame_based());
2261
2262                let keyframes = anim.css_keyframes().unwrap();
2263                assert_eq!(keyframes.len(), 3);
2264
2265                let kf_0 = keyframes.get("0%").unwrap();
2266                assert_eq!(kf_0.sprite, Some("walk_1".to_string()));
2267                assert_eq!(kf_0.opacity, Some(0.0));
2268
2269                let kf_50 = keyframes.get("50%").unwrap();
2270                assert_eq!(kf_50.sprite, Some("walk_2".to_string()));
2271                assert_eq!(kf_50.opacity, Some(1.0));
2272
2273                assert_eq!(anim.duration_ms(), 500);
2274                assert_eq!(anim.timing_function, Some("ease-in-out".to_string()));
2275            }
2276            _ => panic!("Expected animation"),
2277        }
2278    }
2279
2280    #[test]
2281    fn test_animation_css_keyframes_from_to_aliases() {
2282        let json = r#"{
2283            "type": "animation",
2284            "name": "fade",
2285            "keyframes": {
2286                "from": {"opacity": 0.0},
2287                "to": {"opacity": 1.0}
2288            },
2289            "duration": "1s"
2290        }"#;
2291        let obj: TtpObject = serde_json::from_str(json).unwrap();
2292        match obj {
2293            TtpObject::Animation(anim) => {
2294                assert!(anim.is_css_keyframes());
2295                let keyframes = anim.css_keyframes().unwrap();
2296                assert!(keyframes.contains_key("from"));
2297                assert!(keyframes.contains_key("to"));
2298                assert_eq!(anim.duration_ms(), 1000);
2299            }
2300            _ => panic!("Expected animation"),
2301        }
2302    }
2303
2304    #[test]
2305    fn test_animation_parse_keyframe_percent() {
2306        // Percentage strings
2307        assert_eq!(Animation::parse_keyframe_percent("0%"), Some(0.0));
2308        assert_eq!(Animation::parse_keyframe_percent("50%"), Some(0.5));
2309        assert_eq!(Animation::parse_keyframe_percent("100%"), Some(1.0));
2310        assert_eq!(Animation::parse_keyframe_percent("25%"), Some(0.25));
2311
2312        // Aliases
2313        assert_eq!(Animation::parse_keyframe_percent("from"), Some(0.0));
2314        assert_eq!(Animation::parse_keyframe_percent("to"), Some(1.0));
2315
2316        // Case insensitive
2317        assert_eq!(Animation::parse_keyframe_percent("FROM"), Some(0.0));
2318        assert_eq!(Animation::parse_keyframe_percent("TO"), Some(1.0));
2319
2320        // Invalid
2321        assert_eq!(Animation::parse_keyframe_percent("invalid"), None);
2322        assert_eq!(Animation::parse_keyframe_percent("50"), None);
2323    }
2324
2325    #[test]
2326    fn test_animation_sorted_keyframes() {
2327        let anim = Animation {
2328            name: "test".to_string(),
2329            keyframes: Some(HashMap::from([
2330                (
2331                    "100%".to_string(),
2332                    CssKeyframe { sprite: Some("c".to_string()), ..Default::default() },
2333                ),
2334                (
2335                    "0%".to_string(),
2336                    CssKeyframe { sprite: Some("a".to_string()), ..Default::default() },
2337                ),
2338                (
2339                    "50%".to_string(),
2340                    CssKeyframe { sprite: Some("b".to_string()), ..Default::default() },
2341                ),
2342            ])),
2343            ..Default::default()
2344        };
2345
2346        let sorted = anim.sorted_keyframes();
2347        assert_eq!(sorted.len(), 3);
2348        assert_eq!(sorted[0].0, 0.0);
2349        assert_eq!(sorted[0].1.sprite, Some("a".to_string()));
2350        assert_eq!(sorted[1].0, 0.5);
2351        assert_eq!(sorted[1].1.sprite, Some("b".to_string()));
2352        assert_eq!(sorted[2].0, 1.0);
2353        assert_eq!(sorted[2].1.sprite, Some("c".to_string()));
2354    }
2355
2356    #[test]
2357    fn test_animation_css_keyframes_with_transforms() {
2358        let json = r#"{
2359            "type": "animation",
2360            "name": "spin",
2361            "keyframes": {
2362                "0%": {"sprite": "star", "transform": "rotate(0deg)"},
2363                "100%": {"sprite": "star", "transform": "rotate(360deg)"}
2364            },
2365            "duration": 1000,
2366            "timing_function": "linear"
2367        }"#;
2368        let obj: TtpObject = serde_json::from_str(json).unwrap();
2369        match obj {
2370            TtpObject::Animation(anim) => {
2371                let keyframes = anim.css_keyframes().unwrap();
2372                assert_eq!(
2373                    keyframes.get("0%").unwrap().transform,
2374                    Some("rotate(0deg)".to_string())
2375                );
2376                assert_eq!(
2377                    keyframes.get("100%").unwrap().transform,
2378                    Some("rotate(360deg)".to_string())
2379                );
2380                assert_eq!(anim.timing_function, Some("linear".to_string()));
2381            }
2382            _ => panic!("Expected animation"),
2383        }
2384    }
2385
2386    #[test]
2387    fn test_animation_frame_vs_keyframe() {
2388        // Frame-based animation
2389        let frame_anim = Animation {
2390            name: "frames".to_string(),
2391            frames: vec!["f1".to_string(), "f2".to_string()],
2392            ..Default::default()
2393        };
2394        assert!(frame_anim.is_frame_based());
2395        assert!(!frame_anim.is_css_keyframes());
2396
2397        // CSS keyframe animation
2398        let keyframe_anim = Animation {
2399            name: "keyframes".to_string(),
2400            keyframes: Some(HashMap::from([
2401                ("0%".to_string(), CssKeyframe::default()),
2402                ("100%".to_string(), CssKeyframe::default()),
2403            ])),
2404            ..Default::default()
2405        };
2406        assert!(!keyframe_anim.is_frame_based());
2407        assert!(keyframe_anim.is_css_keyframes());
2408    }
2409
2410    #[test]
2411    fn test_animation_css_keyframes_roundtrip() {
2412        let anim = Animation {
2413            name: "test_kf".to_string(),
2414            keyframes: Some(HashMap::from([
2415                (
2416                    "0%".to_string(),
2417                    CssKeyframe {
2418                        sprite: Some("start".to_string()),
2419                        opacity: Some(0.0),
2420                        ..Default::default()
2421                    },
2422                ),
2423                (
2424                    "100%".to_string(),
2425                    CssKeyframe {
2426                        sprite: Some("end".to_string()),
2427                        opacity: Some(1.0),
2428                        ..Default::default()
2429                    },
2430                ),
2431            ])),
2432            duration: Some(Duration::CssString("500ms".to_string())),
2433            timing_function: Some("ease".to_string()),
2434            ..Default::default()
2435        };
2436
2437        let obj = TtpObject::Animation(anim.clone());
2438        let json = serde_json::to_string(&obj).unwrap();
2439        assert!(json.contains("keyframes"));
2440        assert!(json.contains("timing_function"));
2441
2442        let parsed: TtpObject = serde_json::from_str(&json).unwrap();
2443        match parsed {
2444            TtpObject::Animation(parsed_anim) => {
2445                assert_eq!(anim.name, parsed_anim.name);
2446                assert_eq!(anim.timing_function, parsed_anim.timing_function);
2447                assert!(parsed_anim.is_css_keyframes());
2448            }
2449            _ => panic!("Expected animation"),
2450        }
2451    }
2452}