121 lines
10 KiB
Python
121 lines
10 KiB
Python
"""Native 26.3 resources + author-approved deterministic vanilla pixel recoloring.
|
||
Requires Pillow. Source brick textures already installed in assets/sanctuary/textures/block.
|
||
Usage: python generate-colored-bricks.py --minecraft-jar <26.3 minecraft-client.jar>
|
||
"""
|
||
import argparse, hashlib, json, statistics, zipfile
|
||
from pathlib import Path
|
||
from PIL import Image, ImageDraw
|
||
ap=argparse.ArgumentParser();ap.add_argument('--minecraft-jar',required=True);args=ap.parse_args()
|
||
root=Path(__file__).resolve().parents[1];res=root/'mods/sanctuary/src/main/resources';assets=res/'assets/sanctuary';data=res/'data'
|
||
colors=['white','orange','magenta','light_blue','yellow','lime','pink','gray','light_gray','cyan','purple','blue','brown','green','red','black']
|
||
fr=[('blanche','blanches'),('orange','orange'),('magenta','magenta'),('bleu clair','bleu clair'),('jaune','jaunes'),('vert clair','vert clair'),('rose','roses'),('grise','grises'),('gris clair','gris clair'),('cyan','cyan'),('violette','violettes'),('bleue','bleues'),('marron','marron'),('verte','vertes'),('rouge','rouges'),('noire','noires')]
|
||
z=zipfile.ZipFile(args.minecraft_jar)
|
||
def write(path,obj):
|
||
path.parent.mkdir(parents=True,exist_ok=True);path.write_text(json.dumps(obj,ensure_ascii=False,indent=2)+'\n')
|
||
def native(path):return json.loads(z.read(path))
|
||
def copy_native(path,dest,mapping):
|
||
text=z.read(path).decode()
|
||
for a,b in sorted(mapping.items(),key=lambda x:-len(x[0])):text=text.replace(a,b)
|
||
write(dest,json.loads(text))
|
||
def id(name):return 'sanctuary:'+name
|
||
def luminance(p):return .2126*p[0]+.7152*p[1]+.0722*p[2]
|
||
def sample(image):
|
||
pixels=[p[:3] for p in image.convert('RGBA').getdata() if p[3]]
|
||
chroma=[max(p)-min(p) for p in pixels]
|
||
if max(chroma)>20:
|
||
threshold=sorted(chroma)[len(chroma)//2];pixels=[p for p in pixels if max(p)-min(p)>=threshold]
|
||
else:
|
||
threshold=sorted(map(luminance,pixels))[int(len(pixels)*.7)];pixels=[p for p in pixels if luminance(p)<=threshold]
|
||
return tuple(round(statistics.mean(p[i] for p in pixels)) for i in range(3))
|
||
def recolor(src,target,contrast=.85):
|
||
image=Image.open(__import__('io').BytesIO(z.read('assets/minecraft/textures/'+src+'.png'))).convert('RGBA')
|
||
pixels=list(image.getdata());mean=statistics.mean(luminance(p) for p in pixels if p[3])
|
||
out=[]
|
||
for p in pixels:
|
||
if not p[3]:out.append(p);continue
|
||
# Retain every source shading step and alpha, never resize or redraw a pixel.
|
||
delta=luminance(p)-mean
|
||
out.append(tuple(max(0,min(255,round(c+delta*contrast))) for c in target)+(p[3],))
|
||
image.putdata(out);return image
|
||
recipes=[];block_ids=[];item_ids=[];palette={}
|
||
def tag(namespace,kind,name,values):
|
||
path=data/namespace/'tags'/kind/(name+'.json');old=json.loads(path.read_text()) if path.exists() else {'replace':False,'values':[]}
|
||
old['values']=list(dict.fromkeys(old['values']+values));write(path,old)
|
||
def recipe(name,obj,ingredient):
|
||
write(data/'sanctuary/recipe'/(name+'.json'),obj);recipes.append(id(name))
|
||
write(data/'sanctuary/advancement/recipes/colored_bricks'/(name+'.json'),{'parent':'minecraft:recipes/root','criteria':{'has_material':{'trigger':'minecraft:inventory_changed','conditions':{'items':[{'items':ingredient}]}},'has_the_recipe':{'trigger':'minecraft:recipe_unlocked','conditions':{'recipes':id(name)}}},'requirements':[['has_material','has_the_recipe']],'rewards':{'recipes':[id(name)]}})
|
||
def shaped(name,pattern,key,result,count=1):recipe(name,{'type':'minecraft:crafting_shaped','category':'building','pattern':pattern,'key':key,'result':{'id':result,'count':count}},list(key.values())[0])
|
||
labels={lang:json.loads((assets/'lang'/f'{lang}.json').read_text()) for lang in ['fr_fr','en_us']}
|
||
version=dict(line.split('=',1) for line in (root/'gradle.properties').read_text().splitlines() if '=' in line and not line.startswith('#'))['pack_version']
|
||
preview=Image.new('RGB',(840,16*68+50),(30,31,34));draw=ImageDraw.Draw(preview);draw.text((12,12),version.upper()+' bricks / clay block / clay ball / brick item - native 16 x 16',fill='white')
|
||
for index,color in enumerate(colors):
|
||
names=[color+'_'+suffix for suffix in ['bricks','brick_slab','brick_stairs','clay','clay_ball','brick']];bricks,slab,stairs,clay,ball,brick=names
|
||
wall=color+'_brick_wall'
|
||
source=assets/'textures/block'/f'{bricks}.png';image=Image.open(source);assert image.size==(16,16),source
|
||
target=sample(image)
|
||
# Dark neutrals need less white to stay distinct from light gray; retain the source hue.
|
||
weight={'gray':.80,'black':.90}.get(color,.45)
|
||
pastel=tuple(round(c*weight+230*(1-weight)) for c in target)
|
||
clay_contrast=1.35 if color in ('gray','black') else .85
|
||
palette[color]={'brick':target,'clay':pastel,'source_sha256':hashlib.sha256(source.read_bytes()).hexdigest()}
|
||
if color in ('gray','black'):palette[color]['clay_contrast']=clay_contrast
|
||
images=[image.convert('RGBA')]
|
||
for src,name,tint in [('block/clay',clay,pastel),('item/clay_ball',ball,pastel),('item/brick',brick,target)]:
|
||
out=recolor(src,tint,clay_contrast if src in ('block/clay','item/clay_ball') else .85);path=assets/'textures'/src.split('/')[0]/(name+'.png');path.parent.mkdir(parents=True,exist_ok=True);out.save(path);images.append(out)
|
||
draw.text((12,50+index*68+22),color,fill='white')
|
||
for col,img in enumerate(images):
|
||
preview.paste(img.resize((64,64),Image.Resampling.NEAREST),(170+col*160,50+index*68),img.resize((64,64),Image.Resampling.NEAREST))
|
||
mapping={f'minecraft:{s}':id(n) for s,n in zip(['bricks','brick_slab','brick_stairs','clay','clay_ball','brick'],names)}
|
||
mapping.update({f'minecraft:block/{s}':f'sanctuary:block/{n}' for s,n in zip(['bricks','brick_slab','brick_stairs','clay'],names)})
|
||
mapping.update({f'minecraft:blocks/{s}':f'sanctuary:blocks/{n}' for s,n in zip(['bricks','brick_slab','brick_stairs','clay'],names)})
|
||
mapping.update({f'minecraft:item/{s}':f'sanctuary:item/{n}' for s,n in [('clay_ball',ball),('brick',brick)]})
|
||
mapping.update({'minecraft:brick_wall':id(wall),'minecraft:block/brick_wall':'sanctuary:block/'+wall,'minecraft:blocks/brick_wall':'sanctuary:blocks/'+wall})
|
||
for vanilla,name in zip(['bricks','brick_slab','brick_stairs','clay'],names):
|
||
for category in ['blockstates','models/block','items']:
|
||
copy_native('assets/minecraft/'+category+'/'+vanilla+'.json',assets/category/(name+'.json'),mapping)
|
||
copy_native('data/minecraft/loot_table/blocks/'+vanilla+'.json',data/'sanctuary/loot_table/blocks'/(name+'.json'),mapping)
|
||
for vanilla,name in [('brick_slab_top',slab+'_top'),('brick_stairs_inner',stairs+'_inner'),('brick_stairs_outer',stairs+'_outer')]:
|
||
copy_native('assets/minecraft/models/block/'+vanilla+'.json',assets/'models/block'/(name+'.json'),mapping)
|
||
for vanilla,name in [('clay_ball',ball),('brick',brick)]:
|
||
for category in ['models/item','items']:copy_native('assets/minecraft/'+category+'/'+vanilla+'.json',assets/category/(name+'.json'),mapping)
|
||
for category in ['blockstates','items']:
|
||
copy_native('assets/minecraft/'+category+'/brick_wall.json',assets/category/(wall+'.json'),mapping)
|
||
for part in ['post','side','side_tall','inventory']:
|
||
copy_native('assets/minecraft/models/block/brick_wall_'+part+'.json',assets/'models/block'/(wall+'_'+part+'.json'),mapping)
|
||
copy_native('data/minecraft/loot_table/blocks/brick_wall.json',data/'sanctuary/loot_table/blocks'/(wall+'.json'),mapping)
|
||
block_ids.extend(map(id,names[:4]));item_ids.extend(map(id,names));
|
||
block_ids.append(id(wall));item_ids.append(id(wall))
|
||
singular,plural=fr[index];en=color.replace('_',' ').title()
|
||
for key,eng,french,isblock in zip(names,[f'{en} Bricks',f'{en} Brick Slab',f'{en} Brick Stairs',f'{en} Clay',f'{en} Clay Ball',f'{en} Brick'],[f'Briques {plural}',f'Dalle de briques {plural}',f'Escalier de briques {plural}',f'Argile {singular}',f"Boule d’argile {singular}",f'Brique {singular}'],[True]*4+[False]*2):
|
||
translation=('block.' if isblock else 'item.')+'sanctuary.'+key;labels['en_us'][translation]=eng;labels['fr_fr'][translation]=french
|
||
labels['en_us']['block.sanctuary.'+wall]=f'{en} Brick Wall';labels['fr_fr']['block.sanctuary.'+wall]=f'Muret de briques {plural}'
|
||
dye='minecraft:'+color+'_dye'
|
||
for output,ingredient in [(ball,'#sanctuary:clay_balls'),(clay,'#sanctuary:clay_blocks'),(brick,'#sanctuary:brick_items'),(bricks,'#sanctuary:brick_blocks')]:
|
||
shaped(output+'_from_dye',['###','#X#','###'],{'#':ingredient,'X':dye},id(output),8)
|
||
shaped(clay,['##','##'],{'#':id(ball)},id(clay));shaped(bricks,['##','##'],{'#':id(brick)},id(bricks))
|
||
shaped(slab,['###'],{'#':id(bricks)},id(slab),6);shaped(stairs,['# ','## ','###'],{'#':id(bricks)},id(stairs),4)
|
||
shaped(wall,['###','###'],{'#':id(bricks)},id(wall),6)
|
||
for output,amount in [(slab,2),(stairs,1),(wall,1)]:recipe(output+'_from_stonecutting',{'type':'minecraft:stonecutting','ingredient':id(bricks),'result':{'id':id(output),'count':amount}},id(bricks))
|
||
for output,ingredient,exp in [(id(brick),ball,.3),('minecraft:'+color+'_terracotta',clay,.35)]:
|
||
recipe(ingredient+'_smelting',{'type':'minecraft:smelting','cookingtime':200,'experience':exp,'ingredient':id(ingredient),'result':{'id':output}},id(ingredient))
|
||
tag('minecraft','block','mineable/pickaxe',list(map(id,names[:3]))+[id(wall)]);tag('minecraft','block','mineable/shovel',[id(clay)])
|
||
for kind in ['block','item']:
|
||
tag('minecraft',kind,'slabs',[id(slab)]);tag('minecraft',kind,'stairs',[id(stairs)])
|
||
tag('minecraft',kind,'walls',[id(wall)])
|
||
for name,values in [('clay_balls',['minecraft:clay_ball',id(ball)]),('clay_blocks',['minecraft:clay',id(clay)]),('brick_items',['minecraft:brick',id(brick)]),('brick_blocks',['minecraft:bricks',id(bricks)])]:tag('sanctuary','item',name,values)
|
||
for lang,obj in labels.items():write(assets/'lang'/f'{lang}.json',obj)
|
||
p=root/'scripts/data/collections-sanctuary.json';obj=json.loads(p.read_text());extra=obj.setdefault('clay_and_bricks',{})
|
||
# Other features (notably the workshop and sculptures) extend this collection too.
|
||
# Append our generated IDs without dropping additions owned by those features.
|
||
for key,values in [('additional_recipes',recipes),('additional_items',item_ids)]:extra[key]=list(dict.fromkeys(extra.get(key,[])+values))
|
||
members=extra.setdefault('members',{})
|
||
for kind,values in [('blocks',block_ids),('items',item_ids)]:members[kind]=list(dict.fromkeys(members.get(kind,[])+values))
|
||
write(p,obj)
|
||
# The reviewed collection compiler remains the sole writer of runtime taxonomy.
|
||
import subprocess,sys
|
||
subprocess.run([sys.executable,str(root/'scripts/compile_collections.py'),'--write'],check=True)
|
||
write(root/'tools/colored-bricks-palette.json',palette)
|
||
(root/'build').mkdir(exist_ok=True)
|
||
preview.save(root/'build'/f'colored-bricks-{version}-preview.png')
|
||
print(f'{len(colors)} colors, {len(block_ids)} blocks, {len(item_ids)} items including blocks, {len(recipes)} recipes')
|