Files
sanctuary-beta/tools/generate-colored-bricks.py
T
koka 0a81b3c147
Build Sanctuary / build (push) Canceled after 0s
Add sixteen native colored brick walls (beta.118)
2026-09-17 13:03:18 +02:00

121 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 dargile {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')