1use std::collections::HashMap;
6use yog_sync::Arc;
7
8use yog_data::Value;
9
10use super::assets::{Image, Resources, decode_png};
11use super::blocks3d::Faces;
12use yog_registry::ItemLooks;
13
14pub enum Look {
16 Flat(Arc<Image>),
18 Head(Faces),
20}
21
22pub fn look(res: &Resources, id: &str) -> Option<Look> {
25 let looks = ItemLooks::game();
26 let id = yog_ids::qualify(id);
27 let (ns, path) = yog_ids::split(&id);
28 let appearance = res
29 .mod_root
30 .as_deref()
31 .and_then(|root| crate::design::content::content_item_appearance(root, &id));
32 let alias = id.starts_with(yog_ids::yog::POTION_ALIAS);
34 let model = if alias {
35 looks.models.potion.clone()
36 } else {
37 yog_ids::join(ns, &format!("{}{path}", yog_ids::res::ITEM_DIR))
38 };
39 let (parents, textures) = model_chain(
40 res,
41 &model,
42 appearance
43 .as_ref()
44 .and_then(|(item, _)| item.get(yog_ids::content_field::MODEL)),
45 );
46 let is = |m: &str| parents.iter().any(|x| yog_ids::qualify(x) == m);
47 if looks.models.heads.iter().any(|m| is(m))
48 && let Some(tex) = looks.head(path)
49 {
50 return head(res, tex).map(Look::Head);
51 }
52 let layers: Vec<String> = (0..)
53 .map_while(|i| {
54 textures
55 .get(&format!("{}{i}", yog_ids::model::LAYER))
56 .cloned()
57 })
58 .collect();
59 if layers.is_empty() {
60 return None;
61 }
62 let tints = layer_tints(
63 res,
64 looks,
65 &id,
66 &is,
67 appearance.and_then(|(_, color)| color),
68 )?;
69 composite(res, &layers, &tints).map(|i| Look::Flat(Arc::new(i)))
70}
71
72fn layer_tints(
75 res: &Resources,
76 looks: &ItemLooks,
77 id: &str,
78 is: &dyn Fn(&str) -> bool,
79 color: Option<u32>,
80) -> Option<Vec<Option<u32>>> {
81 let (ns, path) = yog_ids::split(id);
82 if is(yog_ids::yog::SPAWN_EGG_MODEL) {
83 return Some(definition_tints(res, ns, path));
85 }
86 if is(&looks.models.spawn_egg) {
87 let mob = path.strip_suffix(yog_ids::res::SPAWN_EGG_SUFFIX)?;
88 let (base, spots) = looks.spawn_egg(mob)?;
89 return Some(vec![Some(base.0), Some(spots.0)]);
90 }
91 if let Some(color) = color {
92 return Some(vec![Some(color)]);
93 }
94 let tinted_as = if id.starts_with(yog_ids::yog::POTION_ALIAS) {
96 model_item(&looks.models.potion)
97 } else {
98 id.to_string()
99 };
100 Some(
101 looks
102 .tint(&tinted_as)
103 .map(|c| vec![Some(c.0)])
104 .unwrap_or_default(),
105 )
106}
107
108fn composite(res: &Resources, layers: &[String], tints: &[Option<u32>]) -> Option<Image> {
111 let mut out: Option<Image> = None;
112 for (i, layer) in layers.iter().enumerate() {
113 let (lns, lpath) = yog_ids::split(layer);
114 let Some(img) = res
115 .read(lns, &yog_ids::texture_file(lpath))
116 .and_then(|b| decode_png(&b))
117 else {
118 continue;
119 };
120 let img = first_frame(img);
121 let tint = tints.get(i).copied().flatten();
122 match &mut out {
123 None => out = Some(tinted(img, tint)),
124 Some(base) if base.width == img.width && base.height == img.height => {
125 over(base, &tinted(img, tint))
126 }
127 Some(_) => {}
128 }
129 }
130 out
131}
132
133fn definition_tints(res: &Resources, ns: &str, path: &str) -> Vec<Option<u32>> {
135 use crate::consts::item_def;
136 let file = format!(
137 "{}{path}{}",
138 yog_ids::res::ITEM_DEFINITIONS,
139 yog_ids::res::JSON
140 );
141 let Some(def) = res
142 .read(ns, &file)
143 .and_then(|b| crate::design::data::read_bytes(&b))
144 else {
145 return Vec::new();
146 };
147 let tints = crate::design::data::at(&def, &[item_def::MODEL, item_def::TINTS])
148 .and_then(Value::as_list)
149 .map(<[Value]>::to_vec)
150 .unwrap_or_default();
151 tints
152 .iter()
153 .map(|t| {
154 (t.get(item_def::TYPE).and_then(Value::as_str) == Some(item_def::CONSTANT))
155 .then(|| t.get(item_def::VALUE).and_then(Value::as_i64))
156 .flatten()
157 .map(|v| v as u32)
158 })
159 .collect()
160}
161
162fn model_item(model: &str) -> String {
164 let (ns, path) = yog_ids::split(model);
165 yog_ids::join(
166 ns,
167 path.strip_prefix(yog_ids::res::ITEM_DIR).unwrap_or(path),
168 )
169}
170
171fn model_chain(
173 res: &Resources,
174 model: &str,
175 content_model: Option<&Value>,
176) -> (Vec<String>, HashMap<String, String>) {
177 let mut chain = Vec::new();
178 let mut textures = HashMap::new();
179 let mut at = model.to_string();
180 let mut pending = content_model.cloned();
181 for _ in 0..8 {
182 chain.push(at.clone());
183 let (ns, path) = yog_ids::split(&at);
184 let json = pending.take().or_else(|| {
185 res.read(ns, &yog_ids::model_file(path))
186 .and_then(|b| crate::design::data::read_bytes(&b))
187 });
188 let Some(json) = json else {
189 break;
190 };
191 if let Some(t) = json.get(yog_ids::model::TEXTURES).and_then(Value::as_map) {
192 for (k, v) in t {
193 if let Some(v) = v.as_str() {
194 textures
195 .entry(k.to_string())
196 .or_insert_with(|| v.to_string());
197 }
198 }
199 }
200 match json.get(yog_ids::model::PARENT).and_then(Value::as_str) {
201 Some(p) => at = p.to_string(),
202 None => break,
203 }
204 }
205 (chain, textures)
206}
207
208fn first_frame(img: Image) -> Image {
210 if img.height <= img.width {
211 return img;
212 }
213 crop(&img, 0, 0, img.width, img.width)
214}
215
216fn crop(img: &Image, x: u32, y: u32, w: u32, h: u32) -> Image {
217 let mut rgba = Vec::with_capacity((w * h * 4) as usize);
218 for yy in y..(y + h).min(img.height) {
219 for xx in x..(x + w).min(img.width) {
220 rgba.extend_from_slice(&img.pixel(xx, yy).to_rgba8());
221 }
222 }
223 let w = w.min(img.width.saturating_sub(x));
224 let h = h.min(img.height.saturating_sub(y));
225 Image {
226 width: w,
227 height: h,
228 rgba,
229 }
230}
231
232fn tinted(mut img: Image, tint: Option<u32>) -> Image {
234 if let Some(t) = tint {
235 let m = [(t >> 16) & 0xFF, (t >> 8) & 0xFF, t & 0xFF];
236 for px in img.pixels_mut() {
237 for c in 0..3 {
238 px[c] = (px[c] as u32 * m[c] / 255) as u8;
239 }
240 }
241 }
242 img
243}
244
245fn over(base: &mut Image, top: &Image) {
247 for (b, t) in base.pixels_mut().iter_mut().zip(top.pixels()) {
248 let a = t[3] as u32;
249 for c in 0..3 {
250 b[c] = ((t[c] as u32 * a + b[c] as u32 * (255 - a)) / 255) as u8;
251 }
252 b[3] = (a + b[3] as u32 * (255 - a) / 255).min(255) as u8;
253 }
254}
255
256fn head(res: &Resources, tex: &str) -> Option<Faces> {
258 let (ns, path) = yog_ids::split(tex);
259 let img = decode_png(&res.read(ns, &yog_ids::texture_file(path))?)?;
260 let u = img.width / 64;
261 let f = |x: u32, y: u32| Some(Arc::new(crop(&img, x * u, y * u, 8 * u, 8 * u)));
262 Some([f(8, 0), f(16, 0), f(8, 8), f(24, 8), f(16, 8), f(0, 8)])
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 fn png(pixel: [u8; 4]) -> Vec<u8> {
271 let mut bytes = Vec::new();
272 let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
273 encoder.set_color(png::ColorType::Rgba);
274 encoder.set_depth(png::BitDepth::Eight);
275 let mut writer = encoder.write_header().unwrap();
276 writer.write_image_data(&pixel).unwrap();
277 drop(writer);
278 bytes
279 }
280
281 #[test]
282 fn content_potion_uses_vanilla_layers_and_effect_color() {
283 let dir = std::env::temp_dir().join(format!("idle-item-model-{}", std::process::id()));
284 let _ = std::fs::remove_dir_all(&dir);
285 std::fs::create_dir_all(dir.join("content/items")).unwrap();
286 std::fs::create_dir_all(dir.join("content/effects")).unwrap();
287 std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"hexcasting\"\n").unwrap();
288 std::fs::write(
289 dir.join("content/items/enlarge_grid.yitem"),
290 r#"{ model: { parent: "minecraft:item/potion" }, item: { potion: { effects: [{ effect: "@effect:hexcasting:enlarge_grid" }] } } }"#,
291 )
292 .unwrap();
293 std::fs::write(
294 dir.join("content/effects/enlarge_grid.ydef"),
295 r#"{ kind: "effect", id: "enlarge_grid", color: 13137407 }"#,
296 )
297 .unwrap();
298 let jar = dir.join("client.jar");
300 {
301 use std::io::Write as _;
302 let mut zip = zip::ZipWriter::new(std::fs::File::create(&jar).unwrap());
303 let files: [(&str, Vec<u8>); 3] = [
304 ("assets/minecraft/models/item/potion.json", br#"{"textures":{"layer0":"minecraft:item/potion_overlay","layer1":"minecraft:item/potion"}}"#.to_vec()),
305 ("assets/minecraft/textures/item/potion_overlay.png", png([255, 255, 255, 255])),
306 ("assets/minecraft/textures/item/potion.png", png([0, 0, 0, 0])),
307 ];
308 for (name, bytes) in files {
309 zip.start_file(name, zip::write::SimpleFileOptions::default())
310 .unwrap();
311 zip.write_all(&bytes).unwrap();
312 }
313 zip.finish().unwrap();
314 }
315 let res = Resources {
316 mod_root: Some(dir.clone()),
317 vanilla: super::super::assets::Vanilla::from_jar(jar, "1.21.1").map(yog_sync::Arc::new),
318 };
319 let Some(Look::Flat(image)) = look(&res, "hexcasting:enlarge_grid") else {
320 panic!("content potion should resolve to a layered item model");
321 };
322 let color = 13_137_407u32;
323 assert_eq!(
324 &image.rgba[..],
325 &[
326 ((color >> 16) & 0xFF) as u8,
327 ((color >> 8) & 0xFF) as u8,
328 (color & 0xFF) as u8,
329 255,
330 ]
331 );
332 assert!(matches!(
333 look(&res, "yog:potion/awkward"),
334 Some(Look::Flat(_))
335 ));
336 let Some(Look::Flat(alias)) = look(&res, "yog:potion/enlarge_grid") else {
337 panic!("potion alias should resolve to the configured potion");
338 };
339 assert_eq!(alias.rgba, image.rgba);
340 let _ = std::fs::remove_dir_all(dir);
341 }
342}