JEV-27B-Demo / arena-renderer.js
uremia's picture
Create arena-renderer.js
6a726cf verified
Raw History Blame Contribute Delete
15.8 kB
/* 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<v.height;y++)for(let x=0;x<v.width;x++)rect(left+x*size+1,top+y*size+1,size-2,size-2,"#f1f4f7",3);
v.walls.forEach(([x,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);