uremia commited on
Commit
6a726cf
·
verified ·
1 Parent(s): 109227a

Create arena-renderer.js

Browse files
Files changed (1) hide show
  1. arena-renderer.js +210 -0
arena-renderer.js ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Shared, deterministic Canvas renderers. No network access or model execution. */
2
+ (function(global) {
3
+ "use strict";
4
+ const C = {bg:"#ffffff",panel:"#f4f6f8",line:"#cbd4df",cream:"#273448",muted:"#697586",gold:"#a87821",mint:"#3b7db6"};
5
+ // Coordinates and turn signs mirror arena/games/cube.py. Every sticker is read
6
+ // from the recorded payload, including faces revealed during a layer turn.
7
+ const CUBE_FACES={
8
+ U:{normal:[0,1,0],right:[1,0,0],down:[0,0,1]},
9
+ D:{normal:[0,-1,0],right:[1,0,0],down:[0,0,-1]},
10
+ L:{normal:[-1,0,0],right:[0,0,1],down:[0,-1,0]},
11
+ R:{normal:[1,0,0],right:[0,0,-1],down:[0,-1,0]},
12
+ F:{normal:[0,0,1],right:[1,0,0],down:[0,-1,0]},
13
+ B:{normal:[0,0,-1],right:[-1,0,0],down:[0,-1,0]}
14
+ };
15
+ const cubeAdd=(a,b)=>a.map((v,i)=>v+b[i]);
16
+ const cubeScale=(a,s)=>a.map(v=>v*s);
17
+ const cubeDot=(a,b)=>a.reduce((s,v,i)=>s+v*b[i],0);
18
+ 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)));}
19
+ function cubeRotate(v,axis,angle){
20
+ const [x,y,z]=v,c=Math.cos(angle),s=Math.sin(angle);
21
+ 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];
22
+ }
23
+ function cubeTurn(action){
24
+ if(typeof action!=="string"||!/^[UDLRFB]'?$/.test(action))return null;
25
+ const n=CUBE_FACES[action[0]].normal,axis=n.findIndex(v=>v!==0),sign=n[axis];
26
+ return {axis,sign,angle:(action.endsWith("'")?sign:-sign)*Math.PI/2};
27
+ }
28
+ function cubeStickers(faces){
29
+ if(!faces)return null;
30
+ const result=[];
31
+ for(const [face,f] of Object.entries(CUBE_FACES)){
32
+ const rows=faces[face];
33
+ if(!Array.isArray(rows)||rows.length!==3||rows.some(r=>!Array.isArray(r)||r.length!==3))return null;
34
+ rows.forEach((row,r)=>row.forEach((color,c)=>result.push({position:cubePosition(face,r,c),normal:f.normal,color,face,row:r,column:c})));
35
+ }
36
+ return result;
37
+ }
38
+ function cubeKey(position,normal){return [...position,...normal].map(v=>Math.round(v)).join(",");}
39
+ function cubeTransitionMatches(before,after,action){
40
+ const turn=cubeTurn(action),source=cubeStickers(before?.faces),target=cubeStickers(after?.faces);
41
+ if(!turn||!source||!target)return false;
42
+ const actual=new Map(target.map(s=>[cubeKey(s.position,s.normal),s.color]));
43
+ return source.every(s=>{
44
+ const moving=s.position[turn.axis]===turn.sign;
45
+ const p=moving?cubeRotate(s.position,turn.axis,turn.angle):s.position;
46
+ const n=moving?cubeRotate(s.normal,turn.axis,turn.angle):s.normal;
47
+ return actual.get(cubeKey(p,n))===s.color;
48
+ });
49
+ }
50
+ function create(canvas, options={}) {
51
+ const ctx=canvas.getContext("2d");
52
+ let renderSequence=0;
53
+ const nesImages=new Map();
54
+ const compactMode=()=>options.compact ?? (window.innerWidth<=600);
55
+ // All drawings come directly from the active game's renderer payload.
56
+ 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();}}
57
+ 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);}
58
+ 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();}
59
+ // Optional replayContext for a recorded cube turn:
60
+ // { previousView: viewBefore, action: "R'", progress: 0..1 }.
61
+ // The supplied view is viewAfter. Invalid transitions remain static; this API
62
+ // never advances a game or reads wall-clock time, so seeking is deterministic.
63
+ function render(view, replayContext=null) {
64
+ const sequence=++renderSequence;
65
+ const compact=compactMode(),width=compact?600:1000,height=compact?650:600;canvas.width=width;canvas.height=height;ctx.clearRect(0,0,width,height);
66
+ 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);
67
+ 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;}
68
+ if(!view)return;
69
+ if(compact)ctx.translate(-200,25);
70
+ if(view.kind==="tetris")drawTetris(view);
71
+ else if(view.kind==="cube")drawCube(view,replayContext);
72
+ else if(view.kind==="obstacles"||view.kind==="sokoban")drawGrid(view);
73
+ else if(view.kind==="mario"){
74
+ if(view.engine==="nes")return drawNesMario(view,sequence);
75
+ drawMario(view);
76
+ }
77
+ }
78
+ 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;}
79
+ function drawTetris(v) {
80
+ const s=25,x0=350,y0=45;rect(x0-10,y0-10,270,520,"#ffffff",8,"#cdd4df");
81
+ 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]);}));
82
+ 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");});
83
+ 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]);});
84
+ const labelX=compactMode()?310:285;
85
+ text("LINE CLEAR",labelX,110,13,C.muted,"right");text(`${v.lines} / ${v.goal_lines}`,labelX,153,compactMode()?36:44,C.gold,"right",true);
86
+ text("SCORE",labelX,229,13,C.muted,"right");text(v.score,labelX,269,30,C.cream,"right",true);
87
+ 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]));});
88
+ 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");
89
+ }
90
+ function drawCube(v,replayContext) {
91
+ let source=v,turn=null,angle=0,progress=1;
92
+ if(replayContext&&Number.isFinite(replayContext.progress)
93
+ &&cubeTransitionMatches(replayContext.previousView,v,replayContext.action)){
94
+ progress=Math.max(0,Math.min(1,replayContext.progress));
95
+ // Exact endpoints use their recorded state, avoiding floating-point seams.
96
+ if(progress<1){source=replayContext.previousView;turn=cubeTurn(replayContext.action);angle=turn.angle*(progress*progress*(3-2*progress));}
97
+ }
98
+ const stickers=cubeStickers(source.faces);
99
+ if(!stickers){text("Cube state unavailable",500,300,20,C.muted,"center");return;}
100
+ const stickerMap=new Map(stickers.map(s=>[cubeKey(s.position,s.normal),s.color]));
101
+ const eye=[1,1,1].map(n=>n/Math.sqrt(3));
102
+ const right=[Math.SQRT1_2,0,-Math.SQRT1_2],up=[-1/Math.sqrt(6),2/Math.sqrt(6),-1/Math.sqrt(6)];
103
+ const scale=86,center=[500,286],polygons=[];
104
+ const project=p=>[center[0]+scale*cubeDot(p,right),center[1]-scale*cubeDot(p,up)];
105
+ const transform=(p,moving)=>moving?cubeRotate(p,turn.axis,angle):p;
106
+ 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))));
107
+ // Paint all solid cubies, not only the front stickers. During a quarter turn
108
+ // this exposes the genuine plastic underside of the moving slice.
109
+ for(let x=-1;x<=1;x++)for(let y=-1;y<=1;y++)for(let z=-1;z<=1;z++){
110
+ const position=[x,y,z],moving=!!turn&&position[turn.axis]===turn.sign;
111
+ for(const face of Object.values(CUBE_FACES)){
112
+ const normal=transform(face.normal,moving);
113
+ if(cubeDot(normal,eye)<=1e-7)continue;
114
+ const middle=transform(cubeAdd(position,cubeScale(face.normal,.487)),moving);
115
+ const points=corners(cubeAdd(position,cubeScale(face.normal,.487)),face.right,face.down,.487).map(p=>transform(p,moving));
116
+ const color=stickerMap.get(cubeKey(position,face.normal));
117
+ polygons.push({depth:cubeDot(middle,eye),points:points.map(project),normal,
118
+ 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)))}});
119
+ }
120
+ }
121
+ ctx.save();ctx.translate(500,515);ctx.scale(1,.12);
122
+ const shadow=ctx.createRadialGradient(0,0,10,0,0,200);
123
+ shadow.addColorStop(0,"#27344824");shadow.addColorStop(1,"#27344800");
124
+ ctx.fillStyle=shadow;ctx.fillRect(-220,-220,440,440);ctx.restore();
125
+ function polygon(points,roundness=0){
126
+ ctx.beginPath();
127
+ points.forEach((p,i)=>{
128
+ const prev=points[(i+points.length-1)%points.length],next=points[(i+1)%points.length];
129
+ const a=p.map((v,j)=>v+(prev[j]-v)*roundness),b=p.map((v,j)=>v+(next[j]-v)*roundness);
130
+ if(i===0)ctx.moveTo(...a);else ctx.lineTo(...a);
131
+ ctx.quadraticCurveTo(...p,...b);
132
+ });ctx.closePath();
133
+ }
134
+ polygons.sort((a,b)=>a.depth-b.depth).forEach(p=>{
135
+ const top=Math.max(0,p.normal[1]),side=Math.max(0,p.normal[0]);
136
+ polygon(p.points,.025);ctx.fillStyle=top>.5?"#293544":side>.5?"#15202e":"#1d2938";ctx.fill();
137
+ ctx.strokeStyle="#0c17253b";ctx.lineWidth=.65;ctx.stroke();
138
+ if(p.sticker){
139
+ polygon(p.sticker.points,.095);ctx.fillStyle=p.sticker.color;ctx.fill();
140
+ // Gentle material highlights retain each recorded sticker's base color.
141
+ const ys=p.sticker.points.map(q=>q[1]),shine=ctx.createLinearGradient(0,Math.min(...ys),0,Math.max(...ys));
142
+ 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})`);
143
+ ctx.fillStyle=shine;ctx.fill();ctx.strokeStyle="#ffffff38";ctx.lineWidth=.7;ctx.stroke();
144
+ }
145
+ });
146
+ text("RUBIK’S CUBE",500,35,12,C.muted,"center");
147
+ text(`${source.solved_faces} of 6 faces aligned`,500,554,18,C.cream,"center");
148
+ if(turn&&progress>0&&progress<1){rect(466,575,68,24,"#f0f4f7",12);text(replayContext.action,500,587,15,C.cream,"center");}
149
+ }
150
+ function drawGrid(v) {
151
+ if(v.campaign)text(`LEVEL ${v.campaign.level} / ${v.campaign.total_levels} · ${v.campaign.completed_levels} CLEARED`,500,45,18,C.gold,"center");
152
+ const size=Math.min(61,490/v.height,690/v.width),left=(1000-v.width*size)/2,top=(600-v.height*size)/2;
153
+ rect(left-12,top-12,v.width*size+24,v.height*size+24,"#ffffff",10,"#cbd4df");
154
+ 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);
155
+ 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);});
156
+ if(v.kind==="sokoban") {
157
+ 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();});
158
+ const goals=new Set(v.goals.map(p=>p.join(",")));
159
+ 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);});
160
+ } else {
161
+ 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");
162
+ 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");});
163
+ }
164
+ 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();
165
+ }
166
+ function drawNesMario(v,sequence) {
167
+ const uri=v.screen?.png;
168
+ if(typeof uri!=="string"||!uri.startsWith("data:image/png;base64,"))
169
+ return Promise.reject(new Error("Original Mario recording is missing its native PNG frame."));
170
+ let pending=nesImages.get(uri);
171
+ if(!pending){
172
+ pending=new Promise((resolve,reject)=>{
173
+ const nativeImage=new Image();
174
+ nativeImage.onload=()=>resolve(nativeImage);
175
+ nativeImage.onerror=()=>reject(new Error("Could not decode the original Mario frame."));
176
+ nativeImage.src=uri;
177
+ });
178
+ nesImages.set(uri,pending);
179
+ if(nesImages.size>24)nesImages.delete(nesImages.keys().next().value);
180
+ }
181
+ return pending.then(nativeImage=>{
182
+ // Live playback may have requested a newer frame while PNG decoding ran.
183
+ if(sequence!==renderSequence)return;
184
+ const scale=2,left=500-nativeImage.width*scale/2,top=58;
185
+ rect(left-7,top-7,nativeImage.width*scale+14,nativeImage.height*scale+14,"#ffffff",8,"#cbd4df");
186
+ ctx.imageSmoothingEnabled=false;
187
+ ctx.drawImage(nativeImage,left,top,nativeImage.width*scale,nativeImage.height*scale);
188
+ text("SUPER MARIO BROS.",500,28,13,C.muted,"center");
189
+ text(`WORLD ${v.hud?.world??1}-${v.hud?.stage??1}`,500,574,15,C.cream,"center");
190
+ });
191
+ }
192
+ function drawMario(v) {
193
+ if(!v.camera||!v.tiles){text("Mario AI Framework is preparing the level…",500,300,22,C.cream,"center",true);return;}
194
+ 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;
195
+ 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);
196
+ 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);
197
+ // Decorative sky follows the camera; every playable tile comes from the engine.
198
+ 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);}
199
+ const palettes={ground:["#9b8152","#a0ac6b"],pipe:["#507e40","#83a352"],question:["#d2ad4d","#f0cf66"],brick:["#aa704e","#d29463"],used:["#947d58","#baa377"],platform:["#6f8258","#a7b075"]};
200
+ 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");}});
201
+ (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);});
202
+ 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);}
203
+ 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);}
204
+ }
205
+
206
+
207
+ return {render};
208
+ }
209
+ global.ArenaRenderer={create};
210
+ })(window);