๐ฎ Marble Run Builder!
by StealthWolf75794 lines33.3 KB
<!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.