Build Capital Quest for CoCo 3 with maps and emulator tests
This commit is contained in:
commit
9f7d6f4786
44 files changed
+6238
No files matched your search
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline, standard-library-only conversion of Fla-shop SVG to identity spans.
|
||||
SVG input uses straight M/L/H/V/Z paths. Flood regions at original resolution,
|
||||
associate via documented seed coordinates, then reduce to guest scanline runs.
|
||||
"""
|
||||
import collections, json, math, re, struct, zlib
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
W,H=2000,1200
|
||||
rows=[s.split('\t') for s in (ROOT/'data/states.tsv').read_text().splitlines()]
|
||||
assert len(rows)==50 and len({r[0] for r in rows})==50
|
||||
ids={r[0]:i+1 for i,r in enumerate(rows)}
|
||||
|
||||
def paths(d):
|
||||
tok=re.findall(r'[MmLlHhVvZz]|[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?',d)
|
||||
i=0;x=y=0;out=[];poly=[];cmd=''
|
||||
while i<len(tok):
|
||||
if tok[i].isalpha():
|
||||
cmd=tok[i];i+=1
|
||||
if cmd.lower()=='z':
|
||||
if poly:poly.append(poly[0]);out.append(poly);poly=[]
|
||||
x,y=out[-1][0];continue
|
||||
rel=cmd.islower(); c=cmd.lower()
|
||||
if c in ('m','l'):
|
||||
a,b=map(float,tok[i:i+2]);i+=2
|
||||
x,y=(x+a,y+b) if rel else (a,b)
|
||||
if c=='m':
|
||||
if poly:out.append(poly)
|
||||
poly=[];cmd='l' if rel else 'L'
|
||||
elif c=='h':
|
||||
a=float(tok[i]);i+=1;x=x+a if rel else a
|
||||
elif c=='v':
|
||||
a=float(tok[i]);i+=1;y=y+a if rel else a
|
||||
else:raise ValueError(cmd)
|
||||
poly.append((x,y))
|
||||
if poly:out.append(poly)
|
||||
return out
|
||||
|
||||
def png(path,w,h,pixels):
|
||||
def chunk(tag,b):return struct.pack('>I',len(b))+tag+b+struct.pack('>I',zlib.crc32(tag+b)&0xffffffff)
|
||||
raw=b''.join(b'\0'+bytes(pixels[y*w*3:(y+1)*w*3]) for y in range(h))
|
||||
path.write_bytes(b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0))+chunk(b'IDAT',zlib.compress(raw))+chunk(b'IEND',b''))
|
||||
|
||||
def main():
|
||||
svg=ET.parse(ROOT/'assets/usa-source.svg').getroot()
|
||||
elements={e.get('id'):e for e in svg.iter() if e.get('id')}
|
||||
land=bytearray(W*H)
|
||||
outline=paths(elements['outline'].get('d'))
|
||||
# Even-odd scan conversion, coast and islands included.
|
||||
edges=[(a,b) for p in outline for a,b in zip(p,p[1:]+p[:1]) if a[1]!=b[1]]
|
||||
buckets=[[] for _ in range(H)]
|
||||
for a,b in edges:
|
||||
for y in range(max(0,math.ceil(min(a[1],b[1])-.5)),min(H,math.ceil(max(a[1],b[1])-.5))):
|
||||
buckets[y].append(a[0]+(y+.5-a[1])*(b[0]-a[0])/(b[1]-a[1]))
|
||||
for y,xx in enumerate(buckets):
|
||||
xx.sort()
|
||||
for a,b in zip(xx[::2],xx[1::2]):
|
||||
lo=max(0,math.ceil(a-.5));hi=min(W,math.ceil(b-.5));land[y*W+lo:y*W+hi]=b'\1'*(hi-lo)
|
||||
coast=bytearray(land)
|
||||
# Three-source-pixel boundary stroke closes subpixel joins before flood fill.
|
||||
for p in paths(elements['boundaries'].get('d')):
|
||||
for a,b in zip(p,p[1:]):
|
||||
n=max(1,math.ceil(max(abs(a[0]-b[0]),abs(a[1]-b[1]))))
|
||||
for t in range(n+1):
|
||||
x=round(a[0]+(b[0]-a[0])*t/n);y=round(a[1]+(b[1]-a[1])*t/n)
|
||||
for yy in range(max(0,y-1),min(H,y+2)):
|
||||
for xx in range(max(0,x-1),min(W,x+2)):land[yy*W+xx]=0
|
||||
seeds={}
|
||||
for e in svg.iter():
|
||||
if e.tag.endswith('tspan'):
|
||||
ab=''.join(e.itertext()).strip()
|
||||
if ab in ids:seeds[ab]=[round(float(e.get('x'))+15),round(float(e.get('y'))-12)]
|
||||
# Callout labels are outside their states. Seed actual interiors instead.
|
||||
seeds.update({'VT':[1800,280],'NH':[1840,280],'MA':[1830,340],
|
||||
'RI':[1860,360],'CT':[1830,375],'NJ':[1780,440],
|
||||
'DE':[1764,509],'MD':[1720,536],'HI':[739,1130]})
|
||||
seeds.update(json.loads((ROOT/'data/map-seeds.json').read_text()) if (ROOT/'data/map-seeds.json').exists() else {})
|
||||
owners=bytearray(W*H);claimed={};counts={}
|
||||
for ab,(x,y) in seeds.items():
|
||||
start=y*W+x
|
||||
assert land[start],(ab,'seed is not land',x,y)
|
||||
|
||||
assert not owners[start],(ab,'shares component with',claimed.get(owners[start]))
|
||||
val=ids[ab];q=collections.deque([start]);owners[start]=val;count=0
|
||||
while q:
|
||||
pos=q.popleft();count+=1
|
||||
for nxt in (pos-1,pos+1,pos-W,pos+W):
|
||||
if 0<=nxt<W*H and land[nxt] and not owners[nxt]:owners[nxt]=val;q.append(nxt)
|
||||
claimed[val]=ab;counts[ab]=count
|
||||
# Unassigned land = detached islands/peninsulas: nearest assigned land,
|
||||
# using a multi-source breadth-first expansion. The source mask is retained.
|
||||
q=collections.deque(i for i in range(W*H) if owners[i] and any(0<=j<W*H and not owners[j] for j in (i-1,i+1,i-W,i+W)))
|
||||
nearest=bytearray(owners)
|
||||
while q:
|
||||
p=q.popleft()
|
||||
for n in (p-1,p+1,p-W,p+W):
|
||||
if 0<=n<W*H and not nearest[n]:nearest[n]=nearest[p];q.append(n)
|
||||
# Detached Alaska/Hawaii insets and Michigan's Upper Peninsula require
|
||||
# explicit geographic regions; proximity alone mislabels small islands.
|
||||
# Assign detached original land by geographic ownership, not nearest capital.
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
p=y*W+x
|
||||
if land[p] and not owners[p]:
|
||||
if 560<x<785 and 995<y<1190:owners[p]=ids['HI']
|
||||
elif x<560 and y>850:owners[p]=ids['AK']
|
||||
elif 1175<x<1465 and 180<y<330:owners[p]=ids['MI']
|
||||
else:owners[p]=nearest[p]
|
||||
(ROOT/'data/map-seeds.json').write_text(json.dumps(seeds,indent=2)+'\n')
|
||||
# Three views share the exact same owner raster and stable TSV IDs.
|
||||
views=[('national',200,110,(0,0,2000,1200)),('northeast',100,100,(1540,120,1980,600)),('hawaii',100,82,(565,995,785,1185))]
|
||||
arrays=[];meta={};preview=[]
|
||||
for name,w,h,(x0,y0,x1,y1) in views:
|
||||
small=[]
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
# Sample one source interior pixel at pixel center.
|
||||
sx=int(x0+(x+.5)*(x1-x0)/w);sy=int(y0+(y+.5)*(y1-y0)/h)
|
||||
small.append((owners[sy*W+sx] or nearest[sy*W+sx]) if coast[sy*W+sx] else 0)
|
||||
if name=='hawaii':small=[v if v==ids['HI'] else 0 for v in small]
|
||||
# One guest pixel on the right/bottom of a state seam gives continuous
|
||||
# boundaries. Preserve tiny national masks; their detail view is larger.
|
||||
areas=collections.Counter(small); original=small[:]
|
||||
for y in range(h-1):
|
||||
for x in range(w-1):
|
||||
pos=y*w+x;val=original[pos]
|
||||
if val and areas[val]>8 and any(v and v!=val for v in (original[pos+1],original[pos+w])):
|
||||
small[pos]=0
|
||||
spans=[];offsets=[0];total={}
|
||||
for val in range(1,51):
|
||||
cnt=0
|
||||
for y in range(h):
|
||||
x=0
|
||||
while x<w:
|
||||
if small[y*w+x]!=val:x+=1;continue
|
||||
first=x
|
||||
while x<w and small[y*w+x]==val:x+=1
|
||||
spans.extend([y,first,x-first]);cnt+=x-first
|
||||
offsets.append(len(spans));total[rows[val-1][0]]=cnt
|
||||
arrays.append('const unsigned char '+name+'_spans[] = {\n'+','.join(map(str,spans))+'\n};\nconst unsigned '+name+'_offsets[51] = {'+','.join(map(str,offsets))+'};\n')
|
||||
meta[name]={'size':[w,h],'source_box':[x0,y0,x1,y1],'pixels':total,'bytes':len(spans)+102}
|
||||
colors=[(8,19,34),(44,116,142),(73,145,155),(103,164,169),(36,92,126)]
|
||||
pix=[]
|
||||
for v in small:pix.extend(colors[0] if not v else colors[1+(v%4)])
|
||||
png(ROOT/'assets'/('preview-'+name+'.png'),w,h,pix)
|
||||
assert all(meta['national']['pixels'].values()),meta['national']['pixels']
|
||||
(ROOT/'src/map_data.c').write_text('/* Adapted from Fla-shop.com, CC BY 4.0. See assets/CREDITS.md. */\n#include "map_data.h"\n'+''.join(arrays))
|
||||
(ROOT/'include/map_data.h').write_text('\n'.join('extern const unsigned char '+n+'_spans[];\nextern const unsigned '+n+'_offsets[51];' for n,*_ in views)+'\n')
|
||||
(ROOT/'src/states.c').write_text('#include "quiz.h"\nconst State states[50] = {\n'+',\n'.join('{"%s","%s","%s"}'%tuple(r) for r in rows)+'\n};\n')
|
||||
meta['source_component_pixels']=counts
|
||||
(ROOT/'data/map-manifest.json').write_text(json.dumps(meta,indent=2)+'\n')
|
||||
print({n:meta[n]['bytes'] for n,*_ in views})
|
||||
if __name__=='__main__':main()
|
||||
Reference in new issue
Block a user