1use std::collections::HashMap;
6use std::sync::Arc;
7
8use serde_json::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.as_ref().and_then(|(item, _)| item.get(yog_ids::content_field::MODEL)),
43 );
44 let is = |m: &str| parents.iter().any(|x| yog_ids::qualify(x) == m);
45 if looks.models.heads.iter().any(|m| is(m))
46 && let Some(tex) = looks.head(path)
47 {
48 return head(res, tex).map(Look::Head);
49 }
50 let mut layers = Vec::new();
51 for i in 0.. {
52 let Some(t) = textures.get(&format!("layer{i}")) else {
53 break;
54 };
55 layers.push(t.clone());
56 }
57 if layers.is_empty() {
58 return None;
59 }
60 let tints: Vec<Option<u32>> = if is(yog_ids::yog::SPAWN_EGG_MODEL) {
62 definition_tints(res, ns, path)
64 } else if is(&looks.models.spawn_egg) {
65 let mob = path.strip_suffix(yog_ids::res::SPAWN_EGG_SUFFIX)?;
66 let (base, spots) = looks.spawn_egg(mob)?;
67 vec![Some(base.0), Some(spots.0)]
68 } else if let Some(color) = appearance.as_ref().and_then(|(_, color)| *color) {
69 vec![Some(color)]
70 } else {
71 let tinted_as = if alias { model_item(&looks.models.potion) } else { id.clone() };
73 looks.tint(&tinted_as).map(|c| vec![Some(c.0)]).unwrap_or_default()
74 };
75 let mut out: Option<Image> = None;
76 for (i, layer) in layers.iter().enumerate() {
77 let (lns, lpath) = yog_ids::split(layer);
78 let Some(img) = res
79 .read(lns, &yog_ids::texture_file(lpath))
80 .and_then(|b| decode_png(&b))
81 else {
82 continue;
83 };
84 let img = first_frame(img);
85 let tint = tints.get(i).copied().flatten();
86 match &mut out {
87 None => out = Some(tinted(img, tint)),
88 Some(base) if base.width == img.width && base.height == img.height => {
89 over(base, &tinted(img, tint))
90 }
91 Some(_) => {}
92 }
93 }
94 out.map(|i| Look::Flat(Arc::new(i)))
95}
96
97
98fn definition_tints(res: &Resources, ns: &str, path: &str) -> Vec<Option<u32>> {
100 use yog_ids::item_def;
101 let file = format!("{}{path}{}", yog_ids::res::ITEM_DEFINITIONS, yog_ids::res::JSON);
102 let Some(def) = res.read(ns, &file).and_then(|b| serde_json::from_slice::<Value>(&b).ok()) else {
103 return Vec::new();
104 };
105 let tints = def[item_def::MODEL][item_def::TINTS].as_array().cloned().unwrap_or_default();
106 tints
107 .iter()
108 .map(|t| (t[item_def::TYPE] == item_def::CONSTANT).then(|| t[item_def::VALUE].as_i64()).flatten().map(|v| v as u32))
109 .collect()
110}
111
112fn model_item(model: &str) -> String {
114 let (ns, path) = yog_ids::split(model);
115 yog_ids::join(ns, path.strip_prefix(yog_ids::res::ITEM_DIR).unwrap_or(path))
116}
117
118fn model_chain(
120 res: &Resources,
121 model: &str,
122 content_model: Option<&Value>,
123) -> (Vec<String>, HashMap<String, String>) {
124 let mut chain = Vec::new();
125 let mut textures = HashMap::new();
126 let mut at = model.to_string();
127 let mut pending = content_model.cloned();
128 for _ in 0..8 {
129 chain.push(at.clone());
130 let (ns, path) = yog_ids::split(&at);
131 let json = pending.take().or_else(|| {
132 res.read(ns, &yog_ids::model_file(path))
133 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
134 });
135 let Some(json) = json else {
136 break;
137 };
138 if let Some(t) = json.get(yog_ids::model::TEXTURES).and_then(Value::as_object) {
139 for (k, v) in t {
140 if let Some(v) = v.as_str() {
141 textures.entry(k.clone()).or_insert_with(|| v.to_string());
142 }
143 }
144 }
145 match json.get(yog_ids::model::PARENT).and_then(Value::as_str) {
146 Some(p) => at = p.to_string(),
147 None => break,
148 }
149 }
150 (chain, textures)
151}
152
153fn first_frame(img: Image) -> Image {
155 if img.height <= img.width {
156 return img;
157 }
158 crop(&img, 0, 0, img.width, img.width)
159}
160
161fn crop(img: &Image, x: u32, y: u32, w: u32, h: u32) -> Image {
162 let mut rgba = Vec::with_capacity((w * h * 4) as usize);
163 for yy in y..(y + h).min(img.height) {
164 for xx in x..(x + w).min(img.width) {
165 rgba.extend_from_slice(&img.pixel(xx, yy).to_rgba8());
166 }
167 }
168 let w = w.min(img.width.saturating_sub(x));
169 let h = h.min(img.height.saturating_sub(y));
170 Image { width: w, height: h, rgba }
171}
172
173fn tinted(mut img: Image, tint: Option<u32>) -> Image {
175 if let Some(t) = tint {
176 let m = [(t >> 16) & 0xFF, (t >> 8) & 0xFF, t & 0xFF];
177 for px in img.rgba.chunks_exact_mut(4) {
178 for c in 0..3 {
179 px[c] = (px[c] as u32 * m[c] / 255) as u8;
180 }
181 }
182 }
183 img
184}
185
186fn over(base: &mut Image, top: &Image) {
188 for (b, t) in base.rgba.chunks_exact_mut(4).zip(top.rgba.chunks_exact(4)) {
189 let a = t[3] as u32;
190 for c in 0..3 {
191 b[c] = ((t[c] as u32 * a + b[c] as u32 * (255 - a)) / 255) as u8;
192 }
193 b[3] = (a + b[3] as u32 * (255 - a) / 255).min(255) as u8;
194 }
195}
196
197fn head(res: &Resources, tex: &str) -> Option<Faces> {
199 let (ns, path) = yog_ids::split(tex);
200 let img = decode_png(&res.read(ns, &yog_ids::texture_file(path))?)?;
201 let u = img.width / 64;
202 let f = |x: u32, y: u32| Some(Arc::new(crop(&img, x * u, y * u, 8 * u, 8 * u)));
203 Some([f(8, 0), f(16, 0), f(8, 8), f(24, 8), f(16, 8), f(0, 8)])
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn png(pixel: [u8; 4]) -> Vec<u8> {
212 let mut bytes = Vec::new();
213 let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
214 encoder.set_color(png::ColorType::Rgba);
215 encoder.set_depth(png::BitDepth::Eight);
216 let mut writer = encoder.write_header().unwrap();
217 writer.write_image_data(&pixel).unwrap();
218 drop(writer);
219 bytes
220 }
221
222 #[test]
223 fn content_potion_uses_vanilla_layers_and_effect_color() {
224 let dir = std::env::temp_dir().join(format!("idle-item-model-{}", std::process::id()));
225 let _ = std::fs::remove_dir_all(&dir);
226 std::fs::create_dir_all(dir.join("content/items")).unwrap();
227 std::fs::create_dir_all(dir.join("content/effects")).unwrap();
228 std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"hexcasting\"\n").unwrap();
229 std::fs::write(
230 dir.join("content/items/enlarge_grid.yitem"),
231 r#"{ model: { parent: "minecraft:item/potion" }, item: { potion: { effects: [{ effect: "@effect:hexcasting:enlarge_grid" }] } } }"#,
232 )
233 .unwrap();
234 std::fs::write(
235 dir.join("content/effects/enlarge_grid.ydef"),
236 r#"{ kind: "effect", id: "enlarge_grid", color: 13137407 }"#,
237 )
238 .unwrap();
239 let jar = dir.join("client.jar");
241 {
242 use std::io::Write as _;
243 let mut zip = zip::ZipWriter::new(std::fs::File::create(&jar).unwrap());
244 let files: [(&str, Vec<u8>); 3] = [
245 ("assets/minecraft/models/item/potion.json", br#"{"textures":{"layer0":"minecraft:item/potion_overlay","layer1":"minecraft:item/potion"}}"#.to_vec()),
246 ("assets/minecraft/textures/item/potion_overlay.png", png([255, 255, 255, 255])),
247 ("assets/minecraft/textures/item/potion.png", png([0, 0, 0, 0])),
248 ];
249 for (name, bytes) in files {
250 zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
251 zip.write_all(&bytes).unwrap();
252 }
253 zip.finish().unwrap();
254 }
255 let res = Resources {
256 mod_root: Some(dir.clone()),
257 vanilla: super::super::assets::Vanilla::from_jar(jar, "1.21.1").map(std::sync::Arc::new),
258 };
259 let Some(Look::Flat(image)) = look(&res, "hexcasting:enlarge_grid") else {
260 panic!("content potion should resolve to a layered item model");
261 };
262 let color = 13_137_407u32;
263 assert_eq!(
264 &image.rgba[..],
265 &[
266 ((color >> 16) & 0xFF) as u8,
267 ((color >> 8) & 0xFF) as u8,
268 (color & 0xFF) as u8,
269 255,
270 ]
271 );
272 assert!(matches!(
273 look(&res, "yog:potion/awkward"),
274 Some(Look::Flat(_))
275 ));
276 let Some(Look::Flat(alias)) = look(&res, "yog:potion/enlarge_grid") else {
277 panic!("potion alias should resolve to the configured potion");
278 };
279 assert_eq!(alias.rgba, image.rgba);
280 let _ = std::fs::remove_dir_all(dir);
281 }
282}