๐ŸŽฎArcadeLab

๐Ÿ”ฎ Marble Run Builder!

by StealthWolf75
794 lines33.3 KB
โ–ถ Play
<!DOCTYPE html>

<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>๐Ÿ”ฎ Marble Run Builder!</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Fredoka+One&family=Nunito:wght@700&display=swap');
  *{margin:0;padding:0;box-sizing:border-box;}
  body{font-family:'Nunito',sans-serif;background:#12122a;color:#fff;overflow:hidden;height:100vh;display:flex;flex-direction:column;}

#header{background:linear-gradient(135deg,#e94560,#9b59b6);padding:7px 12px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;flex-wrap:wrap;gap:6px;box-shadow:0 3px 16px rgba(0,0,0,.5);}
#title{font-family:โ€˜Fredoka Oneโ€™,cursive;font-size:1.5em;text-shadow:2px 2px 0 rgba(0,0,0,.3);}
#level-badge{font-family:โ€˜Fredoka Oneโ€™,cursive;font-size:.9em;background:rgba(255,255,255,.22);padding:4px 12px;border-radius:20px;white-space:nowrap;}
.ctrls{display:flex;gap:6px;flex-wrap:wrap;align-items:center;}
.btn{font-family:โ€˜Fredoka Oneโ€™,cursive;font-size:.85em;padding:5px 13px;border:none;border-radius:18px;cursor:pointer;box-shadow:0 3px 0 rgba(0,0,0,.35);transition:transform .1s;}
.btn:active{transform:translateY(2px);box-shadow:0 1px 0 rgba(0,0,0,.35);}
.go{background:#2ecc71;color:#fff;} .del{background:#e94560;color:#fff;}
.und{background:#3498db;color:#fff;} .clr{background:#e67e22;color:#fff;}
.nxt{background:#f5a623;color:#333;}

#main{display:flex;flex:1;overflow:hidden;}

#panel{width:118px;background:#16213e;border-right:3px solid rgba(255,255,255,.1);display:flex;flex-direction:column;padding:4px;gap:3px;overflow-y:auto;flex-shrink:0;}
#panel::-webkit-scrollbar{width:3px;}
#panel::-webkit-scrollbar-thumb{background:rgba(255,255,255,.2);border-radius:3px;}
.psec{font-family:โ€˜Fredoka Oneโ€™,cursive;font-size:.62em;color:rgba(255,255,255,.4);text-transform:uppercase;letter-spacing:1px;text-align:center;padding-top:5px;border-top:1px solid rgba(255,255,255,.1);margin-top:3px;}
.pbtn{width:108px;height:60px;border:2px solid rgba(255,255,255,.16);border-radius:9px;background:rgba(255,255,255,.04);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;font-family:โ€˜Fredoka Oneโ€™,cursive;font-size:.62em;color:rgba(255,255,255,.85);transition:all .13s;flex-shrink:0;}
.pbtn:hover{border-color:rgba(255,255,255,.5);background:rgba(255,255,255,.1);transform:scale(1.03);}
.pbtn.sel{border-color:#f5a623;background:rgba(245,166,35,.18);box-shadow:0 0 10px rgba(245,166,35,.4);}
.pbtn canvas{width:64px;height:34px;}

#cw{flex:1;position:relative;overflow:hidden;}
#gc{display:block;cursor:crosshair;}

#ov{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.75);z-index:40;}
#ov.show{display:flex;}
#ovbox{background:linear-gradient(135deg,#9b59b6,#e94560);border-radius:22px;padding:26px 40px;text-align:center;box-shadow:0 16px 50px rgba(0,0,0,.6);max-width:400px;}
#ovbox h2{font-family:โ€˜Fredoka Oneโ€™,cursive;font-size:2.2em;margin-bottom:6px;}
#ovbox p{opacity:.92;margin-bottom:14px;}
#ovstars{font-size:2em;}

#bar{background:rgba(0,0,0,.35);padding:4px 12px;border-top:2px solid rgba(255,255,255,.08);display:flex;gap:16px;align-items:center;font-family:โ€˜Fredoka Oneโ€™,cursive;font-size:.8em;flex-shrink:0;}
.si{display:flex;align-items:center;gap:5px;}
.sd{width:10px;height:10px;border-radius:50%;}
#hint{margin-left:auto;font-size:.7em;opacity:.5;font-family:โ€˜Nunitoโ€™,sans-serif;}
</style>

</head>
<body>

<div id="header">
  <div id="title">๐Ÿ”ฎ Marble Run Builder!</div>
  <div id="level-badge">Level 1</div>
  <div class="ctrls">
    <button class="btn und" onclick="undoLast()">โ†ฉ Undo</button>
    <button class="btn clr" onclick="clearAll()">๐Ÿ—‘ Clear</button>
    <button class="btn go" id="runbtn" onclick="toggleRun()">โ–ถ Launch!</button>
    <button class="btn nxt" id="nxtbtn" style="display:none" onclick="nextLevel()">Next โžก</button>
  </div>
</div>

<div id="main">
  <div id="panel"></div>
  <div id="cw">
    <canvas id="gc"></canvas>
    <div id="ov">
      <div id="ovbox">
        <h2 id="ovtitle">๐ŸŽ‰ Level Complete!</h2>
        <p id="ovmsg">The marble reached the goal!</p>
        <div id="ovstars">โญโญโญ</div><br>
        <button class="btn nxt" onclick="nextLevel()" style="font-size:1em;padding:9px 22px;">Next Level โžก</button>
        <button class="btn del" onclick="retryLevel()" style="font-size:1em;padding:9px 22px;margin-left:8px;">๐Ÿ”„ Retry</button>
      </div>
    </div>
  </div>
</div>

<div id="bar">
  <div class="si"><div class="sd" style="background:#e94560"></div><span id="stp">Pieces: 0</span></div>
  <div class="si"><div class="sd" style="background:#f5a623"></div><span id="stm">Time: 0s</span></div>
  <div class="si"><div class="sd" style="background:#2ecc71"></div><span id="stg">In goal: 0</span></div>
  <div id="hint">Select a piece, then click the board to place it. Right-click to delete.</div>
</div>

<script>
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
//  MARBLE RUN โ€” FULLY REWRITTEN PHYSICS ENGINE
//  Key fix: collision uses closest-point-on-segment test,
//  pushes marble out regardless of approach direction,
//  checks BOTH normals so it always works.
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

const gc  = document.getElementById('gc');
const ctx = gc.getContext('2d');
const cw  = document.getElementById('cw');

function resize(){ gc.width=cw.clientWidth; gc.height=cw.clientHeight; }
resize();
window.addEventListener('resize',()=>{ resize(); redraw(); });

const PW=80, PH=44, GRID=40;
const GRAV=0.38, FRIC=0.991, BOUNCE_DAMP=0.55, SLIDE_FRIC=0.985;

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  PIECE CATALOGUE  (22 pieces)
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const PIECES={
  // RAMPS
  ramp_r:    {label:'Ramp โ†’',   color:'#3498db', cat:'Ramps'},
  ramp_l:    {label:'Ramp โ†',   color:'#2980b9', cat:'Ramps'},
  steep_r:   {label:'Steep โ†’',  color:'#1abc9c', cat:'Ramps'},
  steep_l:   {label:'Steep โ†',  color:'#16a085', cat:'Ramps'},
  curve_r:   {label:'Curve โ†’',  color:'#5dade2', cat:'Ramps'},
  curve_l:   {label:'Curve โ†',  color:'#2471a3', cat:'Ramps'},
  // PLATFORMS
  flat:      {label:'Flat',     color:'#9b59b6', cat:'Platforms'},
  wide_flat: {label:'Wide',     color:'#7d3c98', cat:'Platforms'},
  zig:       {label:'Zig-Zag',  color:'#884ea0', cat:'Platforms'},
  // LAUNCHERS
  spring:    {label:'Spring โ†‘', color:'#8e44ad', cat:'Launchers'},
  cannon:    {label:'Cannon โ†’', color:'#d35400', cat:'Launchers'},
  fan:       {label:'Fan โ†‘',    color:'#e67e22', cat:'Launchers'},
  // REDIRECTORS
  funnel:    {label:'Funnel',   color:'#e67e22', cat:'Redirectors'},
  splitter:  {label:'Split',    color:'#c0392b', cat:'Redirectors'},
  flip_l:    {label:'Flip โ†',   color:'#e74c3c', cat:'Redirectors'},
  flip_r:    {label:'Flip โ†’',   color:'#c0392b', cat:'Redirectors'},
  // SPECIAL
  bounce_pad:{label:'Bounce',   color:'#e94560', cat:'Special'},
  loop:      {label:'Loop',     color:'#f5a623', cat:'Special'},
  teleport:  {label:'Teleport', color:'#ff44ff', cat:'Special'},
  blackhole: {label:'B-Hole',   color:'#6c3483', cat:'Special'},
  // PIPES
  pipe_h:    {label:'Pipe H',   color:'#27ae60', cat:'Pipes'},
  pipe_v:    {label:'Pipe V',   color:'#1e8449', cat:'Pipes'},
};

const CATS=['Ramps','Platforms','Launchers','Redirectors','Special','Pipes'];

// For each piece type, return array of {x1,y1,x2,y2} collision segments
// relative to piece top-left corner (0,0) with piece size (PW x PH)
function getSegs(type){
  const W=PW, H=PH;
  switch(type){
    case 'ramp_r':    return [{x1:0,y1:H, x2:W,y2:0}];
    case 'ramp_l':    return [{x1:0,y1:0, x2:W,y2:H}];
    case 'steep_r':   return [{x1:0,y1:H, x2:W*.55,y2:0}];
    case 'steep_l':   return [{x1:W*.45,y1:0, x2:W,y2:H}];
    case 'curve_r':   return bezSegs(0,H, 0,0, W,0, 16);
    case 'curve_l':   return bezSegs(0,0, W,0, W,H, 16);
    case 'flat':      return [{x1:0,y1:H*.5, x2:W,y2:H*.5}];
    case 'wide_flat': return [{x1:0,y1:H*.5, x2:W,y2:H*.5}];
    case 'zig':       return [{x1:0,y1:H, x2:W*.5,y2:0},{x1:W*.5,y1:0, x2:W,y2:H}];
    case 'funnel':    return [{x1:0,y1:0, x2:W*.5,y2:H},{x1:W,y1:0, x2:W*.5,y2:H}];
    case 'splitter':  return [{x1:W*.5,y1:0, x2:W*.5,y2:H*.5},{x1:W*.5,y1:H*.5, x2:0,y2:H},{x1:W*.5,y1:H*.5, x2:W,y2:H}];
    case 'flip_l':    return [{x1:W,y1:0, x2:0,y2:H*.5},{x1:0,y1:H*.5, x2:W*.3,y2:H}];
    case 'flip_r':    return [{x1:0,y1:0, x2:W,y2:H*.5},{x1:W,y1:H*.5, x2:W*.7,y2:H}];
    default:          return [];
  }
}

// Sample a quadratic bezier into N line segments
function bezSegs(sx,sy,cpx,cpy,ex,ey,N){
  const segs=[];
  function bz(a,b,c,t){return (1-t)*(1-t)*a+2*(1-t)*t*b+t*t*c;}
  for(let i=0;i<N;i++){
    segs.push({
      x1:bz(sx,cpx,ex,i/N),   y1:bz(sy,cpy,ey,i/N),
      x2:bz(sx,cpx,ex,(i+1)/N),y2:bz(sy,cpy,ey,(i+1)/N)
    });
  }
  return segs;
}

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  LEVELS
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const ALL=Object.keys(PIECES);
const LEVELS=[
  {name:'Level 1 โ€“ First Drop',     start:{x:.1,y:.08}, goal:{x:.85,y:.85}, hint:'Place ramps to guide the marble to ๐Ÿ†!',        allowed:['ramp_r','ramp_l','flat'],                                             marbles:1,par:4},
  {name:'Level 2 โ€“ Zigzag',         start:{x:.82,y:.08},goal:{x:.15,y:.88}, hint:'Zigzag the marble down to the left!',            allowed:['ramp_r','ramp_l','flat','funnel','zig'],                              marbles:2,par:6},
  {name:'Level 3 โ€“ Bounce House',   start:{x:.1,y:.08}, goal:{x:.5,y:.88},  hint:'Bounce pads redirect marbles!',                  allowed:['ramp_r','ramp_l','flat','bounce_pad','funnel'],                       marbles:2,par:6},
  {name:'Level 4 โ€“ Speed Racer',    start:{x:.1,y:.08}, goal:{x:.88,y:.88}, hint:'Steep ramps = FAST marbles!',                    allowed:['ramp_r','ramp_l','steep_r','steep_l','flat','bounce_pad'],            marbles:3,par:8},
  {name:'Level 5 โ€“ Curves Ahead',   start:{x:.1,y:.08}, goal:{x:.5,y:.88},  hint:'Curved ramps for smooth arcs!',                  allowed:['ramp_r','ramp_l','curve_r','curve_l','flat','funnel'],                marbles:2,par:7},
  {name:'Level 6 โ€“ Pipe Dream',     start:{x:.5,y:.05}, goal:{x:.85,y:.88}, hint:'Pipes carry marbles through tunnels!',           allowed:['flat','pipe_h','pipe_v','ramp_r','ramp_l','funnel'],                  marbles:2,par:7},
  {name:'Level 7 โ€“ Splitsville',    start:{x:.5,y:.05}, goal:{x:.88,y:.88}, hint:'Split the marble to reach BOTH goals!',          allowed:['ramp_r','ramp_l','flat','splitter','funnel','bounce_pad'],            marbles:2,par:7,twoGoals:true,goal2:{x:.12,y:.88}},
  {name:'Level 8 โ€“ Spring Up!',     start:{x:.1,y:.88}, goal:{x:.85,y:.12}, hint:'Springs launch marbles UPWARD!',                 allowed:['ramp_r','ramp_l','spring','flat','funnel','bounce_pad'],              marbles:2,par:8},
  {name:'Level 9 โ€“ Cannon Run',     start:{x:.08,y:.5}, goal:{x:.88,y:.88}, hint:'Cannon blasts marble sideways!',                 allowed:['ramp_r','ramp_l','flat','cannon','bounce_pad','funnel','wide_flat'],  marbles:1,par:5},
  {name:'Level 10 โ€“ Loop de Loop',  start:{x:.08,y:.08},goal:{x:.85,y:.88}, hint:'Loop = the ultimate marble trick!',              allowed:['ramp_r','ramp_l','steep_r','flat','loop','bounce_pad','funnel','zig'],marbles:2,par:8},
  {name:'Level 11 โ€“ Warp Zone',     start:{x:.1,y:.08}, goal:{x:.85,y:.85}, hint:'Two teleporters warp the marble!',               allowed:['ramp_r','ramp_l','flat','teleport','bounce_pad','funnel','pipe_h'],   marbles:2,par:9},
  {name:'Level 12 โ€“ Marble Madness',start:{x:.1,y:.08}, goal:{x:.5,y:.88},  hint:'All pieces unlocked โ€” epic finale!',             allowed:ALL,                                                                   marbles:3,par:12},
];

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  STATE
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
let lvIdx=0, placed=[], selPiece=null;
let running=false, marbles=[], rafId=null;
let t0=0, elapsed=0, scored=0, won=false;
let ghost=null, timerInt=null;
let tpPairs=[];

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  PANEL BUILD
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function buildPanel(){
  const p=document.getElementById('panel'); p.innerHTML='';
  const ok=new Set(LEVELS[lvIdx].allowed);
  CATS.forEach(cat=>{
    const items=Object.entries(PIECES).filter(([k,v])=>v.cat===cat&&ok.has(k));
    if(!items.length) return;
    const s=document.createElement('div'); s.className='psec'; s.textContent=cat; p.appendChild(s);
    items.forEach(([type,info])=>{
      const btn=document.createElement('div'); btn.className='pbtn'; btn.id='pb_'+type; btn.title=info.label;
      const cv=document.createElement('canvas'); cv.width=70; cv.height=36;
      previewPiece(cv.getContext('2d'),type,70,36,info.color);
      btn.appendChild(cv);
      const lb=document.createElement('div'); lb.textContent=info.label; btn.appendChild(lb);
      btn.onclick=()=>pick(type);
      p.appendChild(btn);
    });
  });
}

function pick(t){
  selPiece=t;
  document.querySelectorAll('.pbtn').forEach(b=>b.classList.remove('sel'));
  const b=document.getElementById('pb_'+t); if(b) b.classList.add('sel');
  gc.style.cursor='crosshair';
}

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  DRAWING PIECES
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function previewPiece(c,type,W,H,color){
  c.clearRect(0,0,W,H);
  drawPiece(c,type,2,2,W-4,H-4,color,1,false);
}

function drawPiece(c,type,x,y,W,H,color,alpha,shadow){
  c.save(); c.globalAlpha=alpha;
  if(shadow){c.shadowColor='rgba(0,0,0,.45)';c.shadowBlur=8;c.shadowOffsetY=4;}
  c.strokeStyle=color; c.fillStyle=color;
  c.lineWidth=4; c.lineCap='round'; c.lineJoin='round';

  // Helper: draw a line in piece-local space, mapped to x,y,W,H
  const L=(ax,ay,bx,by,col,lw)=>{
    c.strokeStyle=col||color; c.lineWidth=lw||4;
    c.beginPath();
    c.moveTo(x+ax/PW*W, y+ay/PH*H);
    c.lineTo(x+bx/PW*W, y+by/PH*H);
    c.stroke();
  };
  const BEZ=(sx,sy,cpx,cpy,ex,ey,col,lw)=>{
    c.strokeStyle=col||color; c.lineWidth=lw||4;
    c.beginPath();
    c.moveTo(x+sx/PW*W, y+sy/PH*H);
    c.quadraticCurveTo(x+cpx/PW*W,y+cpy/PH*H, x+ex/PW*W,y+ey/PH*H);
    c.stroke();
  };

  switch(type){
    case 'ramp_r':   L(0,PH,PW,0); L(0,PH,PW,0,'rgba(255,255,255,.3)',1.5); break;
    case 'ramp_l':   L(0,0,PW,PH); L(0,0,PW,PH,'rgba(255,255,255,.3)',1.5); break;
    case 'steep_r':  L(0,PH,PW*.55,0); c.strokeStyle=color+'88';c.lineWidth=2; L(PW*.55,0,PW*.55,PH,color+'88',2); break;
    case 'steep_l':  L(PW*.45,0,PW,PH); c.strokeStyle=color+'88';c.lineWidth=2; L(PW*.45,0,PW*.45,PH,color+'88',2); break;
    case 'curve_r':  BEZ(0,PH, 0,0, PW,0); BEZ(0,PH,0,0,PW,0,'rgba(255,255,255,.3)',1.5); break;
    case 'curve_l':  BEZ(0,0, PW,0, PW,PH); BEZ(0,0,PW,0,PW,PH,'rgba(255,255,255,.3)',1.5); break;
    case 'flat':     L(0,PH*.5,PW,PH*.5); L(0,PH*.5,PW,PH*.5,'rgba(255,255,255,.3)',1.5); break;
    case 'wide_flat':
      c.fillStyle=color+'44'; c.fillRect(x,y+H*.38,W,H*.24);
      L(0,PH*.5,PW,PH*.5); break;
    case 'zig':      L(0,PH,PW*.5,0); L(PW*.5,0,PW,PH); break;
    case 'funnel':   L(0,0,PW*.5,PH); L(PW,0,PW*.5,PH); break;
    case 'splitter': L(PW*.5,0,PW*.5,PH*.5); L(PW*.5,PH*.5,0,PH); L(PW*.5,PH*.5,PW,PH); break;
    case 'flip_l':   L(PW,0,0,PH*.5); L(0,PH*.5,PW*.3,PH); break;
    case 'flip_r':   L(0,0,PW,PH*.5); L(PW,PH*.5,PW*.7,PH); break;
    case 'spring': {
      const sw=PW*.5,sx2=PW*.25;
      c.lineWidth=3;
      for(let i=0;i<5;i++){
        const ax=(i%2===0)?sx2:sx2+sw, bx=(i%2===0)?sx2+sw:sx2;
        L(ax,i*(PH/5), bx,(i+1)*(PH/5),color,3);
      }
      break;
    }
    case 'cannon': {
      // base circle
      c.beginPath(); c.arc(x+W*.22,y+H*.7, H*.25,0,Math.PI*2); c.fill();
      // barrel
      c.fillStyle=color;
      c.beginPath();
      c.roundRect(x+W*.25,y+H*.28, W*.65,H*.45,4);
      c.fill();
      // muzzle flash
      c.fillStyle='#f39c12';
      c.beginPath(); c.arc(x+W*.9,y+H*.5,5,0,Math.PI*2); c.fill();
      break;
    }
    case 'fan': {
      const fx=x+W*.5,fy=y+H*.65;
      c.fillStyle=color+'99';
      for(let i=0;i<3;i++){
        const a=i*(Math.PI*2/3)-Math.PI/2;
        c.beginPath(); c.moveTo(fx,fy);
        c.arc(fx,fy,H*.42,a,a+1.05); c.closePath(); c.fill();
      }
      c.fillStyle=color; c.beginPath(); c.arc(fx,fy,5,0,Math.PI*2); c.fill();
      L(0,PH,PW,PH,color,4);
      break;
    }
    case 'bounce_pad': {
      const r=Math.min(W,H)*.36;
      c.fillStyle=color; c.beginPath(); c.arc(x+W*.5,y+H*.65,r,0,Math.PI*2); c.fill();
      c.strokeStyle='#fff'; c.lineWidth=2;
      c.beginPath(); c.arc(x+W*.5,y+H*.45,r*.35,0,Math.PI*2); c.stroke();
      break;
    }
    case 'loop': {
      const r=Math.min(W,H)*.36;
      c.strokeStyle=color; c.lineWidth=4;
      c.beginPath(); c.arc(x+W*.5,y+H*.5,r,0,Math.PI*2); c.stroke();
      L(0,PH*.5,PW*.5-PW*.36/PW*PW,PH*.5);
      L(PW*.5+PW*.36/PW*PW,PH*.5,PW,PH*.5);
      break;
    }
    case 'teleport': {
      const r=Math.min(W,H)*.38;
      const grd=c.createRadialGradient(x+W*.5,y+H*.5,1,x+W*.5,y+H*.5,r);
      grd.addColorStop(0,'#fff'); grd.addColorStop(.5,color); grd.addColorStop(1,'transparent');
      c.fillStyle=grd; c.beginPath(); c.arc(x+W*.5,y+H*.5,r,0,Math.PI*2); c.fill();
      c.strokeStyle='rgba(255,255,255,.8)'; c.lineWidth=2;
      c.beginPath(); c.arc(x+W*.5,y+H*.5,r,0,Math.PI*2); c.stroke();
      c.fillStyle='white'; c.font=`${H*.45}px serif`;
      c.textAlign='center'; c.textBaseline='middle'; c.fillText('โœฆ',x+W*.5,y+H*.5);
      break;
    }
    case 'blackhole': {
      const r=Math.min(W,H)*.38, bx2=x+W*.5, by2=y+H*.5;
      for(let i=4;i>=1;i--){
        c.strokeStyle=`rgba(108,52,131,${i*.2})`; c.lineWidth=i*2.5;
        c.beginPath(); c.arc(bx2,by2,r*(i/4),0,Math.PI*2); c.stroke();
      }
      c.fillStyle='#100020'; c.beginPath(); c.arc(bx2,by2,r*.28,0,Math.PI*2); c.fill();
      break;
    }
    case 'pipe_h': {
      c.fillStyle=color;
      c.beginPath(); c.roundRect(x,y+H*.27,W,H*.46,7); c.fill();
      c.fillStyle='rgba(255,255,255,.2)';
      c.beginPath(); c.roundRect(x+2,y+H*.27,W-4,H*.18,5); c.fill();
      break;
    }
    case 'pipe_v': {
      c.fillStyle=color;
      c.beginPath(); c.roundRect(x+W*.3,y,W*.4,H,7); c.fill();
      c.fillStyle='rgba(255,255,255,.2)';
      c.beginPath(); c.roundRect(x+W*.3+2,y+2,W*.14,H-4,5); c.fill();
      break;
    }
  }

  c.shadowBlur=0; c.shadowOffsetY=0; c.globalAlpha=1; c.restore();
}

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  MAIN REDRAW
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function redraw(){
  const W=gc.width, H=gc.height;
  ctx.clearRect(0,0,W,H);

  // Background
  const bg=ctx.createLinearGradient(0,0,0,H);
  bg.addColorStop(0,'#1a1a3e'); bg.addColorStop(1,'#0b0b1e');
  ctx.fillStyle=bg; ctx.fillRect(0,0,W,H);

  // Dot grid
  ctx.fillStyle='rgba(255,255,255,.03)';
  for(let gx=GRID;gx<W;gx+=GRID)
    for(let gy=GRID;gy<H;gy+=GRID)
      ctx.fillRect(gx-1,gy-1,2,2);

  // Placed pieces
  placed.forEach(p=>{
    drawPiece(ctx,p.type,p.x-PW/2,p.y-PH/2,PW,PH,PIECES[p.type].color,1,true);
  });

  // Ghost
  if(ghost&&selPiece&&!running){
    drawPiece(ctx,selPiece,ghost.x-PW/2,ghost.y-PH/2,PW,PH,PIECES[selPiece].color,.35,false);
  }

  const lv=LEVELS[lvIdx];

  // Start marker
  marker(lv.start.x*W, lv.start.y*H, '#2ecc71', 'START');

  // Goal(s)
  goal(lv.goal.x*W, lv.goal.y*H, '#f5a623');
  if(lv.twoGoals) goal(lv.goal2.x*W, lv.goal2.y*H, '#2ecc71');

  // Marbles
  marbles.forEach(m=>{
    if(!m.alive) return;
    ctx.save();
    const gr=ctx.createRadialGradient(m.x-m.r*.35,m.y-m.r*.35,m.r*.1, m.x,m.y,m.r);
    gr.addColorStop(0,'#ffffff');
    gr.addColorStop(.35,m.color);
    gr.addColorStop(1,'rgba(0,0,0,.8)');
    ctx.shadowColor=m.color; ctx.shadowBlur=18;
    ctx.fillStyle=gr;
    ctx.beginPath(); ctx.arc(m.x,m.y,m.r,0,Math.PI*2); ctx.fill();
    ctx.restore();
  });
}

function marker(x,y,col,text){
  ctx.save(); ctx.shadowColor=col; ctx.shadowBlur=16;
  ctx.fillStyle=col;
  ctx.beginPath();
  ctx.moveTo(x-16,y-12); ctx.lineTo(x+16,y-12);
  ctx.lineTo(x+16,y+10); ctx.lineTo(x,y+22); ctx.lineTo(x-16,y+10);
  ctx.closePath(); ctx.fill();
  ctx.fillStyle='white'; ctx.font='bold 10px Nunito';
  ctx.textAlign='center'; ctx.textBaseline='middle';
  ctx.fillText(text,x,y+1);
  ctx.restore();
}

function goal(x,y,col){
  ctx.save(); ctx.shadowColor=col; ctx.shadowBlur=22;
  ctx.strokeStyle=col; ctx.lineWidth=3; ctx.strokeRect(x-22,y-18,44,36);
  ctx.fillStyle=col+'2a'; ctx.fillRect(x-22,y-18,44,36);
  ctx.font='18px serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
  ctx.fillText('๐Ÿ†',x,y);
  ctx.restore();
}

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  SEGMENT COLLISION  โ€” THE FIXED VERSION
//
//  Finds the closest point on segment to marble centre.
//  If closer than marble.r, pushes marble OUT along
//  the perpendicular. Works from BOTH sides.
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function segCollide(m, ax,ay, bx,by){
  // Segment vector
  const dx=bx-ax, dy=by-ay;
  const lenSq=dx*dx+dy*dy;
  if(lenSq<0.001) return;

  // t = projection of marble centre onto segment [0,1]
  const t=Math.max(0,Math.min(1,((m.x-ax)*dx+(m.y-ay)*dy)/lenSq));

  // Closest point on segment
  const cx=ax+t*dx, cy=ay+t*dy;

  // Distance from marble centre to closest point
  const ex=m.x-cx, ey=m.y-cy;
  const distSq=ex*ex+ey*ey;
  const minDist=m.r+1.5; // small padding

  if(distSq<minDist*minDist && distSq>0.0001){
    const dist=Math.sqrt(distSq);
    // Unit normal pointing from segment towards marble
    const nx=ex/dist, ny=ey/dist;
    // Push marble out so it just touches the segment
    const push=minDist-dist;
    m.x+=nx*push;
    m.y+=ny*push;
    // Reflect velocity component along normal (with damping)
    const vDotN=m.vx*nx+m.vy*ny;
    if(vDotN<0){ // marble moving into segment
      m.vx-=(1+BOUNCE_DAMP)*vDotN*nx;
      m.vy-=(1+BOUNCE_DAMP)*vDotN*ny;
      // Friction along surface
      const tx=-ny, ty=nx;
      const vDotT=m.vx*tx+m.vy*ty;
      m.vx-=vDotT*tx*(1-SLIDE_FRIC);
      m.vy-=vDotT*ty*(1-SLIDE_FRIC);
    }
  }
}

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  SPECIAL PIECE EFFECTS (non-segment)
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function specialEffect(m,p){
  const px=p.x-PW/2, py=p.y-PH/2;

  if(p.type==='bounce_pad'){
    const cx=p.x, cy=p.y+PH*.15;
    const r=Math.min(PW,PH)*.36;
    const dx=m.x-cx, dy=m.y-cy, d=Math.sqrt(dx*dx+dy*dy);
    if(d<r+m.r && d>0.01){
      const nx=dx/d, ny=dy/d;
      const minD=r+m.r+1;
      m.x=cx+nx*minD; m.y=cy+ny*minD;
      const vn=m.vx*nx+m.vy*ny;
      if(vn<0){ m.vx-=2.6*vn*nx; m.vy-=2.6*vn*ny; }
    }
    return;
  }

  if(p.type==='loop'){
    const cx=p.x, cy=p.y;
    const r=Math.min(PW,PH)*.36;
    const dx=m.x-cx, dy=m.y-cy, d=Math.sqrt(dx*dx+dy*dy);
    if(d>0.01 && d<r+m.r+12 && d>r-m.r-12){
      const nx=dx/d, ny=dy/d;
      // keep marble on loop track
      const target=r;
      if(d>target){ m.x=cx+nx*(target-m.r-1); m.y=cy+ny*(target-m.r-1); }
      else         { m.x=cx+nx*(target+m.r+1); m.y=cy+ny*(target+m.r+1); }
      const spd=Math.max(Math.sqrt(m.vx*m.vx+m.vy*m.vy),5);
      // tangent direction (counter-clockwise)
      m.vx=-ny*spd; m.vy=nx*spd;
    }
    return;
  }

  if(p.type==='spring'){
    if(m.x>px && m.x<px+PW && m.y>py+PH*.4 && m.y<py+PH+m.r+2){
      m.y=py+PH*.35-m.r;
      m.vy=-16;
      m.vx+=(Math.random()-.5)*1.5;
    }
    return;
  }

  if(p.type==='cannon'){
    if(m.x>px-4 && m.x<px+PW*.4 && m.y>py+PH*.12 && m.y<py+PH*.88){
      m.x=px+PW+16; m.y=py+PH*.5;
      m.vx=18; m.vy=-1.5;
    }
    return;
  }

  if(p.type==='pipe_h'){
    const top=py+PH*.27, bot=py+PH*.73;
    if(m.x>px && m.x<px+PW){
      if(m.y-m.r<top && m.y>py){ m.y=top+m.r; m.vy=Math.abs(m.vy)*.2; }
      if(m.y+m.r>bot && m.y<py+PH){ m.y=bot-m.r; m.vy=-Math.abs(m.vy)*.2; }
    }
    return;
  }

  if(p.type==='pipe_v'){
    const lft=px+PW*.3, rgt=px+PW*.7;
    if(m.y>py && m.y<py+PH){
      if(m.x-m.r<lft && m.x>px){ m.x=lft+m.r; m.vx=Math.abs(m.vx)*.2; }
      if(m.x+m.r>rgt && m.x<px+PW){ m.x=rgt-m.r; m.vx=-Math.abs(m.vx)*.2; }
    }
    return;
  }

  if(p.type==='fan'){
    if(m.x>px && m.x<px+PW && m.y>py-70 && m.y<py+PH) m.vy-=1.4;
    return;
  }

  if(p.type==='blackhole'){
    const dx=p.x-m.x, dy=p.y-m.y, d=Math.sqrt(dx*dx+dy*dy);
    if(d<150 && d>1){ m.vx+=(dx/d)*0.85; m.vy+=(dy/d)*0.85; }
    if(d<18){ m.alive=false; }
    return;
  }
}

// Pieces that use segment collision
const SEG_TYPES=new Set(['ramp_r','ramp_l','steep_r','steep_l','curve_r','curve_l','flat','wide_flat','zig','funnel','splitter','flip_l','flip_r']);
// Pieces that use special effect code
const SPE_TYPES=new Set(['bounce_pad','loop','spring','cannon','pipe_h','pipe_v','fan','blackhole']);

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  PHYSICS LOOP
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const SUBSTEPS=4; // run physics multiple times per frame for accuracy

function physicsStep(dt){
  const W=gc.width, H=gc.height;
  const lv=LEVELS[lvIdx];

  marbles.forEach(m=>{
    if(!m.alive) return;

    // Sub-step for accuracy
    for(let s=0;s<SUBSTEPS;s++){
      // Gravity & drag each sub-step
      m.vy+=GRAV/SUBSTEPS;
      m.vx*=Math.pow(FRIC,1/SUBSTEPS);
      m.vy*=Math.pow(FRIC,1/SUBSTEPS);

      m.x+=m.vx/SUBSTEPS;
      m.y+=m.vy/SUBSTEPS;

      // Collision with all placed pieces
      placed.forEach(p=>{
        const ox=p.x-PW/2, oy=p.y-PH/2;
        if(SEG_TYPES.has(p.type)){
          const segs=getSegs(p.type);
          segs.forEach(seg=>{
            segCollide(m, ox+seg.x1,oy+seg.y1, ox+seg.x2,oy+seg.y2);
          });
        }
      });

      // Special effects (only once per frame, not substep, handled below)
    }

    // Special effects once per frame
    placed.forEach(p=>{ if(SPE_TYPES.has(p.type)) specialEffect(m,p); });

    // Teleport
    tpPairs.forEach((pair,pi)=>{
      pair.forEach((tp,ti)=>{
        if(m.lastTP===pi) return;
        if(Math.abs(m.x-tp.x)<16 && Math.abs(m.y-tp.y)<16){
          const dst=pair[1-ti];
          m.x=dst.x; m.y=dst.y;
          m.vy=Math.abs(m.vy)+2; m.vx*=.4;
          m.lastTP=pi;
          setTimeout(()=>{ m.lastTP=-1; },600);
        }
      });
    });

    // Goal check
    const gx=lv.goal.x*W, gy=lv.goal.y*H;
    if(!m.scored && Math.abs(m.x-gx)<26 && Math.abs(m.y-gy)<26){
      m.scored=true; m.alive=false; scored++; checkWin();
    }
    if(lv.twoGoals){
      const gx2=lv.goal2.x*W, gy2=lv.goal2.y*H;
      if(!m.scored && Math.abs(m.x-gx2)<26 && Math.abs(m.y-gy2)<26){
        m.scored=true; m.alive=false; scored++; checkWin();
      }
    }

    // Out of bounds
    if(m.y>H+50||m.x<-80||m.x>W+80){ m.alive=false; checkWin(); }

    // Floor
    if(m.y>H-m.r){ m.y=H-m.r; m.vy*=-0.3; m.vx*=0.92; }
    // Walls
    if(m.x<m.r){ m.x=m.r; m.vx=Math.abs(m.vx)*.5; }
    if(m.x>W-m.r){ m.x=W-m.r; m.vx=-Math.abs(m.vx)*.5; }
  });
}

function gameLoop(){
  if(!running) return;
  physicsStep(1/60);
  redraw();
  updateBar();
  rafId=requestAnimationFrame(gameLoop);
}

function toggleRun(){
  if(running){ stopRun(); return; }
  // Build teleport pairs
  tpPairs=[];
  const tps=placed.filter(p=>p.type==='teleport');
  for(let i=0;i<tps.length-1;i+=2) tpPairs.push([tps[i],tps[i+1]]);

  running=true; scored=0; won=false;
  const lv=LEVELS[lvIdx];
  const W=gc.width, H=gc.height;
  const cols=['#ff4466','#44aaff','#ffcc00','#44ee88','#cc44ff','#44ffee'];
  marbles=[];
  for(let i=0;i<lv.marbles;i++){
    marbles.push({
      x:lv.start.x*W+(i-Math.floor(lv.marbles/2))*18,
      y:lv.start.y*H+10,
      vx:(Math.random()-.5)*.3,
      vy:0, r:10, color:cols[i%cols.length],
      alive:true, scored:false, lastTP:-1
    });
  }
  t0=Date.now();
  timerInt=setInterval(()=>{
    elapsed=((Date.now()-t0)/1000).toFixed(1);
    document.getElementById('stm').textContent='Time: '+elapsed+'s';
  },100);
  document.getElementById('runbtn').textContent='โ–  Stop';
  gameLoop();
}

function stopRun(){
  running=false; cancelAnimationFrame(rafId); clearInterval(timerInt);
  document.getElementById('runbtn').textContent='โ–ถ Launch!';
}

function checkWin(){
  if(!marbles.every(m=>!m.alive)) return;
  stopRun();
  if(won) return; won=true;
  const lv=LEVELS[lvIdx];
  const need=lv.twoGoals?2:lv.marbles;
  scored>=need ? showWin() : showLose();
}

function showWin(){
  const n=placed.length, par=LEVELS[lvIdx].par;
  const stars=n<=par?'โญโญโญ':n<=par*1.4?'โญโญ':'โญ';
  document.getElementById('ovtitle').textContent='๐ŸŽ‰ Level Complete!';
  document.getElementById('ovmsg').textContent=scored+' marble'+(scored>1?'s':'')+' in goal! '+n+' pieces used in '+elapsed+'s';
  document.getElementById('ovstars').textContent=stars;
  document.getElementById('ov').classList.add('show');
  if(lvIdx<LEVELS.length-1) document.getElementById('nxtbtn').style.display='';
}
function showLose(){
  document.getElementById('ovtitle').textContent='๐Ÿ˜ฎ Try Again!';
  document.getElementById('ovmsg').textContent='Only '+scored+' marble'+(scored>1?'s':'')+' reached the goal. Need '+LEVELS[lvIdx].marbles+'!';
  document.getElementById('ovstars').textContent='';
  document.getElementById('ov').classList.add('show');
}
function nextLevel(){
  if(lvIdx<LEVELS.length-1){ lvIdx++; resetLv(); }
  else{
    document.getElementById('ovtitle').textContent='๐ŸŒŸ YOU WIN ALL LEVELS!';
    document.getElementById('ovmsg').textContent='You are a Marble Run LEGEND!';
    document.getElementById('ovstars').textContent='๐ŸŒŸ๐ŸŒŸ๐ŸŒŸ';
  }
}
function retryLevel(){ resetLv(); }
function resetLv(){
  stopRun(); placed=[]; marbles=[]; scored=0; elapsed=0; won=false;
  document.getElementById('ov').classList.remove('show');
  document.getElementById('nxtbtn').style.display='none';
  document.getElementById('runbtn').textContent='โ–ถ Launch!';
  document.getElementById('level-badge').textContent=LEVELS[lvIdx].name;
  document.getElementById('hint').textContent=LEVELS[lvIdx].hint;
  buildPanel(); updateBar(); redraw();
}
function clearAll(){ if(running)return; placed=[]; redraw(); updateBar(); }
function undoLast(){ if(running)return; placed.pop(); redraw(); updateBar(); }
function updateBar(){
  document.getElementById('stp').textContent='Pieces: '+placed.length;
  document.getElementById('stg').textContent='In goal: '+scored;
}

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  INPUT
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
gc.addEventListener('mousemove',e=>{
  const r=gc.getBoundingClientRect();
  ghost={x:sn(e.clientX-r.left), y:sn(e.clientY-r.top)};
  if(!running) redraw();
});
gc.addEventListener('mouseleave',()=>{ ghost=null; if(!running) redraw(); });

gc.addEventListener('click',e=>{
  if(!selPiece||running) return;
  const r=gc.getBoundingClientRect();
  placed.push({type:selPiece, x:sn(e.clientX-r.left), y:sn(e.clientY-r.top)});
  updateBar(); redraw();
});

gc.addEventListener('contextmenu',e=>{
  e.preventDefault(); if(running) return;
  const r=gc.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top;
  // find topmost piece under cursor
  for(let i=placed.length-1;i>=0;i--){
    if(Math.abs(placed[i].x-mx)<PW/2+4 && Math.abs(placed[i].y-my)<PH/2+4){
      placed.splice(i,1); redraw(); updateBar(); return;
    }
  }
  // if nothing hit, deselect piece
  selPiece=null;
  document.querySelectorAll('.pbtn').forEach(b=>b.classList.remove('sel'));
  gc.style.cursor='default';
});

gc.addEventListener('touchstart',e=>{
  e.preventDefault(); if(!selPiece||running) return;
  const r=gc.getBoundingClientRect(), t=e.touches[0];
  placed.push({type:selPiece, x:sn(t.clientX-r.left), y:sn(t.clientY-r.top)});
  updateBar(); redraw();
},{passive:false});

function sn(v){ return Math.round(v/GRID)*GRID; }

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
//  INIT
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
resetLv();
</script>

</body>
</html>

Game Source: ๐Ÿ”ฎ Marble Run Builder!

Creator: StealthWolf75

Libraries: none

Complexity: complex (794 lines, 33.3 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: marble-run-builder-stealthwolf75-mm83abo6" to link back to the original. Then publish at arcadelab.ai/publish.