Files
2026-10-27 17:27:44 -04:00

81 lines
5.4 KiB
Python

"""Compose the release art from native Blender renders and original typography."""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageChops, ImageOps
import numpy as np, math, json, argparse
work=Path(__file__).parent
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output-dir',type=Path,default=work.parent/'outputs/FunctionalTurrets-release-art')
out=parser.parse_args().output_dir
W=2048;coral=(241,100,101);cream=(232,226,209);rng=np.random.default_rng(271026)
impact=Path(r'C:\Windows\Fonts\impact.ttf');mono=Path(r'C:\Windows\Fonts\courbd.ttf')
font=lambda n:ImageFont.truetype(str(impact),n)
small=lambda n:ImageFont.truetype(str(mono),n)
def fit(text,maxwidth,maxsize,fontpath=impact):
for n in range(maxsize,10,-1):
f=ImageFont.truetype(str(fontpath),n)
if f.getlength(text)<=maxwidth:return f
def cutout(path):
im=Image.open(path).convert('RGBA');return im.crop(im.getchannel('A').getbbox())
def arrows(layer,box,color,width):
d=ImageDraw.Draw(layer);cx=(box[0]+box[2])/2;cy=(box[1]+box[3])/2;rx=(box[2]-box[0])/2;ry=(box[3]-box[1])/2
for start,end in [(12,155),(195,338)]:
d.arc(box,start,end,fill=color,width=width)
a=math.radians(end);x=cx+rx*math.cos(a);y=cy+ry*math.sin(a)
tangent=np.array([-rx*math.sin(a),ry*math.cos(a)]);tangent=tangent/np.linalg.norm(tangent)
side=np.array([-tangent[1],tangent[0]])
tip=np.array([x,y])+tangent*width*1.6;base=np.array([x,y])-tangent*width*1.9
d.polygon([tuple(tip),tuple(base+side*width*1.55),tuple(base-side*width*1.55)],fill=color)
def distressed_text(canvas,position,text,typeface,color):
mask=Image.new('L',canvas.size);d=ImageDraw.Draw(mask);d.text(position,text,font=typeface,fill=255,anchor='lt')
a=np.asarray(mask).copy();noise=rng.random(a.shape)
a[(noise<.025)&(a>0)]=30
# Thin uneven scratches cut through the lettering without sacrificing shape.
scratches=Image.new('L',canvas.size);sd=ImageDraw.Draw(scratches)
for _ in range(40):
x=int(rng.integers(70,W-70));y=int(rng.integers(170,650));sd.line((x,y,x+int(rng.integers(5,100)),y+int(rng.integers(-3,4))),fill=150,width=int(rng.integers(1,3)))
mask=ImageChops.subtract(Image.fromarray(a),scratches)
canvas.alpha_composite(Image.composite(Image.new('RGBA',canvas.size,(*color,255)),Image.new('RGBA',canvas.size),mask))
# Weathered dark olive paper, with a restrained warm vignette.
yy,xx=np.mgrid[0:W,0:W];rad=((xx/W-.5)**2+(yy/W-.5)**2)
grain=rng.normal(0,2.3,(W,W));coarse=Image.fromarray(rng.integers(0,255,(64,64),dtype=np.uint8)).resize((W,W),Image.Resampling.BICUBIC)
tone=(np.asarray(coarse).astype(float)-127)/35
background=np.stack([22+grain+tone-10*rad,27+grain+tone-12*rad,24+grain+tone-11*rad],axis=2)
poster=Image.fromarray(np.clip(background,0,255).astype('uint8')).convert('RGBA')
texture=Image.new('RGBA',poster.size);td=ImageDraw.Draw(texture)
for x in range(82,W,128):td.line((x,725,x,1770),fill=(190,190,160,9),width=1)
for y in range(770,1790,128):td.line((85,y,1960,y),fill=(190,190,160,9),width=1)
for _ in range(180):
x,y=rng.integers(15,W-15,2);td.line((int(x),int(y),int(x+rng.integers(-2,3)),int(y+rng.integers(2,55))),fill=(150,145,120,int(rng.integers(5,20))),width=1)
td.rectangle((36,36,2011,2011),outline=(213,209,185,40),width=2)
poster.alpha_composite(texture)
distressed_text(poster,(88,175),'FUNCTIONAL',fit('FUNCTIONAL',1872,262),cream)
distressed_text(poster,(82,384),'TURRETS',fit('TURRETS',1884,377),cream)
ferret=cutout(out/'ferret-turret-render.png');lav=cutout(out/'lav-turret-render.png')
ferret=ImageOps.contain(ferret,(840,890),Image.Resampling.LANCZOS)
lav=ImageOps.contain(lav,(1260,1020),Image.Resampling.LANCZOS)
def place(im,x,y):
shadow=Image.new('RGBA',poster.size);a=im.getchannel('A').filter(ImageFilter.GaussianBlur(9))
shade=Image.new('RGBA',im.size,(0,0,0,0));shade.putalpha(a.point(lambda p:int(p*.65)))
shadow.alpha_composite(shade,(x+14,y+24));poster.alpha_composite(shadow);poster.alpha_composite(im,(x,y))
place(ferret,82,822)
place(lav,1945-lav.width,775)
poster.convert('RGB').save(out/'preview-master.png')
poster.convert('RGB').resize((1024,1024),Image.Resampling.LANCZOS).save(out/'preview.png')
# Shared icon: transparent model silhouette and rotation arrows, no tiny text.
icon=Image.new('RGBA',(1024,1024));ring=Image.new('RGBA',icon.size)
arrows(ring,(90,637,930,960),(*coral,240),24);icon.alpha_composite(ring)
model=ImageOps.contain(cutout(out/'lav-turret-render.png'),(880,865),Image.Resampling.LANCZOS)
a=model.getchannel('A');outline=ImageChops.subtract(a.filter(ImageFilter.MaxFilter(9)),a)
outline_layer=Image.new('RGBA',model.size,(*cream,255));outline_layer.putalpha(outline.point(lambda p:int(p*.78)))
x=(1024-model.width)//2;y=32
icon.alpha_composite(outline_layer,(x,y));icon.alpha_composite(model,(x,y))
icon.save(out/'icon-master.png');icon.resize((512,512),Image.Resampling.LANCZOS).save(out/'icon.png')
icon.resize((128,128),Image.Resampling.LANCZOS).save(out/'icon-128.png')
poster.convert('RGB').resize((256,256),Image.Resampling.LANCZOS).save(out/'preview-256.png')
(out/'artwork.json').write_text(json.dumps({'style':'Original distressed survival-poster treatment; actual game-model renders','pink':'#F16465','preview_master':[2048,2048],'preview':[1024,1024],'icon_master':[1024,1024],'icon':[512,512],'icon_transparent':True,'no_image_generation':True},indent=2))
print('Composed release preview and transparent icon:',out)