/* Shared, deterministic Canvas renderers. No network access or model execution. */ (function(global) { "use strict"; const C = {bg:"#ffffff",panel:"#f4f6f8",line:"#cbd4df",cream:"#273448",muted:"#697586",gold:"#a87821",mint:"#3b7db6"}; // Coordinates and turn signs mirror arena/games/cube.py. Every sticker is read // from the recorded payload, including faces revealed during a layer turn. const CUBE_FACES={ U:{normal:[0,1,0],right:[1,0,0],down:[0,0,1]}, D:{normal:[0,-1,0],right:[1,0,0],down:[0,0,-1]}, L:{normal:[-1,0,0],right:[0,0,1],down:[0,-1,0]}, R:{normal:[1,0,0],right:[0,0,-1],down:[0,-1,0]}, F:{normal:[0,0,1],right:[1,0,0],down:[0,-1,0]}, B:{normal:[0,0,-1],right:[-1,0,0],down:[0,-1,0]} }; const cubeAdd=(a,b)=>a.map((v,i)=>v+b[i]); const cubeScale=(a,s)=>a.map(v=>v*s); const cubeDot=(a,b)=>a.reduce((s,v,i)=>s+v*b[i],0); function cubePosition(face,row,column){const f=CUBE_FACES[face];return cubeAdd(f.normal,cubeAdd(cubeScale(f.right,column-1),cubeScale(f.down,row-1)));} function cubeRotate(v,axis,angle){ const [x,y,z]=v,c=Math.cos(angle),s=Math.sin(angle); return axis===0?[x,y*c-z*s,y*s+z*c]:axis===1?[x*c+z*s,y,-x*s+z*c]:[x*c-y*s,x*s+y*c,z]; } function cubeTurn(action){ if(typeof action!=="string"||!/^[UDLRFB]'?$/.test(action))return null; const n=CUBE_FACES[action[0]].normal,axis=n.findIndex(v=>v!==0),sign=n[axis]; return {axis,sign,angle:(action.endsWith("'")?sign:-sign)*Math.PI/2}; } function cubeStickers(faces){ if(!faces)return null; const result=[]; for(const [face,f] of Object.entries(CUBE_FACES)){ const rows=faces[face]; if(!Array.isArray(rows)||rows.length!==3||rows.some(r=>!Array.isArray(r)||r.length!==3))return null; rows.forEach((row,r)=>row.forEach((color,c)=>result.push({position:cubePosition(face,r,c),normal:f.normal,color,face,row:r,column:c}))); } return result; } function cubeKey(position,normal){return [...position,...normal].map(v=>Math.round(v)).join(",");} function cubeTransitionMatches(before,after,action){ const turn=cubeTurn(action),source=cubeStickers(before?.faces),target=cubeStickers(after?.faces); if(!turn||!source||!target)return false; const actual=new Map(target.map(s=>[cubeKey(s.position,s.normal),s.color])); return source.every(s=>{ const moving=s.position[turn.axis]===turn.sign; const p=moving?cubeRotate(s.position,turn.axis,turn.angle):s.position; const n=moving?cubeRotate(s.normal,turn.axis,turn.angle):s.normal; return actual.get(cubeKey(p,n))===s.color; }); } function create(canvas, options={}) { const ctx=canvas.getContext("2d"); let renderSequence=0; const nesImages=new Map(); const compactMode=()=>options.compact ?? (window.innerWidth<=600); // All drawings come directly from the active game's renderer payload. function rect(x,y,w,h,fill,r=0,stroke=null){ctx.beginPath();ctx.roundRect(x,y,w,h,r);if(fill){ctx.fillStyle=fill;ctx.fill();}if(stroke){ctx.strokeStyle=stroke;ctx.lineWidth=1;ctx.stroke();}} function text(value,x,y,size=16,color=C.cream,align="left",serif=false){ctx.fillStyle=color;ctx.font=`${size}px ${serif?"Georgia":"system-ui, sans-serif"}`;ctx.textAlign=align;ctx.textBaseline="middle";ctx.fillText(String(value),x,y);} function line(x1,y1,x2,y2,color,width=1){ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.strokeStyle=color;ctx.lineWidth=width;ctx.stroke();} // Optional replayContext for a recorded cube turn: // { previousView: viewBefore, action: "R'", progress: 0..1 }. // The supplied view is viewAfter. Invalid transitions remain static; this API // never advances a game or reads wall-clock time, so seeking is deterministic. function render(view, replayContext=null) { const sequence=++renderSequence; const compact=compactMode(),width=compact?600:1000,height=compact?650:600;canvas.width=width;canvas.height=height;ctx.clearRect(0,0,width,height); const grad=ctx.createRadialGradient(500,280,50,500,300,600);grad.addColorStop(0,"#ffffff");grad.addColorStop(1,"#ffffff");ctx.fillStyle=grad;ctx.fillRect(0,0,width,height); if(view?.kind!=="cube"){ctx.globalAlpha=.35;for(let x=20;x<1000;x+=25)for(let y=20;y<600;y+=25){ctx.fillStyle=C.line;ctx.fillRect(x,y,1,1);}ctx.globalAlpha=1;} if(!view)return; if(compact)ctx.translate(-200,25); if(view.kind==="tetris")drawTetris(view); else if(view.kind==="cube")drawCube(view,replayContext); else if(view.kind==="obstacles"||view.kind==="sokoban")drawGrid(view); else if(view.kind==="mario"){ if(view.engine==="nes")return drawNesMario(view,sequence); drawMario(view); } } function tileBlock(x,y,size,color,alpha=1) {ctx.globalAlpha=alpha;rect(x+1.5,y+1.5,size-3,size-3,color,3);ctx.globalAlpha=alpha*.25;rect(x+4,y+4,size-8,3,"#fff",1);ctx.globalAlpha=1;} function drawTetris(v) { const s=25,x0=350,y0=45;rect(x0-10,y0-10,270,520,"#ffffff",8,"#cdd4df"); v.board.forEach((row,y)=>row.forEach((piece,x)=>{rect(x0+x*s+1,y0+y*s+1,s-2,s-2,"#f1f4f7",1);if(piece)tileBlock(x0+x*s,y0+y*s,s,v.colors[piece]);})); if(v.ghost)v.ghost.cells.forEach(([x,y])=>{if(y>=0)rect(x0+x*s+3,y0+y*s+3,s-6,s-6,null,3,"#a8782177");}); if(v.active)v.active.cells.forEach(([x,y])=>{if(y>=0)tileBlock(x0+x*s,y0+y*s,s,v.colors[v.active.piece]);}); const labelX=compactMode()?310:285; text("LINE CLEAR",labelX,110,13,C.muted,"right");text(`${v.lines} / ${v.goal_lines}`,labelX,153,compactMode()?36:44,C.gold,"right",true); text("SCORE",labelX,229,13,C.muted,"right");text(v.score,labelX,269,30,C.cream,"right",true); text("NEXT",665,87,13,C.muted);v.next.slice(0,3).forEach((piece,i)=>{rect(655,115+i*113,143,94,"#f7f8fa",6,"#dce1e7");(v.piece_shapes[piece]||[]).forEach(([x,y])=>tileBlock(677+x*24,137+i*113+y*24,24,v.colors[piece]));}); text(v.realtime?`${v.speed_multiplier.toFixed(1)}× FALL SPEED · ${Math.round(v.gravity_ms)} ms / row`:"Find the gap. Make it fit.",500,575,14,v.realtime?C.gold:C.muted,"center"); } function drawCube(v,replayContext) { let source=v,turn=null,angle=0,progress=1; if(replayContext&&Number.isFinite(replayContext.progress) &&cubeTransitionMatches(replayContext.previousView,v,replayContext.action)){ progress=Math.max(0,Math.min(1,replayContext.progress)); // Exact endpoints use their recorded state, avoiding floating-point seams. if(progress<1){source=replayContext.previousView;turn=cubeTurn(replayContext.action);angle=turn.angle*(progress*progress*(3-2*progress));} } const stickers=cubeStickers(source.faces); if(!stickers){text("Cube state unavailable",500,300,20,C.muted,"center");return;} const stickerMap=new Map(stickers.map(s=>[cubeKey(s.position,s.normal),s.color])); const eye=[1,1,1].map(n=>n/Math.sqrt(3)); const right=[Math.SQRT1_2,0,-Math.SQRT1_2],up=[-1/Math.sqrt(6),2/Math.sqrt(6),-1/Math.sqrt(6)]; const scale=86,center=[500,286],polygons=[]; const project=p=>[center[0]+scale*cubeDot(p,right),center[1]-scale*cubeDot(p,up)]; const transform=(p,moving)=>moving?cubeRotate(p,turn.axis,angle):p; const corners=(center,u,d,half)=>[[-1,-1],[1,-1],[1,1],[-1,1]].map(([a,b])=>cubeAdd(center,cubeAdd(cubeScale(u,a*half),cubeScale(d,b*half)))); // Paint all solid cubies, not only the front stickers. During a quarter turn // this exposes the genuine plastic underside of the moving slice. for(let x=-1;x<=1;x++)for(let y=-1;y<=1;y++)for(let z=-1;z<=1;z++){ const position=[x,y,z],moving=!!turn&&position[turn.axis]===turn.sign; for(const face of Object.values(CUBE_FACES)){ const normal=transform(face.normal,moving); if(cubeDot(normal,eye)<=1e-7)continue; const middle=transform(cubeAdd(position,cubeScale(face.normal,.487)),moving); const points=corners(cubeAdd(position,cubeScale(face.normal,.487)),face.right,face.down,.487).map(p=>transform(p,moving)); const color=stickerMap.get(cubeKey(position,face.normal)); polygons.push({depth:cubeDot(middle,eye),points:points.map(project),normal, sticker:color===undefined?null:{color:source.colors[color],points:corners(cubeAdd(position,cubeScale(face.normal,.489)),face.right,face.down,.415).map(p=>project(transform(p,moving)))}}); } } ctx.save();ctx.translate(500,515);ctx.scale(1,.12); const shadow=ctx.createRadialGradient(0,0,10,0,0,200); shadow.addColorStop(0,"#27344824");shadow.addColorStop(1,"#27344800"); ctx.fillStyle=shadow;ctx.fillRect(-220,-220,440,440);ctx.restore(); function polygon(points,roundness=0){ ctx.beginPath(); points.forEach((p,i)=>{ const prev=points[(i+points.length-1)%points.length],next=points[(i+1)%points.length]; const a=p.map((v,j)=>v+(prev[j]-v)*roundness),b=p.map((v,j)=>v+(next[j]-v)*roundness); if(i===0)ctx.moveTo(...a);else ctx.lineTo(...a); ctx.quadraticCurveTo(...p,...b); });ctx.closePath(); } polygons.sort((a,b)=>a.depth-b.depth).forEach(p=>{ const top=Math.max(0,p.normal[1]),side=Math.max(0,p.normal[0]); polygon(p.points,.025);ctx.fillStyle=top>.5?"#293544":side>.5?"#15202e":"#1d2938";ctx.fill(); ctx.strokeStyle="#0c17253b";ctx.lineWidth=.65;ctx.stroke(); if(p.sticker){ polygon(p.sticker.points,.095);ctx.fillStyle=p.sticker.color;ctx.fill(); // Gentle material highlights retain each recorded sticker's base color. const ys=p.sticker.points.map(q=>q[1]),shine=ctx.createLinearGradient(0,Math.min(...ys),0,Math.max(...ys)); shine.addColorStop(0,`rgba(255,255,255,${.12+.08*top})`);shine.addColorStop(.5,"rgba(255,255,255,0)");shine.addColorStop(1,`rgba(0,0,0,${.035+.035*side})`); ctx.fillStyle=shine;ctx.fill();ctx.strokeStyle="#ffffff38";ctx.lineWidth=.7;ctx.stroke(); } }); text("RUBIK’S CUBE",500,35,12,C.muted,"center"); text(`${source.solved_faces} of 6 faces aligned`,500,554,18,C.cream,"center"); if(turn&&progress>0&&progress<1){rect(466,575,68,24,"#f0f4f7",12);text(replayContext.action,500,587,15,C.cream,"center");} } function drawGrid(v) { if(v.campaign)text(`LEVEL ${v.campaign.level} / ${v.campaign.total_levels} · ${v.campaign.completed_levels} CLEARED`,500,45,18,C.gold,"center"); const size=Math.min(61,490/v.height,690/v.width),left=(1000-v.width*size)/2,top=(600-v.height*size)/2; rect(left-12,top-12,v.width*size+24,v.height*size+24,"#ffffff",10,"#cbd4df"); for(let y=0;y{rect(left+x*size+1,top+y*size+1,size-2,size-2,"#91a0b2",3);line(left+x*size+6,top+y*size+6,left+(x+1)*size-6,top+y*size+6,"#bac7d5",1);}); if(v.kind==="sokoban") { v.goals.forEach(([x,y])=>{const cx=left+(x+.5)*size,cy=top+(y+.5)*size;ctx.beginPath();ctx.arc(cx,cy,size*.22,0,Math.PI*2);ctx.strokeStyle=C.gold;ctx.lineWidth=2;ctx.stroke();ctx.fillStyle="#a8782124";ctx.fill();}); const goals=new Set(v.goals.map(p=>p.join(","))); v.boxes.forEach(([x,y])=>{const px=left+x*size+5,py=top+y*size+5,s=size-10,on=goals.has([x,y].join(","));rect(px,py,s,s,on?"#a6bb7f":"#c79e58",4,on?"#d4e4a7":"#e1c17f");line(px+5,py+5,px+s-5,py+s-5,"#6a6838",2);line(px+s-5,py+5,px+5,py+s-5,"#6a6838",2);}); } else { const [gx,gy]=v.goal;rect(left+gx*size+6,top+gy*size+6,size-12,size-12,C.gold,6);text("⚑",left+(gx+.5)*size,top+(gy+.5)*size,size*.52,C.bg,"center"); v.obstacles.forEach(o=>{const [x,y]=o.position,cx=left+(x+.5)*size,cy=top+(y+.5)*size;ctx.save();ctx.translate(cx,cy);ctx.rotate(Math.PI/4);rect(-size*.3,-size*.3,size*.6,size*.6,"#dc9278",4);ctx.restore();text(o.direction==="right"?"→":"←",cx,cy,Math.max(13,size*.4),"#4b3025","center");}); } const [px,py]=v.player,cx=left+(px+.5)*size,cy=top+(py+.5)*size;ctx.beginPath();ctx.arc(cx,cy,size*.28,0,Math.PI*2);ctx.fillStyle=C.cream;ctx.fill();ctx.strokeStyle="#ffffff";ctx.lineWidth=3;ctx.stroke();ctx.beginPath();ctx.arc(cx+size*.06,cy-size*.07,size*.055,0,Math.PI*2);ctx.fillStyle=C.bg;ctx.fill(); } function drawNesMario(v,sequence) { const uri=v.screen?.png; if(typeof uri!=="string"||!uri.startsWith("data:image/png;base64,")) return Promise.reject(new Error("Original Mario recording is missing its native PNG frame.")); let pending=nesImages.get(uri); if(!pending){ pending=new Promise((resolve,reject)=>{ const nativeImage=new Image(); nativeImage.onload=()=>resolve(nativeImage); nativeImage.onerror=()=>reject(new Error("Could not decode the original Mario frame.")); nativeImage.src=uri; }); nesImages.set(uri,pending); if(nesImages.size>24)nesImages.delete(nesImages.keys().next().value); } return pending.then(nativeImage=>{ // Live playback may have requested a newer frame while PNG decoding ran. if(sequence!==renderSequence)return; const scale=2,left=500-nativeImage.width*scale/2,top=58; rect(left-7,top-7,nativeImage.width*scale+14,nativeImage.height*scale+14,"#ffffff",8,"#cbd4df"); ctx.imageSmoothingEnabled=false; ctx.drawImage(nativeImage,left,top,nativeImage.width*scale,nativeImage.height*scale); text("SUPER MARIO BROS.",500,28,13,C.muted,"center"); text(`WORLD ${v.hud?.world??1}-${v.hud?.stage??1}`,500,574,15,C.cream,"center"); }); } function drawMario(v) { if(!v.camera||!v.tiles){text("Mario AI Framework is preparing the level…",500,300,22,C.cream,"center",true);return;} const scale=1.98,w=v.camera.width*scale,h=v.camera.height*scale,left=(1000-w)/2,top=(600-h)/2,tile=v.tile_size||16; ctx.save();ctx.beginPath();ctx.roundRect(left-6,top-6,w+12,h+12,8);ctx.fillStyle="#ffffff";ctx.fill();ctx.strokeStyle="#cbd4df";ctx.stroke();ctx.beginPath();ctx.rect(left,top,w,h);ctx.clip();ctx.translate(left,top);ctx.scale(scale,scale); const sky=ctx.createLinearGradient(0,0,0,v.camera.height);sky.addColorStop(0,"#cde8ff");sky.addColorStop(1,"#f5fbff");ctx.fillStyle=sky;ctx.fillRect(0,0,v.camera.width,v.camera.height); // Decorative sky follows the camera; every playable tile comes from the engine. for(let i=0;i<5;i++){const x=(i*97-v.camera.x*.15)%340;rect(x,30+(i%3)*22,27,7,"#e7e4c4",4);rect(x+6,25+(i%3)*22,13,12,"#e7e4c4",4);} const palettes={ground:["#9b8152","#a0ac6b"],pipe:["#507e40","#83a352"],question:["#d2ad4d","#f0cf66"],brick:["#aa704e","#d29463"],used:["#947d58","#baa377"],platform:["#6f8258","#a7b075"]}; v.tiles.forEach(t=>{const x=t.x*tile-v.camera.x,y=t.y*tile-(v.camera.y||0);if(t.kind==="coin"){ctx.beginPath();ctx.ellipse(x+8,y+8,3,6,0,0,Math.PI*2);ctx.fillStyle="#f1d163";ctx.fill();return;}if(t.kind==="flag"){rect(x+7,y,2,16,"#ebebd8");ctx.beginPath();ctx.moveTo(x+7,y+2);ctx.lineTo(x+15,y+5);ctx.lineTo(x+7,y+9);ctx.fillStyle="#a87821";ctx.fill();return;}const colors=palettes[t.kind]||palettes.ground;rect(x,y,tile,tile,colors[0]);rect(x+1,y+1,tile-2,3,colors[1]);if(t.kind==="question")text("?",x+8,y+9,12,"#604c28","center");if(t.kind==="brick"){line(x,y+8,x+16,y+8,"#704d35");line(x+8,y,x+8,y+8,"#704d35");line(x+4,y+8,x+4,y+16,"#704d35");}if(t.kind==="pipe"){rect(x+3,y,3,tile,"#99b16b");}}); (v.enemies||[]).forEach(e=>{const x=e.x-v.camera.x-e.width/2,y=e.y-(v.camera.y||0)-e.height;rect(x,y,e.width,e.height,"#855132",4);rect(x+2,y+3,3,4,"#f5e7ca");rect(x+e.width-5,y+3,3,4,"#f5e7ca");rect(x-1,y+e.height-3,e.width+2,3,"#45382b",1);}); const p=v.player;if(p){const x=p.x-v.camera.x-p.width/2,y=p.y-(v.camera.y||0)-p.height,pw=p.width,ph=p.height;rect(x,y+ph*.35,pw,ph*.5,"#497799",1);rect(x+1,y+ph*.1,pw-2,ph*.38,"#e2b886",1);rect(x-1,y,pw+2,ph*.18,"#b5533f",1);rect(x+(p.facing<0?1:pw-3),y+ph*.2,2,3,"#3a3025");rect(x-1,y+ph-3,pw/2,3,"#46392e",1);rect(x+pw/2+1,y+ph-3,pw/2,3,"#46392e",1);} ctx.restore();if(!compactMode()){text("MARIO AI FRAMEWORK",left-29,85,12,C.gold,"right");text("Reach the flag",left-29,112,15,C.muted,"right");text("COINS",left+w+29,85,12,C.muted);text(v.hud?.coins??0,left+w+29,120,30,C.cream,"left",true);} } return {render}; } global.ArenaRenderer={create}; })(window);