"""Offline rooftop atlas/map compiler. Game rendering and travel remain MASM.""" from pathlib import Path import json import math import struct import numpy as np import cv2 from PIL import Image ROOT = Path(__file__).resolve().parents[2] ART = ROOT / "assets/sprites/rooftop" OUT = ROOT / "assets/animations/rooftop" def grid(path, columns, rows): source = Image.open(ART/path).convert("RGBA") return [source.crop((round(x*source.width/columns), round(y*source.height/rows), round((x+1)*source.width/columns), round((y+1)*source.height/rows))) .resize((256, 256), Image.Resampling.LANCZOS) for y in range(rows) for x in range(columns)] def pack(name, frames): w, h = frames[0].size payload = b"".join(frame.tobytes("raw", "BGRA") for frame in frames) (OUT/name).write_bytes(struct.pack("<4s4I", b"ANM1", w, h, len(frames), len(payload))+payload) def clean_hands(frame): pixels = np.asarray(frame).copy() mask = (pixels[:,:,3] >= 128).astype(np.uint8) mask = cv2.morphologyEx(mask,cv2.MORPH_OPEN,np.ones((3,3),np.uint8)) count,labels,stats,_ = cv2.connectedComponentsWithStats(mask) keep = [i for i in range(1,count) if stats[i,cv2.CC_STAT_AREA] > 2500] support = np.isin(labels,keep).astype(np.uint8) support = cv2.erode(support,np.ones((3,3),np.uint8)) pixels[:,:,3] = support*255 pixels[support == 0,:3] = 0 return Image.fromarray(pixels) def night_facade(frame, seed): """Identify window apertures; mark lit glass with FF alpha and unlit walls FE.""" pixels = np.asarray(frame).copy() rgb = pixels[:,:,:3].astype(np.int16) mask = ((rgb.max(axis=2)<145) & (rgb[:,:,2]>rgb[:,:,0]-10)).astype(np.uint8)*255 mask = cv2.morphologyEx(mask,cv2.MORPH_CLOSE,np.ones((7,5),np.uint8)) contours,_ = cv2.findContours(mask,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE) pixels[:,:,3] = 254 rng = np.random.default_rng(seed) count = 0 for contour in contours: x,y,w,h = cv2.boundingRect(contour) if not (8 <= w <= 110 and 14 <= h <= 180 and .8 <= h/w <= 4): continue if rng.random() > .48: continue region = pixels[y+2:y+h-2,x+2:x+w-2] source = region[:,:,:3].astype(np.int16) glass = (source[:,:,2]>source[:,:,0]+4) & (source.max(axis=2)<155) if glass.sum() < 15: continue yy,xx = np.mgrid[:h-4,:w-4] glow = np.clip(1-((xx-(w-4)/2)/(w/2))**2*.22, .5, 1) warmth = np.array([210,148,76])*rng.uniform(.7,1.1) lit = np.clip(glow[:,:,None]*warmth,0,255).astype(np.uint8) region[:,:,:3][glass] = lit[glass] region[:,:,3][glass] = 255 count += 1 return Image.fromarray(pixels),count def main(): OUT.mkdir(parents=True, exist_ok=True) sky = Image.open(ART/"sunset-sky-v1.png").convert("RGBA").resize((2048,512),Image.Resampling.LANCZOS) # A short symmetric overlap makes the cylindrical wrap continuous. original = sky.copy() for x in range(32): mixed = Image.blend(original.crop((x,0,x+1,512)), original.crop((2047-x,0,2048-x,512)), (32-x)/64) sky.paste(mixed, (x,0)) mixed = Image.blend(original.crop((2047-x,0,2048-x,512)), original.crop((x,0,x+1,512)), (32-x)/64) sky.paste(mixed, (2047-x,0)) pack("panorama-v1.bin", [sky.crop((i*512,0,(i+1)*512,512)) for i in range(4)]) night = Image.open(ART/"night-sky-v1.png").convert("RGBA").resize((2048,512),Image.Resampling.LANCZOS) pack("night-v1.bin",[night.crop((i*512,0,(i+1)*512,512)) for i in range(4)]) hands = Image.open(ART/"rail-hands-v1.png").convert("RGBA") poses = [hands.crop((round(x*hands.width/4),round(y*hands.height/2), round((x+1)*hands.width/4),round((y+1)*hands.height/2))) .resize((640,320),Image.Resampling.LANCZOS) for y in range(2) for x in range(4)] # Offline bidirectional flow supplies in-between poses; playback is pure MASM. coordinates = np.mgrid[:320,:640][::-1].transpose(1,2,0).astype(np.float32) smooth = [] for first,second in zip(poses,poses[1:]): a,b = np.asarray(first),np.asarray(second) ag = cv2.cvtColor(a,cv2.COLOR_RGBA2GRAY) bg = cv2.cvtColor(b,cv2.COLOR_RGBA2GRAY) forward = cv2.calcOpticalFlowFarneback(ag,bg,None,.5,5,35,6,7,1.5,0) backward = cv2.calcOpticalFlowFarneback(bg,ag,None,.5,5,35,6,7,1.5,0) for index in range(4): q = index/4 aw = cv2.remap(a,coordinates-forward*q,None,cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT) bw = cv2.remap(b,coordinates-backward*(1-q),None,cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT) smooth.append(Image.fromarray(cv2.addWeighted(aw,1-q,bw,q,0))) smooth += [poses[-1]]*4 assert len(smooth) == 32 pack("rail-hands-v1.bin",[clean_hands(frame) for frame in smooth]) materials = grid("materials-v1.png",4,2) pack("materials-v1.bin",materials) panels = grid("panels-v1.png",4,2)[:4] # One moving leaf, not a photograph of a whole elevator on each leaf. panels[0] = panels[0].crop((72,16,126,235)).resize((256,256),Image.Resampling.LANCZOS) # Use only the fluted facade, not the reference's pictured skyline above it. panels[2] = panels[2].crop((0,116,256,240)).resize((256,256),Image.Resampling.LANCZOS) cutouts = grid("cutouts-v1.png",2,2) for frame in cutouts: assert frame.getchannel("A").getextrema()[0] == 0, "cutouts require actual alpha" sofas = Image.open(ART/"sofa-views-v1.png").convert("RGBA") occupied = np.sum(np.asarray(sofas.getchannel("A")) > 128,axis=0) > 10 edges = np.flatnonzero(np.diff(np.r_[False,occupied,False])) spans = [(int(a),int(b)) for a,b in zip(edges[::2],edges[1::2]) if b-a > 80] assert len(spans) == 3, spans boxes = [] for lo,hi in spans: box = sofas.crop((lo,0,hi,sofas.height)).getchannel("A").point(lambda a:255 if a>128 else 0).getbbox() boxes.append((lo,box[1],hi,box[3])) top = min(box[1] for box in boxes) bottom = max(box[3] for box in boxes) views = [sofas.crop((box[0],top,box[2],bottom)).resize((256,256),Image.Resampling.LANCZOS) for box in boxes] chairs = Image.open(ART/"chair-views-v1.png").convert("RGBA") split = round(chairs.width*.55) boxes = [(0,0,split,chairs.height//2),(split,0,chairs.width,chairs.height//2), (0,chairs.height//2,split,chairs.height),(split,chairs.height//2,chairs.width,chairs.height)] chair_views = [] for box in boxes: frame = chairs.crop(box) tight = frame.getchannel("A").point(lambda a:255 if a>128 else 0).getbbox() chair_views.append(frame.crop(tight).resize((256,256),Image.Resampling.LANCZOS)) pack("details-v1.bin",panels+cutouts+views+chair_views) facade = Image.open(ART/"city-facades-v1.png").convert("RGBA") city = [facade.crop((round(x*facade.width/4),round(y*facade.height/2), round((x+1)*facade.width/4),round((y+1)*facade.height/2))) .resize((512,512),Image.Resampling.LANCZOS) for y in range(2) for x in range(4)] # Foliage-only tuft, not a planter photographed on a vertical card. city.append(cutouts[1].crop((0,0,256,160)).resize((512,512),Image.Resampling.LANCZOS)) earth = np.asarray(materials[1].resize((512,512))).copy() earth[:,:,:3] = (earth[:,:,:3].astype(np.uint16)*np.array([37,31,24])//100).astype(np.uint8) city.append(Image.fromarray(earth)) city.append(Image.open(ART/"roof-zinc-v1.png").convert("RGBA").resize((512,512),Image.Resampling.LANCZOS)) # Existing high-detail stone is also a road aggregate; water is a generated # native texture below. Neither is a photograph of a whole city object. road = np.asarray(materials[1].resize((512,512))).copy() road[:,:,:3] = (road[:,:,:3].astype(np.uint16)*45//100).astype(np.uint8) city.append(Image.fromarray(road)) yy,xx = np.mgrid[:512,:512] ripple = (np.sin(xx*.17+np.sin(yy*.09)*2)+np.sin(yy*.31))*4 water = np.zeros((512,512,4),dtype=np.uint8) for channel,base in enumerate((24,55,72)): water[:,:,channel] = np.clip(base+ripple,0,255).astype(np.uint8) water[:,:,3] = 255 city.append(Image.fromarray(water)) modern = Image.open(ART/"modern-facades-v1.png").convert("RGBA") city += [modern.crop((round(x*modern.width/4),round(y*modern.height/2), round((x+1)*modern.width/4),round((y+1)*modern.height/2))) .resize((512,512),Image.Resampling.LANCZOS) for y in range(2) for x in range(4)] noise = np.random.default_rng(701).integers(-8,9,size=(512,512,1)) lawn = np.empty((512,512,4),dtype=np.uint8) lawn[:,:,:3] = np.clip(np.array([39,68,43])+noise,0,255) lawn[:,:,3] = 255 city.append(Image.fromarray(lawn)) pack("city-v1.bin",city) night_city,lit_windows = [],0 for index,frame in enumerate(city): if index < 8 or 13 <= index < 21: frame,count = night_facade(frame,2000+index) lit_windows += count night_city.append(frame) pack("city-night-v1.bin",night_city) count = pack_city_district() pack_roof_map() print(f"Packed roof: sunset/night, {lit_windows} distinct lit window apertures, 32 hand frames, {count} buildings.") def pack_city_district(): # A connected street/courtyard district, not a ring of independent props. blocks = [] lots,parks = [],[] for gy in range(-5,6): for gx in range(-5,6): x,y = 20+gx*36,11+gy*36 if gx == 0 and gy == 0: continue # The cross-city canal has continuous quays, not houses over water. if y+14 > 54 and y-14 < 82: continue # Two open squares reserve space around the dome and golden spire. if (gx,gy) in ((0,3),(4,0)): continue key = (gx*17+gy*31)%37 if key%11 == 0 or (gx,gy) in ((1,0),(-1,0)): parks.append((x,y)) continue facade = 186+(gx*3+gy)%8 if max(abs(gx),abs(gy)) <= 1: top = -60+6+key%6 facade = 203 wings = [(x-6,x+5,y-5,y+5)] elif key%7 == 0: top = -60+24+key%17 facade = 199+(key%2)*5 wings = [(x-8,x+8,y-10,y+10)] else: top = -60+12+key%16 wings = [(x-14,x+14,y-14,y-7),(x-14,x+14,y+7,y+14), (x-14,x-7,y-7,y+7),(x+7,x+14,y-7,y+7)] if key%3 == 0: wings = wings[:3] elif key%5 == 0: wings = wings[1:3] lots.append({"centre":[x,y],"courtyard":[x-7,x+7,y-7,y+7], "facade":facade,"height":top+60}) for index,wing in enumerate(wings): blocks.append((*wing,top+(index%2)*.3,facade,index)) # Distant quarters are aggregated geometry, not a scaled city photograph. for gy in range(-8,9): for gx in range(-8,9): if max(abs(gx),abs(gy)) <= 3: continue x,y = 20+gx*64,11+gy*64 if y+24 > 54 and y-24 < 82: continue key = (gx*5+gy*11)%29 top = -60+10+key half = 17+key%10 blocks.append((x-half,x+half,y-24,y+24,top,186+(gx+gy)%8,4)) towers = [(320,-130,90),(355,-165,126),(390,-125,108),(425,-195,155), (460,-145,102),(345,-225,115),(405,-260,132),(475,-240,88)] for index,(x,y,height) in enumerate(towers): blocks.append((x-8,x+8,y-10,y+10,-60+height,200+index%3,2)) blocks.append((x-5,x+5,y-7,y+7,-60+height+7,200+index%3,2)) # The eastern avenue opens into a broad river, not an endless paved corridor. blocks = [b for b in blocks if not (b[1] > 68 and b[2] < 81 and b[3] > 26)] # Far riverfront quarters break the empty, perfectly straight horizon. for side in (-1,1): for i in range(10): x = 570+i*34 y = 6-(i%3)*8 if side < 0 else 106+(i%4)*9 h = 16+(i*7)%24 blocks.append((x-11,x+11,y-10,y+10,-60+h,186+i%8,4)) # Canal clearance removes water footprints, not entire empty city blocks. waterfront = [] for gx in range(-13,2): x = 20+gx*36 for bank in (0,1): key = (gx*19+bank*7)%31 front,back = (53,39-key%4) if bank == 0 else (82,94+key%3) y0,y1 = sorted((front,back)) mid = x-2+key%5 for index,(x0,x1) in enumerate(((x-13,mid-.45),(mid+.45,x+13))): top = -60+12+(key+index*3)%11 waterfront.append((x0,x1,y0,y1,top,186+(gx+bank*3+index)%8,5)) blocks.extend(waterfront) blocks.sort(key=lambda b:(((b[0]+b[1])/2-20)**2+((b[2]+b[3])/2-11)**2)) # Ground-only baked illumination follows the same real building footprints. elevation = np.zeros((512,512),dtype=np.float32) for x0,x1,y0,y1,z,_,_ in blocks: loX,hiX = max(0,math.floor(x0/2)+256),min(512,math.ceil(x1/2)+256) loY,hiY = max(0,math.floor(y0/2)+256),min(512,math.ceil(y1/2)+256) if loX < hiX and loY < hiY: elevation[loY:hiY,loX:hiX] = np.maximum(elevation[loY:hiY,loX:hiX],z+60) occlusion = np.zeros_like(elevation) padded = np.pad(elevation,128) for step in range(1,100): sx,sy = round(-step*.89),round(step*.45) ray_height = step*2*math.tan(math.radians(18)) blocked = padded[128+sy:640+sy,128+sx:640+sx]-ray_height occlusion = np.maximum(occlusion,blocked) shadow = cv2.GaussianBlur(np.where(occlusion>0,155,250).astype(np.uint8),(5,5),1) day = np.empty((512,512,4),dtype=np.uint8) day[:,:,:3] = shadow[:,:,None] day[:,:,3] = 255 lamps = [] for gx in range(-5,6): for gy in range(-6,7): lamps.append((34.8+36*gx,11+30*gy)) lamps += [(x,y) for x in range(-170,211,18) for y in (58.5,77.5)] lamps = [(x,y) for x,y in lamps if not (60 < y < 76 or (x > 68 and 29 < y < 76))] lamps += [(x,y) for x in range(86,650,18) for y in (27,78)] yy,xx = np.mgrid[:512,:512] world_x,world_y = (xx-256)*2,(yy-256)*2 illumination = np.zeros((512,512,3),dtype=np.float32)+[24,30,46] for lx,ly in lamps: energy = np.exp(-((world_x-lx)**2+(world_y-ly)**2)/(2*5.5**2)) illumination += energy[:,:,None]*np.array([215,150,64]) if ly in (27,78,58.5,77.5): reflected = np.exp(-((world_x-lx)**2/(2*1.6**2)+(world_y-ly)**2/(2*13**2))) reflected *= ((world_y>=60)&(world_y<=76)) | ((world_x>=70)&(world_y>=29)&(world_y<=76)) illumination += reflected[:,:,None]*np.array([180,125,60]) night_light = np.empty((512,512,4),dtype=np.uint8) night_light[:,:,:3] = np.clip(illumination,0,255).astype(np.uint8) night_light[:,:,3] = 255 glow = np.empty_like(night_light) glow[:,:,:3] = np.clip((illumination-np.array([24,30,46]))*.38,0,255).astype(np.uint8) glow[:,:,3] = 255 pack("lighting-v1.bin",[Image.fromarray(day),Image.fromarray(night_light),Image.fromarray(glow)]) district = ["; Generated city block footprints and rooftop-relative heights.", f"ROOF_BUILDING_COUNT equ {len(blocks)}", ".data", "roofDistrict3D LABEL DWORD"] for block in blocks: district.append(" dd "+",".join(str(round(v*65536)) if i<5 else str(v) for i,v in enumerate(block))) district += [f"ROOF_PARK_COUNT equ {len(parks)}","roofCityParks3D LABEL DWORD"] district += [f" dd {round(x*65536)},{round(y*65536)}" for x,y in parks] district += [f"ROOF_CITY_LAMP_COUNT equ {len(lamps)}","roofCityLamps3D LABEL DWORD"] district += [f" dd {round(x*65536)},{round(y*65536)}" for x,y in lamps] pieces = [] def landmark_piece(kind,coords,material,pivot,target,scale=2): ox,oy = pivot tx,ty = target if kind == 0: a,b,c,d,e,f = coords coords = ((a-ox)*scale+tx,(b-ox)*scale+tx,(c-oy)*scale+ty,(d-oy)*scale+ty, (e+17)*scale-60,(f+17)*scale-60) else: a,b,c,d,e,f = coords coords = ((a-ox)*scale+tx,(b-oy)*scale+ty,(c+17)*scale-60,d*scale,e*scale,f*scale) pieces.append((kind,*[round(v*65536) for v in coords],material)) dome = lambda kind,coords,mat: landmark_piece(kind,coords,mat,(56,47),(20,119)) dome(0,(47,65,38,56,-17,2),193) dome(0,(50,62,41,53,2,5),168) for index in range(16): angle = index*math.tau/16 x,y = 56+5*math.cos(angle),47+5*math.sin(angle) dome(0,(x,x+.35,y,y+.35,5,8),168) dome(0,(50,62,41,53,8,8.4),196) dome(1,(56,47,8.3,5.9,5.9,5),196) dome(0,(55.4,56.6,46.4,47.6,13,15),166) dome(0,(55.9,56.1,46.9,47.1,15,18),166) spire = lambda kind,coords,mat: landmark_piece(kind,coords,mat,(-16,-61),(164,11)) spire(0,(-23,-9,-68,-54,-17,0),191) spire(0,(-19,-13,-64,-58,0,9),206) spire(0,(-18,-14,-63,-59,9,15),191) spire(2,(-16,-61,15,1.8,1.8,20),166) # The same modest quay geometry is consumed by both native renderers. quays = [] def quay_piece(kind,coords,mat): piece = (kind,*[round(v*65536) for v in coords],mat) pieces.append(piece) quays.append((kind,*coords,mat)) for y0,y1 in ((55,60),(76,81)): quay_piece(0,(-520,70,y0,y1,-62,-59.75),162) edge = 59.65 if y0 < 60 else 76 for a,b in ((-520,-37.6),(-30.4,34.4),(41.6,70)): quay_piece(0,(a,b,edge,edge+.35,-59.75,-58.85),162) quay_piece(0,(a,b,edge-.04,edge+.39,-58.85,-58.7),168) for x in range(-184,68,18): if min(abs(x-b) for b in (-34,38)) < 6: continue for y in (56.7,79.3): quay_piece(0,(x-.12,x+.12,y-.12,y+.12,-59.75,-56.8),162) quay_piece(1,(x,y,-56.8,1.5,1.5,2.1),207) quay_piece(0,(x+3,x+5,y-.25,y+.25,-59.35,-59.15),196) for leg in (x+3.2,x+4.6): quay_piece(0,(leg,leg+.18,y-.2,y+.2,-59.75,-59.35),165) district += [f"ROOF_LANDMARK_PARTS equ {len(pieces)}","roofLandmarkParts3D LABEL DWORD"] district += [" dd "+",".join(map(str,piece)) for piece in pieces] fog = [min(190,round(d*180/(d+250))) for d in range(1024)] for start in range(0,1024,32): district.append(("roofFog3D" if start == 0 else " ")+" db "+ ",".join(map(str,fog[start:start+32]))) district.append(".code") (ROOT/"src_graphics/rooftop_district_data.inc").write_text("\n".join(district)+"\n",encoding="ascii") (ART/"district-map-v1.json").write_text(json.dumps({"extent":[-520,560,-530,550], "casino":[10.9,29.1,.9,21.1],"street_spacing":36,"street_width":8, "street_level":-60,"canal":[-190,230,60,76],"bridges":[-34,38,110], "east_river":[70,1800,29,76],"river_bridges":[110,254,398], "waterfront_buildings":waterfront,"canal_quay_parts":quays, "lots":lots,"parks":parks,"lamps":lamps,"towers":towers,"buildings":blocks, "landmarks":{"dome":[20,119],"spire":[164,11]}},indent=2)+"\n",encoding="ascii") return len(blocks) def pack_roof_map(): # Collision-only enclosure uses a valid legacy prepass material; the final # rooftop pass replaces its tall walls with low world-space railings. roof = [[102]*32 for _ in range(32)] for y in range(1,21): for x in range(11,29): roof[y][x] = 0 lines = ["; Generated by tools/generators/pack_rooftop.py.", ".data"] for y,row in enumerate(roof): lines.append(("roofMap3D" if y == 0 else " ")+" db "+",".join(map(str,row))) columns = [round(math.atan((x/320-1)*.66)*2048/(2*math.pi)) for x in range(640)] for start in range(0,640,16): lines.append(("roofSkyColumns3D" if start == 0 else " ")+" dd "+ ",".join(map(str,columns[start:start+16]))) (ROOT/"src_graphics/rooftop_map.inc").write_text("\n".join(lines)+"\n",encoding="ascii") (ART/"packed-v1.json").write_text(json.dumps({"panorama":[512,512,4], "materials":[256,256,8],"details":[256,256,15],"city":[512,512,22], "city-night":[512,512,22],"night":[512,512,4],"rail-hands":[640,320,32], "lighting":[512,512,3],"city_renderer":"world-space MASM geometry; sky only is raster", "source":"Built-in image generation; runtime geometry and interaction are assembly-only", "reference":"https://www.loftprojectetagi.ru/visitors/krysha/"},indent=2)+"\n",encoding="ascii") if __name__ == "__main__": import argparse parser = argparse.ArgumentParser() parser.add_argument("--district-only",action="store_true") if parser.parse_args().district_only: print(f"Packed city district: {pack_city_district()} building parts") else: main()