Skip to main content

yog_formats/
texture.rs

1//! `.ytexture` — a texture: how one of the mod's pictures works in the game.
2//! The picture itself is a `.yres` image (`image: @Images.block.ruby`) the
3//! IDE shows as it is; this says how the build turns it into what the game
4//! draws — which atlas it's packed into, how it animates and filters. Its
5//! id is its path under `resources/` without the extension
6//! (`resources/block/ruby.ytexture` is `<mod id>:block/ruby`), named
7//! elsewhere as `@texture:<mod id>:block/ruby`. Yog Data text, like every
8//! source format; the build compiles it.
9
10mod consts;
11use consts as mcmeta;
12
13use yog_data::{Res, Schema};
14
15/// A `.ytexture`.
16#[derive(Schema, Debug, Clone, PartialEq)]
17pub struct TextureFile {
18    /// The picture: a `.yres` image.
19    pub image: Res,
20    /// The atlas it's packed into (`minecraft:blocks`, `minecraft:particles`,
21    /// … — see `yog_registry::Atlas`); none: bound on its own (an entity's,
22    /// armor's).
23    pub atlas: Option<String>,
24    /// Frames stacked top to bottom, played in turn.
25    pub animation: Option<Animation>,
26    /// Smoothed when scaled, not kept pixelated.
27    #[data(default)]
28    pub blur: bool,
29    /// Its edges clamped, not repeated.
30    #[data(default)]
31    pub clamp: bool,
32}
33
34/// How an animated texture plays.
35#[derive(Schema, Debug, Clone, PartialEq, Default)]
36pub struct Animation {
37    /// Ticks each frame shows (1 if not given).
38    pub frame_time: Option<u32>,
39    /// Blends each frame into the next.
40    #[data(default)]
41    pub interpolate: bool,
42    /// The frames' order and times, when not each once, top to bottom.
43    #[data(default)]
44    pub frames: Vec<Frame>,
45    /// A frame's size, when frames aren't square (the picture's width wide).
46    pub width: Option<u32>,
47    pub height: Option<u32>,
48}
49
50/// One step of an animation: a frame (from 0, top to bottom), for its own time.
51#[derive(Schema, Debug, Clone, PartialEq, Default)]
52pub struct Frame {
53    pub index: u32,
54    pub time: Option<u32>,
55}
56
57impl Animation {
58    /// Ticks a frame shows when nothing says otherwise.
59    pub const DEFAULT_FRAME_TIME: u32 = 1;
60
61    /// Ticks each frame shows.
62    #[must_use]
63    pub fn frame_time(&self) -> u32 {
64        self.frame_time.unwrap_or(Self::DEFAULT_FRAME_TIME)
65    }
66}
67
68/// A texture's id from its file's path under `resources/` (`block/ruby.ytexture`) and the mod's id.
69#[must_use]
70pub fn id_of(mod_id: &str, rel: &str) -> Option<String> {
71    let path = rel
72        .strip_suffix(yog_ids::Format::Texture.ext())?
73        .strip_suffix('.')?;
74    Some(yog_ids::join(mod_id, path))
75}
76
77use serde_json::{Value, json};
78
79impl TextureFile {
80    /// The game's `.mcmeta` of the texture, when it animates or filters.
81    #[must_use]
82    pub fn game_mcmeta(&self) -> Option<Value> {
83        let t = self;
84        let mut meta = serde_json::Map::new();
85        if let Some(a) = &t.animation {
86            meta.insert(mcmeta::ANIMATION.into(), animation(a));
87        }
88        if t.blur || t.clamp {
89            meta.insert(
90                mcmeta::TEXTURE.into(),
91                json!({ "blur": t.blur, "clamp": t.clamp }),
92            );
93        }
94        (!meta.is_empty()).then_some(Value::Object(meta))
95    }
96}
97
98/// An animation the game's way.
99fn animation(a: &Animation) -> Value {
100    let mut anim = serde_json::Map::new();
101    anim.insert("frametime".into(), json!(a.frame_time()));
102    if a.interpolate {
103        anim.insert("interpolate".into(), json!(true));
104    }
105    if !a.frames.is_empty() {
106        let frames = a.frames.iter().map(|f| match f.time {
107            Some(time) => json!({ "index": f.index, "time": time }),
108            None => json!(f.index),
109        });
110        anim.insert("frames".into(), Value::Array(frames.collect()));
111    }
112    if let Some(w) = a.width {
113        anim.insert("width".into(), json!(w));
114    }
115    if let Some(h) = a.height {
116        anim.insert("height".into(), json!(h));
117    }
118    Value::Object(anim)
119}
120
121impl Animation {
122    /// The animation a game `.mcmeta` (its JSON) describes.
123    #[must_use]
124    pub fn from_mcmeta(mcmeta: &Value) -> Option<Animation> {
125        let a = mcmeta.get(mcmeta::ANIMATION)?;
126        let u32_of = |v: &Value, k: &str| {
127            v.get(k)
128                .and_then(Value::as_u64)
129                .and_then(|n| u32::try_from(n).ok())
130        };
131        Some(Animation {
132            frame_time: u32_of(a, "frametime"),
133            interpolate: a
134                .get("interpolate")
135                .and_then(Value::as_bool)
136                .unwrap_or(false),
137            frames: a
138                .get("frames")
139                .and_then(Value::as_array)
140                .map(|fs| {
141                    fs.iter()
142                        .filter_map(|f| match f.as_u64() {
143                            Some(i) => Some(Frame {
144                                index: u32::try_from(i).ok()?,
145                                time: None,
146                            }),
147                            None => Some(Frame {
148                                index: u32_of(f, "index")?,
149                                time: u32_of(f, "time"),
150                            }),
151                        })
152                        .collect()
153                })
154                .unwrap_or_default(),
155            width: u32_of(a, "width"),
156            height: u32_of(a, "height"),
157        })
158    }
159}
160
161/// The `.yres` set a picture at `path` (`block/ruby`) goes into when the
162/// IDE or an import adds it: by its first folder (`BlockImages`), the GUI's
163/// in `Images`.
164#[must_use]
165pub fn picture_set(path: &str) -> String {
166    match path.split(yog_ids::sep::PATH).next().unwrap_or_default() {
167        GUI_DIR => GUI_SET.to_string(),
168        top => {
169            top.split('_')
170                .map(|w| w[..1].to_ascii_uppercase() + &w[1..])
171                .collect::<String>()
172                + PICTURE_SET_SUFFIX
173        }
174    }
175}
176
177/// The GUI's pictures' folder and set; what the other sets' names end with.
178const GUI_DIR: &str = "gui";
179const GUI_SET: &str = "Images";
180const PICTURE_SET_SUFFIX: &str = "Images";
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn a_texture_round_trips_through_its_file_and_names_itself() {
188        let t = TextureFile {
189            image: Res {
190                set: "Images".into(),
191                key: "block.ruby".into(),
192            },
193            atlas: Some("minecraft:blocks".into()),
194            animation: Some(Animation {
195                frame_time: Some(2),
196                frames: vec![Frame {
197                    index: 1,
198                    time: Some(5),
199                }],
200                ..Animation::default()
201            }),
202            blur: false,
203            clamp: true,
204        };
205        assert_eq!(
206            yog_data::from_text::<TextureFile>(&yog_data::text::to_string_pretty(&t.write()))
207                .unwrap()
208                .value,
209            t
210        );
211        assert_eq!(
212            id_of("mymod", "block/ruby.ytexture").as_deref(),
213            Some("mymod:block/ruby")
214        );
215        assert_eq!(
216            (
217                picture_set("block/ruby"),
218                picture_set("mob_effect/x"),
219                picture_set("gui/logo")
220            ),
221            (
222                "BlockImages".into(),
223                "MobEffectImages".into(),
224                "Images".into()
225            )
226        );
227        let meta = json!({ "animation": { "frametime": 2, "interpolate": true, "frames": [1, { "index": 0, "time": 5 }] } });
228        let a = Animation::from_mcmeta(&meta).unwrap();
229        assert_eq!(
230            TextureFile {
231                animation: Some(a),
232                clamp: false,
233                ..t
234            }
235            .game_mcmeta(),
236            Some(meta)
237        );
238    }
239}