Files

290 lines
18 KiB
Python

"""The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright; ensure_playwright()
from browser import launch, consume_gpu_option
consume_gpu_option()
root = pathlib.Path(__file__).resolve().parent.parent
out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'smooth'); out.mkdir(parents=True, exist_ok=True)
fails = []
def check(name, cond, extra=''):
print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else ''))
if not cond: fails.append(name)
with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
logs = []
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type in ('error', 'warning') else None)
pg.add_init_script('localStorage.clear();')
pg.goto(url); pg.evaluate('CAPS_CHECK'); pg.evaluate('SET.fog = false'); pg.wait_for_timeout(300)
pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
pg.wait_for_function('GAME.running === true', timeout=20000); pg.wait_for_timeout(1500)
ev = pg.evaluate
check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && GPU.ok && R.gpuOn && R.smoothOn"), ev('[SET.look, GPU.ok, GPU.error, R.smoothOn]'))
# the canvas shows the GPU image: sample a sky pixel and a ground pixel, they must differ and not be black
px = ev("""(() => { const c = worldPixelContext().getImageData(R.canvas.width >> 1, 4, 1, 1).data, g = worldToScreenPx(bot.x + 2, surfY[Math.floor(bot.x)] + 6), d = worldPixelContext().getImageData(Math.round(g.x), Math.round(g.y), 1, 1).data; return [Array.from(c), Array.from(d)]; })()""")
check('it draws sky and ground', sum(px[0][:3]) > 200 and px[0][:3] != px[1][:3], px)
pg.screenshot(path=str(out / 'smooth.png'))
ev("player.lens = true"); pg.keyboard.press('h'); pg.wait_for_timeout(1500)
check('the thermal lens draws in pixels', ev('R.lens && !R.smoothOn'))
pg.keyboard.press('h'); pg.wait_for_timeout(1500)
check('and the smooth look comes back after it', ev('!R.lens && R.smoothOn'), ev('[R.lens, R.smoothOn, SET.look, GPU.ok, GPU.error]'))
ev("SET.look = 'pixel'"); pg.wait_for_timeout(1500)
check('the pixel look can be chosen', ev('!R.smoothOn'))
pg.screenshot(path=str(out / 'pixel.png'))
ev("SET.look = 'smooth'"); pg.wait_for_timeout(1500)
# Uniform colours isolate the solid/loose seam from material noise and lighting.
ground = ev('''() => {
GAME.paused = true; requestAnimationFrame = () => 0;
SET.lighting = 'off'; SET.glow = 'off'; SET.grain = 0; SET.zoom = 6;
resizeRender(); bot.x = 138; bot.y = 118; snapCamera();
for (let y = 60; y < 220; y++) for (let x = 40; x < 240; x++) {
const i = y * W + x;
setCell(i, STONE, 22, F_NAT); bgm[i] = 0; noiseB[i] = 128;
}
GPU.world = null; // The fixture changed static noise in place.
const fill = m => { for (let y = 100; y < 140; y++) for (let x = 100; x < 180; x++) setCell(y * W + x, m, 22); };
const at = (x, y) => { const p=worldToScreenPx(x+0.5,y+0.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]+c[1]+c[2]; };
const out = {};
for (const lens of [false, true]) {
R.lens = lens; SET.look = 'pixel'; fill(RSTONE); renderDirty.fill(1); GPU.world=null; drawWorld();
const edge = at(140, 140), inside = at(140, 142), loose = at(140, 130);
fill(STONE); renderDirty.fill(1); GPU.world=null; drawWorld();
out[lens ? 'thermal' : 'pixel'] = [edge, inside, loose, at(140, 140)];
}
R.lens = false;
const i = 140 * W + 140;
flags[i] = 0; fill(RSTONE); renderDirty.fill(1); GPU.world=null; drawWorld(); const built = at(140, 140);
fill(STONE); renderDirty.fill(1); GPU.world=null; drawWorld(); out.built = [built, at(140, 140)]; flags[i] = F_NAT;
fill(RSTONE); SET.look = 'smooth'; renderDirty.fill(1); GPU.world=null; drawWorld();
const sample = (x, y) => {
const p = worldToScreenPx(x, y);
const c = worldPixelContext().getImageData(Math.floor(p.x), Math.floor(p.y), 1, 1).data;
return c[0] + c[1] + c[2];
};
out.smooth = [sample(140.5, 140.08), sample(140.5, 142.5)];
const packed = [];
for (let y = 120; y < 121; y += 1 / 6) for (let x = 140; x < 141; x += 1 / 6) packed.push(sample(x, y));
out.texture = Math.max(...packed) - Math.min(...packed);
return out;
}''')
for look in ['pixel', 'thermal']:
edge, inside, loose, solid = ground[look]
check(f'{look}: ground has a darker edge against loose grains', edge < inside * 0.85 and inside == solid, ground[look])
check(f'{look}: loose rubble is lighter than solid stone', loose > inside, ground[look])
check('built solids get no loose-grain outline', ground['built'][0] == ground['built'][1], ground['built'])
check('smooth: a thin darker seam separates ground and rubble', ground['smooth'][0] < ground['smooth'][1] * 0.9, ground['smooth'])
check('smooth: packed rubble keeps pebble shading', ground['texture'] > 30, ground['texture'])
# Cursor labels and outlines use a paused scene and the real drawing functions.
cursor = ev("""() => {
SET.look='pixel'; SET.cursorLabel='ores'; SET.fog=false; SET.zoom=8;
resizeRender(); GAME.tool=0; GAME.mouse.in=true; GAME.usingPad=false;
bot.x=118; bot.y=114; snapCamera(); player.lens=false; R.lens=false;
for(let y=110;y<130;y++)for(let x=110;x<140;x++) {
const i=y*W+x; setCell(i,STONE,22,F_NAT); part[i]=0; mach[i]=0; occ[i]=0; bgm[i]=0;
blockP[(y>>2)*BW+(x>>2)]=0;
}
for(let y=118;y<126;y++)for(let x=120;x<132;x++)
if(x+y>239 && x+y<255)setCell(y*W+x,CUV,22,F_NAT);
setCell(116*W+116,SAND,22,0);
GAME.aim={x:124,y:122}; cursorLabelKey=null;
const ctx=R.ctx,text=ctx.fillText,stroke=ctx.stroke,now=performance.now.bind(performance);
let time=1000,calls=[],strokes=0;
performance.now=()=>time;
ctx.fillText=function(t){calls.push([t,this.globalAlpha,this.fillStyle]);};
ctx.stroke=function(){strokes++;};
const label=()=>{calls=[];drawCursorLabel(500,300,1);return calls.slice();};
try {
GAME.aim={x:116,y:116};const sand=label();
GAME.aim={x:124,y:122};const copper=label();drawHoveredVein();const outlined=strokes;
const vein=cursorVeinCache;
GAME.aim={x:125,y:122};time=3200;const fading=label();
const reused=cursorVeinCache===vein;
time=3400;const faded=label();
setCell(116*W+117,FEV,22,F_NAT); GAME.aim={x:117,y:116};
const hard=label();time=6000;const warning=label();
GAME.aim={x:124,y:122}; SET.fog=true;fog.fill(0);
GAME.ping={x:124,y:122,t0:6000,r:48,cells:[122*W+124]};
const ping=hoverInfo(124,122),pingLabel=label();renderInfo();
const panel=$('info').textContent;
time=8000;const hidden=hoverInfo(124,122).t1;
GAME.ping=null;SET.fog=false; SET.cursorLabel='off';
const off=label();strokes=0;drawHoveredVein();
return {sand,copper,outlined,reused,fading,faded,hard,warning,ping,pingLabel,panel,hidden,off,offStrokes:strokes};
} finally {ctx.fillText=text;ctx.stroke=stroke;performance.now=now;}
}""")
check('ores mode skips sand and draws the copper vein name', not cursor['sand'] and any(t[0] == 'Copper ore vein' for t in cursor['copper']), cursor['copper'])
check('hovering a vein draws an outline and reuses it within the vein', cursor['outlined'] > 0 and cursor['reused'])
check('the name fades then disappears while staying on the vein', any(0 < t[1] < 1 for t in cursor['fading']) and not cursor['faded'], cursor['fading'])
check('the needed bit stays red after the name fades', any(t[0].startswith('needs ') and t[1] == 1 and t[2] == '#ef6a55' for t in cursor['warning']), cursor['warning'])
check('a pinged vein under fog names itself in the label and corner panel', cursor['ping']['t1'] == 'Copper ore vein' and 'found by the ping' in cursor['panel'] and any('found by the ping' in t[0] for t in cursor['pingLabel']), cursor['pingLabel'])
check('a faded ping hides the vein again', cursor['hidden'] == 'Unexplored')
check('off hides both label and vein outline', not cursor['off'] and cursor['offStrokes'] == 0)
for look in ['pixel', 'smooth']:
ev("""look => {
SET.look=look; SET.cursorLabel='ores'; GAME.aim={x:124,y:122}; cursorLabelKey=null;
$('cards').replaceChildren(); renderInfo(); renderDirty.fill(1); GPU.world=null; drawWorld(); drawHoveredVein(); drawBot();
const p=worldToScreenPx(124,122); drawCursorLabel(p.x,p.y,1);
}""", look)
pg.screenshot(path=str(root / 'shots' / ('t77-vein.png' if look == 'pixel' else 't77-vein-smooth.png')))
wall = pg.evaluate('''() => {
GAME.paused = true; R.lens = false; SET.fog = false;
SET.lighting = 'off'; SET.glow = 'off'; SET.grain = false;
for(let y=20;y<150;y++)for(let x=40;x<240;x++)setCell(y*W+x,EMPTY,22,0);
for(let k=0;k<6;k++)placeBlock(BP_BELT_UP,1,25+k,25-k,true);
rebuildPower();slopeCleatsTick(false);prepareSlopeBelts(40,20,240,150);
let cell=-1;
for(let y=40;y<100 && cell<0;y++)for(let x=100;x<124;x++)
if(slopeDetailAt(y*W+x,x,y)===2){cell=y*W+x;break;}
if(cell<0)return null;
const x=cell%W,y=(cell/W)|0,control=80*W+180;
bot.x=118;bot.y=78;snapCamera();
setCell(cell,SAND,22,0);setCell(control,SAND,22,0);
noiseB[cell]=noiseB[control]=128; GPU.world=null;
const at=i=>{ if(R.smoothOn) return R.px[(((i/W)|0)-R.vy0)*R.bw+i%W-R.vx0]; const p=worldToScreenPx(i%W+0.5,((i/W)|0)+0.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]|c[1]<<8|c[2]<<16; };
SET.look='pixel';renderDirty.fill(1); GPU.world=null; drawWorld();const colors=[at(cell)&0xffffff,at(control)&0xffffff];
SET.look='smooth';renderDirty.fill(1); GPU.world=null; drawWorld();const grainClass=at(cell)>>>24;
return {colors,grainClass};
}''')
check('sidewall stays behind the grain colour in pixels and smooth',
wall and wall['colors'][0] == wall['colors'][1] and wall['grainClass'] == 2, wall)
# Travel joins: edge elbows in diagonals, E elbows in straight receivers, otherwise collars.
pg.set_viewport_size({"width": 2100, "height": 1500})
ev("part.fill(0); blockP.fill(0); occ.fill(0); mach.fill(0); ents=[]; ghostBlocks.clear();")
ev("() => {" + (root / "tools/scenes/joins.js").read_text() + "}")
mouths = ev('''() => {
SET.zoom=4; SET.lighting="off"; SET.glow="off"; resizeRender();
bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2;snapCamera();
const now=performance.now.bind(performance),frozen=now();
performance.now=()=>frozen;
try {
const out={};
for(const look of ["pixel","smooth"]) {
SET.look=look;renderDirty.fill(1); GPU.world=null; drawWorld();
const bad=[], copper=c=>c[0]>c[1]*1.15 && c[1]>c[2]*1.1;
const sample=(x,y)=>{const p=worldToScreenPx(x,y);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);};
for(const j of JOIN_CASES.filter(j=>j.code===BP_SCREW)) {
const slope=j.v===4 || j.v===6?-1:1, side=j.end?1:-1;
const joins=diagonalScrewJoins(j.bx,j.by,j.v);
if(!j.joined) {
if(!(joins & (64<<j.end)) || (joins>>(j.end*3)&7))bad.push(['missing free collar',j]);
// The middle of the free cap, safely inside its diagonal block.
const cap=look==='pixel' ? [[1.5,2.5],[2.5,1.5]] : [[1.8,1.8]];
const colors=cap.map(([x,y])=>sample(j.bx*4+2+side*x,j.by*4+2+side*slope*y));
if(!colors.some(copper))bad.push(['free collar colour',j,colors]);
continue;
}
const ex=(j.kind>=5?j.nx:j.bx)*4+2,ey=(j.kind>=5?j.ny:j.by)*4+2;
const dx=j.vertical?0:side,dy=j.vertical?side*slope:0;
// Both walls on the outgoing axis, beyond the rounded meeting point.
for(const sign of [-1,1]) {
const x=ex+dx*1.5-dy*sign*1.6,y=ey+dy*1.5+dx*sign*1.6,c=sample(x,y);
if(!copper(c))bad.push(['open casing',j,x,y,c]);
}
// Follow both centrelines through the join: the bore must never show sky.
const ax=j.bx*4+2-side*1.5,ay=j.by*4+2-side*slope*1.5;
for(let t=0;t<=1;t+=0.05)for(const leg of [0,1]) {
const x=leg?ex+dx*t*5:ax+(ex-ax)*t,y=leg?ey+dy*t*5:ay+(ey-ay)*t,c=sample(x,y);
if(c[2]>c[0]*1.4 && c[2]>110)bad.push(['background inside tube',j,x,y,c]);
}
if(j.kind>=5) {
// The unused corner of the bent straight block must become background.
const x=j.nx*4+2+(j.vertical?side:-side)*1.8;
const y=j.ny*4+2+(j.vertical?-side*slope:side*slope)*1.8,c=sample(x,y);
if(copper(c) || c[2]<c[0]*1.2)bad.push(['square casing outside elbow',j,x,y,c]);
}
}
out[look]=bad;
}return out;
} finally {performance.now=now;}
}''')
for look, bad in mouths.items():
check(f"{look}: shared screw joins have closed elbows, free collars and no bore holes", not bad, bad[:2])
previews = ev('''() => {
const bad=[], now=performance.now.bind(performance),frozen=now();
performance.now=()=>frozen;
try {
for(const look of ['pixel','smooth'])for(const j of JOIN_CASES.filter(j=>j.code===BP_SCREW && j.joined && j.kind>=5 && (j.v===5&&!j.vertical || j.v===4&&j.vertical))) {
SET.look=look;
const side=j.end?1:-1,slope=j.v===4||j.v===6?-1:1;
const ux=2+(j.vertical?side:-side)*1.8,uy=2+(j.vertical?-side*slope:side*slope)*1.8;
const b=j.by*BW+j.bx,nb=j.ny*BW+j.nx;
const plan=new Map([[b,[BP_SCREW,j.v]],[nb,[BP_SCREW,j.straight]]]);
removeBlock(j.bx,j.by);removeBlock(j.nx,j.ny);
const sample=(x,y)=>{const p=worldToScreenPx(j.nx*4+x,j.ny*4+y);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data).join(',');};
renderDirty.fill(1); GPU.world=null; drawWorld();const bg=sample(ux,uy),empty=sample(2,2);
for(const [n,piece] of plan)ghostBlocks.set(n,piece);
renderDirty.fill(1); GPU.world=null; drawWorld();
if(sample(ux,uy)!==bg || sample(2,2)===empty)bad.push(['ghost elbow silhouette',look,j]);
ghostBlocks.clear();
const ctx=R.ctx,canvas=document.createElement('canvas');canvas.width=canvas.height=192;
try {
R.ctx=canvas.getContext('2d');R.ctx.fillStyle='#ffffff';
previewBlock(BP_SCREW,64,64,64,64,false,j.straight,j.nx,j.ny,plan);
const alpha=(x,y)=>R.ctx.getImageData(Math.floor(64+x*16),Math.floor(64+y*16),1,1).data[3];
if(alpha(ux,uy)!==0 || alpha(2,2)===0)bad.push(['planned preview elbow silhouette',look,j]);
} finally {R.ctx=ctx;}
placeBlock(BP_SCREW,j.v,j.bx,j.by,true);placeBlock(BP_SCREW,j.straight,j.nx,j.ny,true);
}
} finally {performance.now=now;ghostBlocks.clear();}
return bad;
}''')
check('both looks: ghosts and planned previews leave the unused elbow corner empty', not previews, previews[:2])
tree_tags = ev('''() => {
SET.look='smooth';
const now=performance.now.bind(performance),frozen=now();performance.now=()=>frozen;
try {
const j=JOIN_CASES.find(j=>j.v===5 && j.end===1 && !j.vertical && j.spot===2 && j.code===BP_SCREW);
const bx=j.nx+1,by=j.ny;
const sample=()=>{const p=worldToScreenPx(bx*4,by*4);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),4*R.zp,4*R.zp).data).join(',');};
renderDirty.fill(1); GPU.world=null; drawWorld();const plain=sample(),bad=[];
for(const m of [WOOD,LEAVES]) {
for(let y=by*4;y<by*4+4;y++)for(let x=bx*4;x<bx*4+4;x++)bgm[y*W+x]=m;
renderDirty.fill(1); GPU.world=null; drawWorld();if(sample()!==plain)bad.push(m);
}
for(let y=by*4;y<by*4+4;y++)for(let x=bx*4;x<bx*4+4;x++)bgm[y*W+x]=0;
return bad;
} finally {performance.now=now;}
}''')
check('smooth: trees behind a straight screw cannot become elbow join bits', not tree_tags, tree_tags)
drops = ev("""() => {
const detail=slopeDetailAt,now=performance.now.bind(performance),frozen=now(),out={};
const j=JOIN_CASES.find(j=>j.v===4&&j.end===0&&j.code===BP_BELT&&j.spot===0);
try {
slopeDetailAt=()=>0;performance.now=()=>frozen;
const sample=()=>{const p=worldToScreenPx(j.bx*BS+0.4,j.by*BS+2.5);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);};
for(const look of ['pixel','smooth']) {
SET.look=look;renderDirty.fill(1); GPU.world=null; drawWorld();const joined=sample();
removeBlock(j.nx,j.ny);renderDirty.fill(1); GPU.world=null; drawWorld();const free=sample();
placeBlock(BP_BELT,j.straight,j.nx,j.ny,true);
out[look]=[joined,free];
}
return out;
} finally {slopeDetailAt=detail;performance.now=now;}
}""")
for look, (joined, free) in drops.items():
check(f'{look}: the same-row uphill inlet is a drop, drawn like a free low end', joined == free, [joined, free])
filter_plans = ev("""() => {
const saved={...GAME},original=slopeBeltConnections,seen=[];
try {
GAME.tool=TOOLS.findIndex(t=>t.id==='build');GAME.pieceIdx=PIECES.findIndex(p=>p.id==='filter');
GAME.selDrag=null;GAME.paste=false;GAME.filterCfg={m:[],inv:0};GAME.pieceDir={filter:1};
slopeBeltConnections=(x,y,c,p)=>{
if(p)seen.push([c,p.get(y*BW+x)?.[1]]);
return original(x,y,c,p);
};
for(const grade of [-1,1]) {
GAME.drag={bx:50,by:30};GAME.aim={x:52*BS,y:(30+2*grade)*BS};
drawPreviews();
}
return seen;
} finally {slopeBeltConnections=original;Object.assign(GAME,saved);}
}""")
check('filter drag previews pass stored travel bits to the join rule',
bool(filter_plans) and all(v == (128 if c == ev('BP_FILTER_DOWN') else 0) for c, v in filter_plans), filter_plans)
errs = [l for l in logs if 'PAGEERROR' in l or l.startswith('error')]
check('no errors', not errs, errs[:3])
b.close()
print('FAILS:', fails)
sys.exit(1 if fails else 0)