Free NeoForge 1.21.1 modpack on Cobblemon 1.8.4 built with packwiz, plus the Kotlin companion mod (cobblesurvival) and its built-in data pack. - Phase 0: packwiz pack with Modrinth/CurseForge cross-links, build and export tooling, Prism instance sync, test server with smoke checks. - Phase 1: Poke Ball shaped island (WorldGen Editor preset, seed isle-26), Adrian's biome list, no natural ore, structures without ore or vanilla mobs, no Nether portals, world template builder and world report. - Phase 2: permadeath (CobbleHardcoreMon plus the companion mod's gap fixes), Revival Charm, expensive Healing Machine, Lootr refreshing loot, in-game self-tests on Minecraft's GameTest framework. - Phase 3 so far: 14 zones with their own spawn pools and level bands (tools/zone_layout.py, tools/zone_spawns.py, "zone" spawn condition), Cold Sweat heat and cold as gates with boss-drop insulation recipes, spawn survey and hazard self-tests in tools/quick_check.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
6.1 KiB
Python
145 lines
6.1 KiB
Python
"""Item icons rendered from the jars' item models into build/field-guide/icons/.
|
|
|
|
Flat items composite their layer textures; block items get a small isometric
|
|
cube from their block model's faces; cross-shaped plants use their texture.
|
|
The PNGs are third-party assets (Cobblemon's assets are not MPL-licensed), so
|
|
they only ever go to build/ and are never committed.
|
|
"""
|
|
import io
|
|
|
|
from PIL import Image, ImageEnhance
|
|
|
|
from .datasource import split_id
|
|
|
|
S = 16
|
|
FACES = { # texture keys tried for each visible face of a cube
|
|
"top": ("up", "top", "end", "all", "texture", "side", "particle"),
|
|
"left": ("south", "front", "side", "all", "texture", "north", "particle"),
|
|
"right": ("east", "side", "all", "texture", "west", "particle"),
|
|
}
|
|
FLAT_PARENTS = ("item/generated", "item/handheld", "item/handheld_rod", "builtin/generated")
|
|
CROSS_PARENTS = ("block/cross", "block/tinted_cross", "block/flower_pot_cross", "block/crop")
|
|
FOLIAGE = (72, 181, 24)
|
|
|
|
|
|
class Icons:
|
|
def __init__(self, stack, out_dir):
|
|
self.stack, self.out = stack, out_dir
|
|
self.models = {}
|
|
self.missing = []
|
|
|
|
def model(self, mid):
|
|
"""Merged model: (parent chain ids, textures, elements)."""
|
|
if mid in self.models:
|
|
return self.models[mid]
|
|
ns, path = split_id(mid)
|
|
d = self.stack.json(f"assets/{ns}/models/{path}.json") or {}
|
|
chain, textures, elements = [f"{ns}:{path}"], {}, d.get("elements")
|
|
parent = d.get("parent")
|
|
if parent and len(chain) < 20:
|
|
pchain, ptex, pel = self.model(parent if ":" in parent else "minecraft:" + parent)
|
|
chain += pchain
|
|
textures.update(ptex)
|
|
elements = elements or pel
|
|
textures.update(d.get("textures", {}))
|
|
self.models[mid] = (chain, textures, elements)
|
|
return self.models[mid]
|
|
|
|
def texture(self, ref, textures, depth=0):
|
|
while isinstance(ref, str) and ref.startswith("#") and depth < 10:
|
|
ref, depth = textures.get(ref[1:]), depth + 1
|
|
if not isinstance(ref, str):
|
|
return None
|
|
ns, path = split_id(ref)
|
|
raw = self.stack.read(f"assets/{ns}/textures/{path}.png")
|
|
if raw is None:
|
|
return None
|
|
img = Image.open(io.BytesIO(raw)).convert("RGBA")
|
|
if img.height > img.width: # animated strip: first frame
|
|
img = img.crop((0, 0, img.width, img.width))
|
|
return img.resize((S, S), Image.NEAREST) if img.size != (S, S) else img
|
|
|
|
def render(self, iid):
|
|
ns, path = split_id(iid)
|
|
chain, tex, elements = self.model(f"{ns}:item/{path}")
|
|
if len(chain) == 1 and not tex:
|
|
chain, tex, elements = self.model(f"{ns}:block/{path}")
|
|
if len(chain) == 1 and not tex: # no model at all: KubeJS items get item/generated with their own texture
|
|
chain, tex = [f"{ns}:item/{path}", "minecraft:item/generated"], {"layer0": f"{ns}:item/{path}"}
|
|
names = [c.split(":", 1)[1] for c in chain]
|
|
if any(n in FLAT_PARENTS for n in names) or any(k.startswith("layer") for k in tex):
|
|
layers = sorted((k for k in tex if k.startswith("layer")), key=lambda k: int(k[5:] or 0))
|
|
img = None
|
|
for k in layers:
|
|
t = self.texture(tex[k], tex)
|
|
if t is not None:
|
|
img = t if img is None else Image.alpha_composite(img, t)
|
|
return img.resize((32, 32), Image.NEAREST) if img else None
|
|
if any(n in CROSS_PARENTS for n in names):
|
|
t = self.texture(tex.get("cross") or tex.get("crop") or tex.get("plant") or tex.get("particle"), tex)
|
|
return t.resize((32, 32), Image.NEAREST) if t else None
|
|
faces = {}
|
|
for face, keys in FACES.items():
|
|
for k in keys:
|
|
if k in tex:
|
|
faces[face] = self.texture(tex[k], tex)
|
|
if faces[face] is not None:
|
|
break
|
|
if not any(v is not None for v in faces.values()):
|
|
return None
|
|
tinted = any(n in ("block/leaves",) for n in names) or path.endswith("leaves")
|
|
return iso_cube(faces, tinted)
|
|
|
|
def write_all(self, ids):
|
|
out = {}
|
|
for iid in sorted(ids):
|
|
try:
|
|
img = self.render(iid)
|
|
except (OSError, ValueError) as e:
|
|
self.missing.append(f"{iid}: {e}")
|
|
img = None
|
|
if img is None:
|
|
self.missing.append(iid)
|
|
continue
|
|
ns, path = split_id(iid)
|
|
rel = f"icons/{ns}/{path}.png"
|
|
dst = self.out / rel
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
img.save(dst)
|
|
out[iid] = rel
|
|
return out
|
|
|
|
|
|
def _tint(img, rgb):
|
|
r, g, b, a = img.split()
|
|
grey = img.convert("L")
|
|
return Image.merge("RGBA", [grey.point(lambda v, c=c: v * c // 255) for c in rgb] + [a])
|
|
|
|
|
|
def iso_cube(faces, tinted=False):
|
|
"""32x32 isometric block icon from top/left/right 16x16 face textures."""
|
|
canvas = Image.new("RGBA", (32, 32), (0, 0, 0, 0))
|
|
geometry = { # origin, u axis, v axis (output pixels per texture pixel)
|
|
"top": ((16, 0), (1, 0.5), (-1, 0.5)),
|
|
"left": ((0, 8), (1, 0.5), (0, 1)),
|
|
"right": ((16, 16), (1, -0.5), (0, 1)),
|
|
}
|
|
shade = {"top": 1.0, "left": 0.8, "right": 0.62}
|
|
for face in ("top", "left", "right"):
|
|
tex = faces.get(face) or faces.get("top") or next(v for v in faces.values() if v is not None)
|
|
if tinted:
|
|
tex = _tint(tex, FOLIAGE)
|
|
(ox, oy), (ux, uy), (vx, vy) = geometry[face]
|
|
det = ux * vy - uy * vx
|
|
# inverse affine: texture coords from output coords
|
|
a, b = vy / det, -vx / det
|
|
d, e = -uy / det, ux / det
|
|
c, f = -(a * ox + b * oy), -(d * ox + e * oy)
|
|
layer = tex.transform((32, 32), Image.AFFINE, (a, b, c, d, e, f), resample=Image.NEAREST,
|
|
fillcolor=(0, 0, 0, 0))
|
|
if shade[face] != 1.0:
|
|
rgb = ImageEnhance.Brightness(layer.convert("RGB")).enhance(shade[face])
|
|
layer = Image.merge("RGBA", (*rgb.split(), layer.split()[3]))
|
|
canvas = Image.alpha_composite(canvas, layer)
|
|
return canvas
|