feat(levels): assemble the 50-level campaign and the sheet to review it

The deep-lock construction cannot go below three turns, so it cannot supply an
opening band — every level it makes lands mid-difficulty and the curve comes
out flat. harvest-easy pulls the shallow end from the shipped v0.5 set, but
only the 3% of it that holds up: 568 of 1000 need no rotation at all and 51
more are clearable by tapping at random, which is what the monkey threshold is
for. 33 levels survive, at one and two turns.

select-50 scores candidates on mechanical features only, spreads the pick
evenly across that range, and caps any one route grammar at 36% so the campaign
cannot quietly become one template repeated — the shipped 1000 turned out to be
thirty. Weights are a flag, so refitting them from a round of agent ratings is
a re-run rather than an edit.

The result is a curve rather than a plateau: difficulty score 3.85 / 6.24 /
7.60 across the three bands, rotations 1.8 / 3.0 / 3.5, and every level keeps
three or more opening choices with a show of at least a second.

The 50 register as a playable campaign edition, so review happens in the real
game; docs/review-50.html is the handover sheet, one card per level with the
opening position drawn from the solver rather than sketched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 16:53:57 +08:00
co-authored by Claude Opus 5
parent 0aa080c7c3
commit 96e5e4c21f
15 changed files with 87896 additions and 47 deletions
+134 -17
View File
@@ -1,4 +1,4 @@
import { drawStyledToken } from './token-art.js';
import { drawStyledToken, tint } from './token-art.js';
import { appearanceTile } from './art-directions.js';
import { exitTiming, smoothMotion, travelProgress } from './exit-animation.js';
import { THEME, PIGMENTS, pigmentIndex } from './design-tokens.js';
@@ -8,9 +8,14 @@ import { collisionPhase, contactPath } from './collision-animation.js';
import { center, hexPoints, trackPoints, routePoints, pointAlong } from './geometry.js';
import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js';
export const COLORS=PIGMENTS.map(p=>p.color);
// Macaron's exit ends in a bubble dissolve that needs more time than the shared 180 ms effect window.
const MACARON_DISSOLVE_MS=560;
export function exitDuration(route,artStyle){const timing=exitTiming(route);return artStyle==='macaron'?timing.travelMs+MACARON_DISSOLVE_MS:timing.duration;}
// Four-point sparkle star used by the macaron win celebration.
const sparkle=(x,y,r)=>Array.from({length:8},(_,j)=>{const b=j*Math.PI/4-Math.PI/2,rr=j%2?r*.38:r;return {x:x+Math.cos(b)*rr,y:y+Math.sin(b)*rr};});
// Scene commands remain engine-independent. sprite() is an optional atlas adapter.
export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,scale=1) {
const r=size*(tile.artStyle==='paper'?.88:TILE_RADIUS)*scale;
const r=size*(tile.artStyle==='paper'?.88:tile.artStyle==='signpost'?.90:tile.artStyle==='macaron'?.90:TILE_RADIUS)*scale;
if(drawStyledToken(p,x,y,r,tile,angle))return;
const textured=p.sprite?.(tile.color,x,y,r)===true;
if(!textured){
@@ -25,7 +30,8 @@ export function drawScene(p,level,tiles,view,state={}) {
const {size}=view;
const visualTile=tile=>appearanceTile({...tile,color:pigmentIndex(state.theme?Math.max(0,level.tiles.findIndex(t=>t.id===tile.id)):tile.color,state.theme)},state.theme);
const materials={...THEME,...state.theme?.materials};
const paper=state.theme?.artStyle==='paper';
const paper=state.theme?.artStyle==='paper',signpost=state.theme?.artStyle==='signpost',macaron=state.theme?.artStyle==='macaron';
const roadbedWidth=Math.min(18,Math.max(5,size*.30)),railWidth=Math.min(7,Math.max(2.5,size*.105)),gummyWidth=Math.min(6.5,Math.max(2.2,size*.11));
const angle=state.boardAngle||0,cos=Math.cos(angle),sin=Math.sin(angle);
const rotate=points=>points.map(pt=>{const x=pt.x-view.ox,y=pt.y-view.oy;return {x:view.ox+x*cos-y*sin,y:view.oy+x*sin+y*cos};});
const base={polygon:(pts,c)=>p.polygon(rotate(pts),c),line:(pts,c,w)=>p.line(rotate(pts),c,w)};
@@ -44,47 +50,121 @@ export function drawScene(p,level,tiles,view,state={}) {
const movingIds=new Set(motions.map(a=>a.route.tile.id));
const cellKeys=new Set(level.cells.map(c=>key(c.q,c.r)));
const sorted=[...level.cells].sort((a,b)=>center(a,view).y-center(b,view).y);
// Macaron: cells pop in as a wave from the top-left corner (state.intro 0–1); without an intro every cell is at scale 1.
const cellScale=(()=>{
if(!macaron||!(state.intro<1))return ()=>1;
const cs=level.cells.map(c=>({c,p:center(c,view)})),xs=cs.map(o=>o.p.x),ys=cs.map(o=>o.p.y),x0=Math.min(...xs),x1=Math.max(...xs),y0=Math.min(...ys),y1=Math.max(...ys);
const order=new Map(cs.map(({c,p})=>[key(c.q,c.r),((x1>x0?(p.x-x0)/(x1-x0):0)+(y1>y0?(p.y-y0)/(y1-y0):0))/2]));
return cell=>{const t=Math.max(0,Math.min(1,(state.intro-order.get(key(cell.q,cell.r))*.55)/.45)),u=t-1;return t<=0?0:1+2.2*u*u*u+1.2*u*u;};
})();
// Macaron: cell tints follow concentric rings from the board centre, so they stay put through every rotation.
const ringTint=(()=>{
if(!macaron)return ()=>materials.board;
const rings=materials.rings||[materials.board],cq=level.cells.reduce((a,c)=>a+c.q,0)/level.cells.length,cr=level.cells.reduce((a,c)=>a+c.r,0)/level.cells.length;
let origin=level.cells[0],best=Infinity;for(const c of level.cells){const d=Math.hypot(c.q-cq,c.r-cr);if(d<best){best=d;origin=c;}}
return cell=>{const dq=cell.q-origin.q,dr=cell.r-origin.r;return rings[Math.max(Math.abs(dq),Math.abs(dr),Math.abs(dq+dr))%rings.length];};
})();
// Macaron: cells along a previewed or travelling route blush in the token's own tint; a blocked cell blushes orange-cream.
// A tapped route carries its tile; a traced preview does not, so the tile is looked up at the route's first step.
const routeFill=route=>{const start=route.steps?.[0],tile=route.tile||(start&&tiles.find(t=>t.q===start.q&&t.r===start.r));return (tile&&visualTile(tile).fill)||materials.track;};
const blushCells=new Map();
if(macaron){
const blush=(steps,color)=>{for(const s of steps||[])blushCells.set(key(s.q,s.r),color);};
for(const m of motions)if(m.route.status==='exit')blush(m.route.steps,tint(routeFill(m.route),.84));
if(state.path)blush(state.path.steps,state.path.status==='exit'?tint(routeFill(state.path),.80):materials.blush);
for(const m of motions)if(m.blocker&&m.phase.impact>0)blushCells.set(key(m.blocker.q,m.blocker.r),materials.blush);
}
// Macaron: one white tray under everything, the rounded convex hull of the cells plus a margin, lifted by two opaque
// shadow steps. The tray turns with the board; the shadow offset stays vertical on screen.
if(macaron){
const pad=size*.34,cross=(o,a,b)=>(a.x-o.x)*(b.y-o.y)-(a.y-o.y)*(b.x-o.x);
const hull=pts=>{const s=[...pts].sort((a,b)=>a.x-b.x||a.y-b.y),lower=[],upper=[];for(const q of s){while(lower.length>=2&&cross(lower[lower.length-2],lower[lower.length-1],q)<=0)lower.pop();lower.push(q);}for(const q of s.reverse()){while(upper.length>=2&&cross(upper[upper.length-2],upper[upper.length-1],q)<=0)upper.pop();upper.push(q);}return [...lower.slice(0,-1),...upper.slice(0,-1)];};
const rounded=(poly,radius)=>poly.flatMap((v,i)=>{const prev=poly[(i+poly.length-1)%poly.length],next=poly[(i+1)%poly.length],ka=Math.min(.5,radius/Math.hypot(prev.x-v.x,prev.y-v.y)),kb=Math.min(.5,radius/Math.hypot(next.x-v.x,next.y-v.y)),a={x:v.x+(prev.x-v.x)*ka,y:v.y+(prev.y-v.y)*ka},b={x:v.x+(next.x-v.x)*kb,y:v.y+(next.y-v.y)*kb};return Array.from({length:6},(_,j)=>{const t=j/5,u=1-t;return {x:u*u*a.x+2*u*t*v.x+t*t*b.x,y:u*u*a.y+2*u*t*v.y+t*t*b.y};});});
const tray=grow=>rounded(hull(level.cells.flatMap(c=>{const {x,y}=center(c,view);return hexPoints(x,y,size+pad+grow);})),size*.9);
for(const [grow,dy,color] of [[size*.06,size*.20,materials.shadowSoft],[0,size*.10,materials.shadow]])p.polygon(rotate(tray(grow)).map(q=>({x:q.x,y:q.y+dy})),color);
base.polygon(tray(0),materials.tray);
}
// Signpost: a flat offset base and a rim under the cells lift the card off the world colour.
// The base offset stays vertical on screen, so it is applied after board rotation.
if(signpost){
const rimW=Math.max(1.5,size*.06),baseDy=size*.12;
for(const cell of sorted){const {x,y}=center(cell,view);p.polygon(rotate(hexPoints(x,y,size+.2+rimW)).map(q=>({x:q.x,y:q.y+baseDy})),materials.base);}
for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,size+.2+rimW),materials.rim);}
}
// Paint cells before rails. Paper keeps the reference's narrow gaps; other
// styles overlap slightly to avoid antialiasing seams.
for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,paper?size*.97:size+.2),materials.board);}
// Cell boundaries stay beneath the connected track endpoints.
for(const cell of sorted){
const {x,y}=center(cell,view);
if(macaron){const sc=cellScale(cell);if(sc>.02)base.polygon(roundedHex(x,y,size*.90*sc,.16),blushCells.get(key(cell.q,cell.r))||ringTint(cell));continue;}
base.polygon(hexPoints(x,y,paper?size*.97:size+.2),materials.board);
}
// Cell boundaries stay beneath the connected track endpoints. Macaron separates cells with white gutters instead.
if(!macaron)for(const cell of sorted){
const {x,y}=center(cell,view),edge=hexPoints(x,y,paper?size*.97:size);
base.line([...edge,edge[0]],materials.boardGrid,paper?Math.max(.4,size*.025):Math.max(1,size*.024));
base.line([...edge,edge[0]],materials.boardGrid,paper?Math.max(.4,size*.025):signpost?Math.max(.6,size*.02):Math.max(1,size*.024));
}
const rails=[];
for(const cell of sorted) {
const {x,y}=center(cell,view);
const {x,y}=center(cell,view),sc=cellScale(cell);
if(sc<=.15)continue;
for(let a=0;a<6;a++) if(a<cell.tracks[a]) {
// Inset the centerline by half the widest stroke at each hex side.
// Rounded line caps must also remain inside the visible cell.
const trackWidth=Math.max(4,size*.16);
const visualRadius=Math.max(0,size*.965-trackWidth/Math.sqrt(3)-.5);
const trackWidth=signpost?roadbedWidth:macaron?gummyWidth:Math.max(4,size*.16);
// Rails run continuously across macaron's white gutters; only board-edge ends are pulled inside the brick face.
const visualRadius=Math.max(0,(macaron?size*.90:size*.965)-trackWidth/Math.sqrt(3)-.5);
const b=cell.tracks[a],points=trackPoints(x,y,size,a,b);
const exterior=d=>!cellKeys.has(key(cell.q+DIRS[d][0],cell.r+DIRS[d][1]));
const first=points[0],last=points[points.length-1],shrink=1-visualRadius/size;
const dx0=exterior(a)?(first.x-x)*shrink:0,dy0=exterior(a)?(first.y-y)*shrink:0;
const dx1=exterior(b)?(last.x-x)*shrink:0,dy1=exterior(b)?(last.y-y)*shrink:0;
if(!paper)points.forEach((p,i)=>{const t=i/(points.length-1),u=1-t;p.x-=u*u*u*dx0+t*t*t*dx1;p.y-=u*u*u*dy0+t*t*t*dy1;});
if(sc<1)points.forEach(pt=>{pt.x=x+(pt.x-x)*sc;pt.y=y+(pt.y-y)*sc;});
rails.push({points,primary:!state.trackEmphasis||state.trackEmphasis(cell,a,b)});
}
}
// Pattern studies can emphasize real routes while retaining the other paired
// connections as secondary lines. Ordinary levels keep the same drawing passes.
for(const {points,primary} of rails)if(!primary)base.line(points,state.secondaryTrackColor||materials.boardGrid,Math.max(1,size*.025));
if(!paper)for(const {points,primary} of rails)if(primary)base.line(points,materials.trackHalo,Math.max(4,size*.16));
for(const {points,primary} of rails)if(primary)base.line(points,materials.track,paper?Math.max(.7,size*.055):Math.max(2.5,size*.10));
if(state.path) p.line(routePoints(state.path,view),state.path.status==='exit'?THEME.primary:THEME.warning,Math.max(4,size*.12));
if(!paper&&!macaron)for(const {points,primary} of rails)if(primary)base.line(points,materials.trackHalo,signpost?roadbedWidth:Math.max(4,size*.16));
for(const {points,primary} of rails)if(primary)base.line(points,materials.track,paper?Math.max(.7,size*.055):signpost?railWidth:macaron?gummyWidth:Math.max(2.5,size*.10));
// Route preview: classic styles draw it under the tokens; signpost draws it over them, in two layers, ending at the rim.
// Macaron draws the line under the tokens but its blocked cross above them, so a short stub still shows its cross.
let afterTokens=null;
const drawPath=()=>{
if(!state.path)return;
if(macaron){
// One chunky line in the token's own colour over the rails; a blocked route stops at the contact point with an orange cross.
const exit=state.path.status==='exit',fill=routeFill(state.path);
let points=routePoints(state.path,view);if(exit&&!state.path.travelPoints&&points.length>2)points=points.slice(0,-1);
p.line(points,exit?fill:materials.blocked,gummyWidth*1.15);
if(!exit)afterTokens=()=>{const q=points[points.length-1],k=gummyWidth*1.2;p.line([{x:q.x-k,y:q.y-k},{x:q.x+k,y:q.y+k}],materials.blocked,gummyWidth);p.line([{x:q.x-k,y:q.y+k},{x:q.x+k,y:q.y-k}],materials.blocked,gummyWidth);};
return;
}
if(!signpost){p.line(routePoints(state.path,view),state.path.status==='exit'?THEME.primary:THEME.warning,Math.max(4,size*.12));return;}
const exit=state.path.status==='exit',route=state.theme.route||{};
let points=routePoints(state.path,view);if(exit&&!state.path.travelPoints&&points.length>2)points=points.slice(0,-1);
p.line(points,exit?route.exitGlow:route.blockedGlow,roadbedWidth*1.05);p.line(points,exit?route.exit:route.blocked,railWidth*1.5);
if(!exit){const q=points[points.length-1],k=railWidth*1.4;p.line([{x:q.x-k,y:q.y-k},{x:q.x+k,y:q.y+k}],route.blocked,railWidth);p.line([{x:q.x-k,y:q.y+k},{x:q.x+k,y:q.y-k}],route.blocked,railWidth);}
};
if(!signpost)drawPath();
if(state.selected) {
const cell=level.cells.find(c=>c.q===state.selected.q&&c.r===state.selected.r);
if(cell){const c=center(cell,view),pts=hexPoints(c.x,c.y,size*.98);p.line([...pts,pts[0]],THEME.reward,3);}
}
for(const tile of [...tiles].filter(t=>!movingIds.has(t.id)).sort((a,b)=>center(a,view).y-center(b,view).y)) {
const c=center(tile,view);
if(tile.id===state.hint) {p.circle(c.x,c.y,size*1.13,THEME.reward);p.circle(c.x,c.y,size*1.03,THEME.warningSurface);}
const c=center(tile,view),sc=cellScale(tile);
// Macaron: a tray-white seat under every resting token covers the rail crossings in the gutters around its brick,
// so no rail end shows at the token's edge; when the token leaves, the continuous rail reappears.
if(macaron&&sc>=.3)p.polygon(roundedHex(c.x,c.y,size*1.12*sc,.16),materials.tray);
if(tile.id===state.hint) {
if(macaron){p.polygon(roundedHex(c.x,c.y,size*1.02,.16),materials.hintGlow);p.polygon(roundedHex(c.x,c.y,size*.94,.16),materials.hint);}
else if(signpost){const ring=Array.from({length:48},(_,i)=>({x:c.x+Math.cos(i/48*Math.PI*2)*size*1.1,y:c.y+Math.sin(i/48*Math.PI*2)*size*1.1}));p.line([...ring,ring[0]],state.theme.route?.hint||THEME.reward,Math.max(2.5,size*.08));}
else{p.circle(c.x,c.y,size*1.13,THEME.reward);p.circle(c.x,c.y,size*1.03,THEME.warningSurface);}
}
if(state.queued?.includes(tile.id)){
const ring=hexPoints(c.x,c.y,size*1.03);
p.line([...ring,ring[0]],THEME.primary,3);
p.line([...ring,ring[0]],macaron?materials.track:THEME.primary,3);
}
let dx=0;
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
@@ -92,7 +172,17 @@ export function drawScene(p,level,tiles,view,state={}) {
for(const {blocker,impactAngle,phase} of motions)if(tile.id===blocker?.id){
dx+=Math.cos(impactAngle)*phase.kick*size*.32;dy+=Math.sin(impactAngle)*phase.kick*size*.32;
}
drawTile(p,c.x+dx,c.y+dy,size,visualTile(tile));
if(sc>=.3)drawTile(p,c.x+dx,c.y+dy,size,visualTile(tile),undefined,sc);
}
if(signpost)drawPath();
afterTokens?.();
// Macaron: when the last token has left, twelve candy stars and a honey ring burst out of the tray before the result opens.
if(macaron&&state.celebrate!==undefined&&state.celebrate<1){
const t=state.celebrate,ease=1-Math.pow(1-t,3),cs=level.cells.map(c=>center(c,view)),cx=cs.reduce((a,c)=>a+c.x,0)/cs.length,cy=cs.reduce((a,c)=>a+c.y,0)/cs.length;
const reach=Math.max(...cs.map(c=>Math.hypot(c.x-cx,c.y-cy)))+size*1.3,colors=(state.theme.palette||[]).map(i=>state.theme.pigments[i].color);
const ringR=reach*(.45+.6*ease),ring=Array.from({length:48},(_,i)=>({x:cx+Math.cos(i/48*Math.PI*2)*ringR,y:cy+Math.sin(i/48*Math.PI*2)*ringR}));
p.line([...ring,ring[0]],materials.hint+Math.round((1-t)*200).toString(16).padStart(2,'0'),Math.max(1,size*.14*(1-t)));
for(let i=0;i<12;i++){const a=i/12*Math.PI*2-Math.PI/2+ease*.4,d=reach*(.3+.8*ease),r=size*.34*(1-smoothMotion(t)),sx=cx+Math.cos(a)*d,sy=cy+Math.sin(a)*d;if(r>0)p.polygon(sparkle(sx,sy,r),colors.length?colors[i%colors.length]:materials.hint);}
}
for(const {route,progress,target,phase,movingPath,contact,impactAngle} of motions) {
const blocked=route.status!=='exit';
@@ -104,6 +194,7 @@ export function drawScene(p,level,tiles,view,state={}) {
}
if(target&&phase.impact>0){
const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2;
if(macaron){const k=size*.30*phase.impact;p.line([{x:x-k,y:y-k},{x:x+k,y:y+k}],materials.blocked,gummyWidth);p.line([{x:x-k,y:y+k},{x:x+k,y:y-k}],materials.blocked,gummyWidth);continue;}
for(const offset of [-1.95,-1.2,1.2,1.95]){const a=impactAngle+offset,d=size*(.12+phase.impact*.16),length=size*.26*phase.impact;p.line([{x:x+Math.cos(a)*d,y:y+Math.sin(a)*d},{x:x+Math.cos(a)*(d+length),y:y+Math.sin(a)*(d+length)}],materials.track,Math.max(1.5,size*.055));}
}
}
@@ -132,13 +223,14 @@ export function exitGeometry(route,view){
export function drawExit(p,route,view,progress,tile,reduced=false){
const {points,edge,angle}=exitGeometry(route,view),size=view.size;
if(reduced){if(progress<.8){const at=pointAlong(points,progress/.8);drawTile(p,at.x,at.y,size,tile,at.angle);}return;}
const timing=exitTiming(route),travel=timing.travelFraction;
const timing=exitTiming(route),travel=timing.travelMs/exitDuration(route,tile.artStyle);
if(progress<travel){
const at=pointAlong(points,travelProgress(progress/travel,timing.travelMs));
drawTile(p,at.x,at.y,size,tile,at.angle);return;
}
const t=Math.min(1,(progress-travel)/(1-travel)),ease=smoothMotion(t);
const color=tile.fill||COLORS[tile.color],dx=Math.cos(angle),dy=Math.sin(angle);
if(tile.artStyle==='macaron'){bubbleDissolve(p,edge,angle,size,tile,color,t);return;}
// Local exit plume: short enough to remain visible in the canvas gutter.
const at={x:edge.x+dx*size*.48*ease,y:edge.y+dy*size*.48*ease};
for(let i=3;i>=1;i--){
@@ -154,3 +246,28 @@ export function drawExit(p,route,view,progress,tile,reduced=false){
}
}
}
// Macaron exit: the token reaches its port, swells like a bubble, pops, and drifts off as smaller bubbles with
// highlights that fade out. Circles with alpha only; every bubble's offsets are fixed so all frames agree.
function bubbleDissolve(p,edge,angle,size,tile,color,t){
const dx=Math.cos(angle),dy=Math.sin(angle),alphaHex=v=>Math.round(Math.max(0,Math.min(1,v))*255).toString(16).padStart(2,'0'),base=color.slice(0,7);
const reach=smoothMotion(Math.min(1,t/.38)),at={x:edge.x+dx*size*.22*reach,y:edge.y+dy*size*.22*reach};
const swell=t<.38?1+.14*smoothMotion(t/.38):Math.max(0,1.14*(1-(t-.38)/.10));
if(swell>0)drawTile(p,at.x,at.y,size,tile,angle,swell);
const pop=(t-.36)/.30;
if(pop>0&&pop<1){const r=size*(.5+.9*(1-Math.pow(1-pop,3))),ring=Array.from({length:36},(_,i)=>({x:at.x+Math.cos(i/36*Math.PI*2)*r,y:at.y+Math.sin(i/36*Math.PI*2)*r}));p.line([...ring,ring[0]],tint(base,.45)+alphaHex(.8*(1-pop)),Math.max(.6,size*.09*(1-pop)));}
// Ten bubbles leave the popped face together: three large, the rest small, spread across the face and drifting
// outward past the port, upward, with a slow wobble, so the cloud stays on screen long enough to read.
for(let i=0;i<10;i++){
const s1=(i*7919%97)/97,s2=(i*104729%89)/89,s3=(i*15485863%83)/83;
const born=.34+.12*s1,life=.48+.18*s2,u=(t-born)/life;
if(u<=0||u>=1)continue;
const side=(s3-.5)*2.4,along=(s1-.5)*1.4,r0=size*(i<3?.22+.12*s2:.11+.12*s2);
const drift=size*(.05+(.35+.5*s2)*(1-Math.pow(1-u,2))),wobble=Math.sin(u*Math.PI*1.5+s1*6.28)*size*.12;
const bx=at.x+dx*(along*size*.35+drift)-dy*(side*size*.5+wobble),by=at.y+dy*(along*size*.35+drift)+dx*(side*size*.5+wobble)-size*.42*u;
const grow=Math.min(1,u/.18),fade=u>.6?1-(u-.6)/.4:1,r=r0*grow*fade;
if(r<=0)continue;
p.circle(bx,by,r,tint(base,.28)+alphaHex(.92*fade));
p.circle(bx-r*.32,by-r*.34,r*.28,'#FFFFFF'+alphaHex(.9*fade));
}
}