Tessellate terrain and light-map surfaces for vertex-fog drivers, render animated water from the tiled mesh, request accurate fog and extend renderer diagnostics. Add canal-front buildings, raised quays, trees and benches while preserving the terrace and native runtime. Update native regression coverage and shipping executables. Exclude laptop screenshots and local runtime data.
419 lines
21 KiB
Python
419 lines
21 KiB
Python
"""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()
|