alexagame / webapp /carrom_uploaded_source.html
hansaka01's picture
Upload 60 files
d0c7582 verified
Raw
History Blame Contribute Delete
64.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<title>Carrom Board Game</title>
<style>
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{height:100%;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#f3e9d2;background:#2c3e50;overflow:auto;user-select:none;-webkit-user-select:none}
#app{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;padding:8px;gap:8px;min-height:100%}
/* ===== Player panel ===== */
.players{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:min(95vw,580px)}
.players.team{grid-template-columns:1fr 1fr}
.pl{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 12px;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.1);border-radius:12px;font-size:12px;transition:.25s}
.pl.active{background:rgba(255,200,100,.18);box-shadow:0 0 0 2px rgba(255,200,100,.55) inset}
.pl .name{font-weight:700;font-size:13px;display:flex;align-items:center;gap:6px}
.pl .pocketed{display:flex;flex-wrap:wrap;gap:2px;justify-content:center;min-height:18px;max-width:160px}
.pl .pocketed .pp{width:12px;height:12px;border-radius:50%;border:1.5px solid #222;box-shadow:inset 0 -1px 2px rgba(0,0,0,.4),inset 0 1px 1px rgba(255,255,255,.4)}
.pl .pocketed .pp.w{background:radial-gradient(circle at 35% 30%,#fff,#d8cdb4)}
.pl .pocketed .pp.b{background:radial-gradient(circle at 35% 30%,#5a5a5a,#0a0a0a)}
.pl .pocketed .pp.q{background:radial-gradient(circle at 35% 30%,#ff6a6a,#8a0a0a)}
.pl .score{font-weight:700;color:#ffd98a;font-size:18px}
.pl .matchpts{font-size:11px;opacity:.75}
.dot{width:14px;height:14px;border-radius:50%;border:2px solid #222;box-shadow:inset 0 -2px 3px rgba(0,0,0,.4),inset 0 2px 2px rgba(255,255,255,.4)}
.dot.w{background:radial-gradient(circle at 35% 30%,#fff,#d8cdb4)}
.dot.b{background:radial-gradient(circle at 35% 30%,#5a5a5a,#0a0a0a)}
.status{padding:6px 14px;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.1);border-radius:20px;font-size:13px;color:#ffd98a;text-align:center;min-height:30px;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap}
.status .badge{background:rgba(255,200,100,.2);padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
.status .badge.q{background:rgba(255,80,80,.25);color:#ffa0a0}
/* ===== Board ===== */
.board-container{
position:relative;
width:min(95vw, 580px);
aspect-ratio:1/1;
box-shadow:0 20px 50px rgba(0,0,0,0.6);
border-radius:46px;
touch-action:none;
}
.layer{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;fill:none}
#gameLayer{pointer-events:auto;cursor:crosshair}
/* ===== Bottom controls ===== */
.swipe-wrap{width:min(95vw,580px);padding:10px 14px;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.1);border-radius:12px;display:flex;align-items:center;gap:10px}
.swipe-wrap label{font-size:11px;color:#ffd98a;font-weight:600;min-width:96px}
.hud{display:flex;align-items:center;gap:8px;padding:8px 12px;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.1);border-radius:12px;width:min(95vw,580px);flex-wrap:wrap}
.hud label{font-size:12px;color:#ffd98a;font-weight:600;min-width:42px}
#power,#slide{flex:1;min-width:120px;accent-color:#ffaa33;height:6px}
#powerVal{font-size:12px;color:#ffd98a;min-width:36px;text-align:right}
.btn{background:linear-gradient(180deg,#c8862a,#8a5a18);color:#fff;border:none;padding:7px 12px;border-radius:8px;font-weight:600;font-size:12px;cursor:pointer;box-shadow:0 2px 4px rgba(0,0,0,.4)}
.btn.secondary{background:linear-gradient(180deg,#4a5568,#2d3748)}
/* ===== Modals ===== */
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.78);display:none;align-items:center;justify-content:center;z-index:50;padding:20px}
.overlay.show{display:flex}
.card{background:#1a1a1d;border:1px solid #2c2c30;border-radius:14px;padding:24px;max-width:380px;width:100%;text-align:center;max-height:90vh;overflow:auto}
.card h2{color:#ffd98a;margin-bottom:12px;font-size:20px}
.card p{font-size:13px;opacity:.9;margin-bottom:12px;line-height:1.5}
.card .row{display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin:10px 0}
.card button{background:linear-gradient(180deg,#c8862a,#8a5a18);color:#fff;border:none;padding:10px 18px;border-radius:8px;font-weight:600;cursor:pointer;font-size:13px}
.card button.alt{background:linear-gradient(180deg,#4a5568,#2d3748)}
.card .modesel{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px}
.card .modesel button{padding:14px 8px;line-height:1.3;font-size:12px}
.card .modesel button .sub{display:block;opacity:.75;font-size:10px;margin-top:3px;font-weight:400}
.ruleslist{text-align:left;font-size:12px;line-height:1.55;margin:8px 0 12px;max-height:50vh;overflow:auto;padding-right:6px}
.ruleslist h3{color:#ffd98a;font-size:13px;margin-top:10px;margin-bottom:4px}
.ruleslist li{margin-left:18px;margin-bottom:3px}
/* Toast for foul/event messages */
.toast{position:fixed;top:14px;left:50%;transform:translateX(-50%) translateY(-60px);background:rgba(0,0,0,.85);color:#fff;padding:10px 18px;border-radius:8px;font-size:13px;font-weight:600;z-index:60;border:1px solid rgba(255,200,100,.3);transition:transform .35s ease;pointer-events:none;max-width:90vw;text-align:center}
.toast.show{transform:translateX(-50%) translateY(0)}
.toast.foul{border-color:#ff7070;color:#ffb0b0}
.toast.good{border-color:#7fe07f;color:#c0ffc0}
</style>
</head>
<body>
<div id="app">
<div class="players" id="playersBar">
<!-- populated by JS based on mode -->
</div>
<div class="status" id="statusBar">
<span id="turnText">P1 to play</span>
<span class="badge" id="boardBadge">Board 1</span>
<span class="badge q" id="queenBadge" style="display:none">Queen pending — must cover!</span>
</div>
<div class="board-container">
<!-- ===== Board frame ===== -->
<svg class="layer" viewBox="0 0 680 680">
<!-- stock inside frame is bord corner -->
<rect x="20" y="20" width="640" height="640" rx="39" fill="#DCB284" stroke="#3D1B18" stroke-width="40"/>
</svg>
<!-- ===== pockets ===== -->
<svg class="layer" id='pct-leftup' viewBox="0 0 680 680"><circle cx="65" cy="65" r="25" fill="#281310"/></svg>
<svg class="layer" id='pct-rightup' viewBox="0 0 680 680"><circle cx="615" cy="65" r="25" fill="#281310"/></svg>
<svg class="layer" id='pct-rightdown' viewBox="0 0 680 680"><circle cx="615" cy="615" r="25" fill="#281310"/></svg>
<svg class="layer" id='pct-leftdown' viewBox="0 0 680 680"><circle cx="65" cy="615" r="25" fill="#281310"/></svg>
<!-- ===== Baseline tracks (rounded pill outlines) ===== -->
<svg class="layer" id='stkbr-up' viewBox="0 0 680 680">
<path d="M500 137C507.18 137 513 142.82 513 150C513 157.18 507.18 163 500 163H180C172.82 163 167 157.18 167 150C167 142.82 172.82 137 180 137H500Z" stroke="#1D0D0B" stroke-width="2"/>
</svg>
<svg class="layer" id='stkbr-up' viewBox="0 0 680 680">
<path d="M500 517C507.18 517 513 522.82 513 530C513 537.18 507.18 543 500 543H180C172.82 543 167 537.18 167 530C167 522.82 172.82 517 180 517H500Z" stroke="#1D0D0B" stroke-width="2"/>
</svg>
<svg class="layer" id='stkbr-right' viewBox="0 0 680 680">
<path d="M517 180C517 172.82 522.82 167 530 167C537.18 167 543 172.82 543 180V500C543 507.18 537.18 513 530 513C522.82 513 517 507.18 517 500V180Z" stroke="#1D0D0B" stroke-width="2"/>
</svg>
<svg class="layer" id='stkbr-left' viewBox="0 0 680 680">
<path d="M137 180C137 172.82 142.82 167 150 167C157.18 167 163 172.82 163 180L163 500C163 507.18 157.18 513 150 513C142.82 513 137 507.18 137 500L137 180Z" stroke="#1D0D0B" stroke-width="2"/>
</svg>
<!-- ===== stk bar Red circles (8) ===== -->
<svg class="layer" viewBox="0 0 680 680"><circle cx="180" cy="150" r="13" fill="#BB1D2D" stroke="#1D0D0B" stroke-width="2"/></svg>
<svg class="layer" viewBox="0 0 680 680"><circle cx="150" cy="180" r="13" fill="#BB1D2D" stroke="#1D0D0B" stroke-width="2"/></svg>
<svg class="layer" viewBox="0 0 680 680"><circle cx="500" cy="150" r="13" fill="#BB1D2D" stroke="#1D0D0B" stroke-width="2"/></svg>
<svg class="layer" viewBox="0 0 680 680"><circle cx="530" cy="180" r="13" fill="#BB1D2D" stroke="#1D0D0B" stroke-width="2"/></svg>
<svg class="layer" viewBox="0 0 680 680"><circle cx="530" cy="499" r="13" fill="#BB1D2D" stroke="#1D0D0B" stroke-width="2"/></svg>
<svg class="layer" viewBox="0 0 680 680"><circle cx="500" cy="530" r="13" fill="#BB1D2D" stroke="#1D0D0B" stroke-width="2"/></svg>
<svg class="layer" viewBox="0 0 680 680"><circle cx="180" cy="530" r="13" fill="#BB1D2D" stroke="#1D0D0B" stroke-width="2"/></svg>
<svg class="layer" viewBox="0 0 680 680"><circle cx="150" cy="499" r="13" fill="#BB1D2D" stroke="#1D0D0B" stroke-width="2"/></svg>
<!-- ===== Central compass: outer/inner/dashed rings ===== -->
<svg class="layer" viewBox="0 0 680 680"><circle cx="340" cy="340" r="62" stroke="#1D0D0B" stroke-width="4"/></svg>
<svg class="layer" viewBox="0 0 680 680"><circle cx="339.5" cy="340.5" r="37" stroke="#1D0D0B"/></svg>
<svg class="layer" viewBox="0 0 680 680"><circle cx="340" cy="340" r="49.5" stroke="#1D0D0B" stroke-dasharray="3 2"/></svg>
<!-- ===== Compass star: thin outline guide star ===== -->
<svg class="layer" viewBox="0 0 680 680">
<path d="M330.808 330.808L340 277.775L349.192 330.808L402.225 340L349.192 349.192L340 402.225L330.808 349.192L277.775 340L330.808 330.808Z" stroke="#1D0D0B"/>
</svg>
<!-- ===== Compass star: dark filled N/S/E/W star ===== -->
<svg class="layer" viewBox="0 0 680 680">
<path d="M340 278L348 324L340 328V340H402L356 348L348 356L340 352V402L332 356L328 340H278L324 332L340 328V278Z" fill="#1D0D0B"/>
</svg>
<svg class="layer" viewBox="0 0 680 680">
<path d="M340 328V278L348 324L340 328ZM340 328V340H402L356 348L348 356L340 352V402L332 356L328 340H278L324 332L340 328Z" stroke="#1D0D0B"/>
</svg>
<!-- ===== Compass star: cream diagonal star ===== -->
<svg class="layer" viewBox="0 0 680 680">
<path d="M327 340L296 296L340 327L384 296L353 340L384 384L340 353L296 384L327 340Z" fill="#DCB284" stroke="#1D0D0B"/>
</svg>
<svg class="layer" viewBox="0 0 680 680">
<path d="M296 296L340 340M384 384L340 340M340 340L296 384M340 340L384 296" stroke="#1D0D0B"/>
</svg>
<!-- ===== Compass hub[red peace adding position] (center red dot, layered) ===== -->
<svg class="layer" viewBox="0 0 680 680"><circle cx="340" cy="340" r="10.5" fill="#BB1D2D" stroke="#1D0D0B" stroke-width="5"/></svg>
<svg class="layer" viewBox="0 0 680 680"><circle cx="340" cy="340" r="8.5" fill="#BB1D2D" stroke="#DCB284" stroke-width="5"/></svg>
<svg class="layer" viewBox="0 0 680 680"><circle cx="340" cy="340" r="7" fill="#BB1D2D" stroke="#1D0D0B" stroke-width="2"/></svg>
<!-- ===== Corner decorations: hook + arrow (top-left) ===== -->
<svg class="layer" viewBox="0 0 680 680">
<path d="M218.707 234.296C218.316 233.905 217.683 233.905 217.293 234.296L210.93 240.661C210.539 241.051 210.54 241.685 210.93 242.075C211.321 242.466 211.954 242.465 212.344 242.075L218 236.417L223.658 242.073C224.049 242.463 224.682 242.463 225.072 242.073C225.463 241.682 225.463 241.049 225.072 240.659L218.707 234.296ZM234.29 217.293C233.899 217.684 233.9 218.317 234.29 218.707L240.655 225.07C241.046 225.461 241.679 225.46 242.069 225.07C242.46 224.679 242.46 224.046 242.069 223.656L236.411 218L242.067 212.342C242.458 211.951 242.458 211.318 242.067 210.928C241.676 210.537 241.043 210.537 240.653 210.928L234.29 217.293ZM112.65 111.65C112.097 111.65 111.65 112.097 111.65 112.65V121.65C111.65 122.202 112.097 122.65 112.65 122.65C113.202 122.65 113.65 122.202 113.65 121.65V113.65H121.65C122.202 113.65 122.65 113.202 122.65 112.65C122.65 112.097 122.202 111.65 121.65 111.65H112.65ZM252 234.997L251 234.997C251.001 243.834 243.839 250.999 235.003 251L235.003 252L235.003 253C244.944 252.998 253.002 244.938 253 234.997L252 234.997ZM235.003 252L235.003 251C226.166 251.001 219.001 243.839 219 235.003L218 235.003L217 235.003C217.002 244.944 225.062 253.002 235.003 253L235.003 252ZM234.997 218L234.997 219C243.834 218.999 250.999 226.161 251 234.997L252 234.997L253 234.997C252.998 225.056 244.938 216.998 234.997 217L234.997 218ZM247 247L247.707 246.293L113.357 111.943L112.65 112.65L111.943 113.357L246.293 247.707L247 247Z" fill="#1D0D0B"/>
</svg>
<!-- ===== Corner decorations: hook + arrow (bottom-left) ===== -->
<svg class="layer" viewBox="0 0 680 680">
<path d="M234.296 460.943C233.905 461.333 233.905 461.966 234.296 462.357L240.661 468.72C241.051 469.11 241.685 469.11 242.075 468.72C242.466 468.329 242.465 467.696 242.075 467.305L236.417 461.649L242.073 455.992C242.463 455.601 242.463 454.968 242.073 454.577C241.682 454.187 241.049 454.187 240.659 454.578L234.296 460.943ZM217.293 445.36C217.684 445.75 218.317 445.75 218.707 445.36L225.07 438.995C225.461 438.604 225.46 437.971 225.07 437.58C224.679 437.19 224.046 437.19 223.656 437.581L218 443.238L212.342 437.582C211.951 437.192 211.318 437.192 210.928 437.583C210.537 437.973 210.537 438.606 210.928 438.997L217.293 445.36ZM111.65 567C111.65 567.552 112.097 568 112.65 568H121.65C122.202 568 122.65 567.552 122.65 567C122.65 566.448 122.202 566 121.65 566H113.65V558C113.65 557.448 113.202 557 112.65 557C112.097 557 111.65 557.448 111.65 558V567ZM234.997 427.65L234.997 428.65C243.834 428.648 250.999 435.81 251 444.647L252 444.647L253 444.647C252.998 434.706 244.938 426.648 234.997 426.65L234.997 427.65ZM252 444.647L251 444.647C251.001 453.484 243.839 460.648 235.003 460.65L235.003 461.65L235.003 462.65C244.944 462.648 253.002 454.588 253 444.647L252 444.647ZM218 444.653L219 444.652C218.999 435.816 226.161 428.651 234.997 428.65L234.997 427.65L234.997 426.65C225.056 426.651 216.998 434.712 217 444.653L218 444.653ZM247 432.65L246.293 431.943L111.943 566.293L112.65 567L113.357 567.707L247.707 433.357L247 432.65Z" fill="#1D0D0B"/>
</svg>
<!-- ===== Corner decorations: hook + arrow (bottom-right) ===== -->
<svg class="layer" viewBox="0 0 680 680">
<path d="M460.943 445.354C461.333 445.745 461.966 445.744 462.357 445.354L468.72 438.989C469.11 438.598 469.11 437.965 468.72 437.575C468.329 437.184 467.696 437.184 467.305 437.575L461.649 443.233L455.992 437.577C455.601 437.186 454.968 437.186 454.577 437.577C454.187 437.968 454.187 438.601 454.578 438.991L460.943 445.354ZM445.36 462.357C445.75 461.966 445.75 461.333 445.36 460.942L438.995 454.58C438.604 454.189 437.971 454.189 437.58 454.58C437.19 454.97 437.19 455.604 437.581 455.994L443.238 461.65L437.582 467.308C437.192 467.698 437.192 468.332 437.583 468.722C437.973 469.112 438.606 469.112 438.997 468.722L445.36 462.357ZM567 568C567.552 568 568 567.552 568 567V558C568 557.448 567.552 557 567 557C566.448 557 566 557.448 566 558V566H558C557.448 566 557 566.448 557 567C557 567.552 557.448 568 558 568H567ZM427.65 444.653L428.65 444.652C428.648 435.816 435.81 428.651 444.647 428.65L444.647 427.65L444.647 426.65C434.706 426.651 426.648 434.712 426.65 444.653L427.65 444.653ZM444.647 427.65L444.647 428.65C453.484 428.648 460.648 435.81 460.65 444.647L461.65 444.647L462.65 444.647C462.648 434.706 454.588 426.648 444.647 426.65L444.647 427.65ZM444.653 461.65L444.652 460.65C435.816 460.651 428.651 453.489 428.65 444.652L427.65 444.653L426.65 444.653C426.651 454.594 434.712 462.651 444.653 462.65L444.653 461.65ZM432.65 432.65L431.943 433.357L566.293 567.707L567 567L567.707 566.293L433.357 431.943L432.65 432.65Z" fill="#1D0D0B"/>
</svg>
<!-- ===== Corner decorations: hook + arrow (top-right) ===== -->
<svg class="layer" viewBox="0 0 680 680">
<path d="M445.354 218.707C445.745 218.316 445.744 217.683 445.354 217.293L438.989 210.93C438.598 210.539 437.965 210.54 437.575 210.93C437.184 211.321 437.184 211.954 437.575 212.344L443.233 218L437.577 223.658C437.186 224.049 437.186 224.682 437.577 225.072C437.968 225.463 438.601 225.463 438.991 225.072L445.354 218.707ZM462.357 234.29C461.966 233.899 461.333 233.9 460.942 234.29L454.58 240.655C454.189 241.046 454.189 241.679 454.58 242.069C454.97 242.46 455.604 242.46 455.994 242.069L461.65 236.411L467.308 242.067C467.698 242.458 468.332 242.458 468.722 242.067C469.112 241.676 469.112 241.043 468.722 240.653L462.357 234.29ZM568 112.65C568 112.097 567.552 111.65 567 111.65H558C557.448 111.65 557 112.097 557 112.65C557 113.202 557.448 113.65 558 113.65H566V121.65C566 122.202 566.448 122.65 567 122.65C567.552 122.65 568 122.202 568 121.65V112.65ZM444.653 252L444.652 251C435.816 251.001 428.651 243.839 428.65 235.003L427.65 235.003L426.65 235.003C426.651 244.944 434.712 253.002 444.653 253L444.653 252ZM427.65 235.003L428.65 235.003C428.648 226.166 435.81 219.001 444.647 219L444.647 218L444.647 217C434.706 217.002 426.648 225.062 426.65 235.003L427.65 235.003ZM461.65 234.997L460.65 234.997C460.651 243.834 453.489 250.999 444.652 251L444.653 252L444.653 253C454.594 252.998 462.651 244.938 462.65 234.997L461.65 234.997ZM432.65 247L433.357 247.707L567.707 113.357L567 112.65L566.293 111.943L431.943 246.293L432.65 247Z" fill="#1D0D0B"/>
</svg>
<!-- ===== GAME LAYER (game objects, same 680x680 coord space) ===== -->
<svg class="layer" id="gameLayer" viewBox="0 0 680 680">
<line id="baseHint" x1="180" y1="530" x2="500" y2="530"
stroke="rgba(255,255,255,.55)" stroke-width="1.5" stroke-dasharray="4 6"/>
<line id="aimLine" x1="0" y1="0" x2="0" y2="0"
stroke="rgba(255,220,120,.85)" stroke-width="2.5" stroke-dasharray="6 8" visibility="hidden"/>
<line id="pullLine" x1="0" y1="0" x2="0" y2="0"
stroke="rgba(255,120,80,.9)" stroke-width="3" visibility="hidden"/>
<circle id="powerArc" cx="0" cy="0" r="24"
fill="none" stroke="#3cff5a" stroke-width="4"
stroke-dasharray="0 999" visibility="hidden"/>
<g id="coinsGroup"></g>
</svg>
</div>
<div class="swipe-wrap">
<label id="slideLabel">Striker ⇄</label>
<input type="range" id="slide" min="0" max="1000" value="500" />
</div>
<div class="hud">
<label>Power</label>
<input type="range" id="power" min="10" max="100" value="65" />
<span id="powerVal">65%</span>
<button class="btn secondary" id="rulesBtn">Rules</button>
<button class="btn secondary" id="modeBtn">Mode</button>
<button class="btn" id="resetBtn">New Board</button>
</div>
<div class="status" style="font-size:11px;opacity:.7">Slide bar = position striker • Drag away from striker = aim & flick</div>
</div>
<!-- Mode selection modal -->
<div class="overlay show" id="modeModal">
<div class="card">
<h2>Choose Game Mode</h2>
<p>Pick how you want to play. All modes follow official Carrom Board rules.</p>
<div class="modesel">
<button data-mode="classic2">Classic 2P<span class="sub">White vs Black<br/>First to 25 pts</span></button>
<button data-mode="team4">Team 4P<span class="sub">P1+P3 vs P2+P4<br/>Sit opposite, clockwise</span></button>
<button data-mode="family">Family Point<span class="sub">2-3-4 players<br/>Any colour, B=5 W=10 Q=25</span></button>
<button data-mode="point">Point Carrom<span class="sub">2-3-4 players<br/>Each piece=1pt, Q=3pt, to 21</span></button>
</div>
</div>
</div>
<!-- Rules modal -->
<div class="overlay" id="rulesModal">
<div class="card">
<h2>Official Carrom Rules</h2>
<div class="ruleslist">
<h3>Setup</h3>
<ul>
<li>19 pieces: 9 white + 9 black + 1 red Queen</li>
<li>Queen at center, surrounded by 6 alternating pieces, then 12 more forming the outer ring</li>
</ul>
<h3>Shooting (Classic)</h3>
<ul>
<li>Striker must rest on the baseline strip (between the two red dots)</li>
<li>Flick the striker to hit the carrom-men</li>
<li>White player goes first</li>
</ul>
<h3>Pocketing</h3>
<ul>
<li>Pocket your own colour → shoot again</li>
<li>Pocket opponent's piece → lose turn (it stays pocketed and counts for them)</li>
<li>Pocketed pieces stay pocketed — they don't return</li>
</ul>
<h3>Queen (Red)</h3>
<ul>
<li>Can be pocketed any time after sinking your first own piece</li>
<li>Must be <b>covered</b> by pocketing one of your own pieces in same or next strike — otherwise Queen returns to centre</li>
<li>Queen must be pocketed before your last piece</li>
</ul>
<h3>Fouls</h3>
<ul>
<li>Striker pocketed → one of your already-pocketed pieces returns to centre + lose turn (if none yet, you owe one)</li>
<li>Piece jumps off board → returned to centre</li>
<li>Sinking your last piece before covering Queen → lose board, -3 pts + 1 pt per opponent's remaining</li>
<li>Sinking opponent's last piece → you lose the board, -3 pts</li>
</ul>
<h3>Scoring</h3>
<ul>
<li>Winner of a board = 1 pt per opponent's piece left</li>
<li>+3 bonus if winner covered the Queen</li>
<li>Match: first to 25 pts (or 8 boards) wins</li>
</ul>
<h3>Team Carrom (4 Players)</h3>
<ul>
<li>P1 + P3 = team (white), P2 + P4 = team (black)</li>
<li>Teammates sit OPPOSITE each other</li>
<li>Turn order is clockwise: P1 → P2 → P3 → P4</li>
<li>Each team shares colour and score</li>
</ul>
</div>
<div class="row"><button id="closeRules">Got it</button></div>
</div>
</div>
<!-- Board-end modal -->
<div class="overlay" id="boardModal">
<div class="card">
<h2 id="boardT">Board Complete</h2>
<p id="boardS"></p>
<div class="row">
<button id="nextBoard">Next Board</button>
<button class="alt" id="endMatch">End Match</button>
</div>
</div>
</div>
<!-- Match-end modal -->
<div class="overlay" id="matchModal">
<div class="card">
<h2 id="matchT">Match Winner</h2>
<p id="matchS"></p>
<div class="row">
<button id="newMatch">New Match</button>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<script>
(() => {
'use strict';
const SVG_NS = 'http://www.w3.org/2000/svg';
const game = document.getElementById('gameLayer');
const coinsGrp = document.getElementById('coinsGroup');
const aimLine = document.getElementById('aimLine');
const pullLine = document.getElementById('pullLine');
const powerArc = document.getElementById('powerArc');
const baseHint = document.getElementById('baseHint');
// === Geometry from your test.html (680x680 viewBox) ===
const SIZE = 680, CX = 340, CY = 340;
const POCKETS = [
{x:65, y:65, side:'TL'},
{x:615, y:65, side:'TR'},
{x:65, y:615, side:'BL'},
{x:615, y:615, side:'BR'}
];
const POCKET_R = 25;
// PULL_R = distance at which the pocket starts SUCKING the piece in.
// Once inside this radius, the piece's velocity is bent toward the pocket
// center, and walls are disabled. This makes it feel like a real pocket
// "swallow" instead of the piece bouncing off the rail.
const PULL_R = POCKET_R + 14; // = 39
// CAPTURE_R = piece is officially pocketed (removed from play).
const CAPTURE_R = POCKET_R + 4; // = 29 — generous so grazes count
const WALL_MIN = 42, WALL_MAX = SIZE - 42; // play area inside frame
const COIN_R = 14, STRIKER_R = 17;
const FRICTION = 0.988;
const MIN_V = 0.05;
const REST = 0.94;
const WALL_REST = 0.82;
const MAX_SPEED = 42; // more powerful shots
// Striker positions per side (P1 bottom, P2 top, P3 top, P4 right/left for team)
// For 4-player TEAM mode we still only use top/bottom baselines per spec
// (teammates sit opposite, turn clockwise -> we'll alternate top/bottom)
const STRIKER_Y_BOTTOM = 530;
const STRIKER_Y_TOP = 150;
const STRIKER_X_LEFT = 150;
const STRIKER_X_RIGHT = 530;
const BASE_MIN = 180, BASE_MAX = 500; // travel between red dots
// ===== Game configuration =====
const MODES = {
classic2: {label:'Classic 2P', players:2, team:false, targetScore:25, queenPoints:3, anyColour:false, blackPts:1, whitePts:1, queenCover:true},
team4: {label:'Team 4P', players:4, team:true, targetScore:25, queenPoints:3, anyColour:false, blackPts:1, whitePts:1, queenCover:true},
family: {label:'Family Point', players:0, team:false, targetScore:Infinity, queenPoints:25, anyColour:true, blackPts:5, whitePts:10, queenCover:true, oneBoard:true},
point: {label:'Point Carrom', players:0, team:false, targetScore:21, queenPoints:3, anyColour:true, blackPts:1, whitePts:1, queenCover:true}
};
// ===== State =====
let MODE = null;
let G = null; // current-board game state
let MATCH = null; // match-wide state
function newMatch(modeKey, numPlayers){
MODE = JSON.parse(JSON.stringify(MODES[modeKey]));
MODE.key = modeKey;
if (MODE.players === 0) MODE.players = numPlayers || 2;
// team mapping: in team4 -> player 1&3 = team A (white), 2&4 = team B (black)
// for non-team modes: in 2P classic, P1=white, P2=black
// in family/point with N players, each player has own score; "team" of size 1
if (MODE.team){
MODE.teamOf = [0, 0, 1, 0, 1]; // 1-indexed: P1->A, P2->B, P3->A, P4->B
MODE.teamLabel = ['Team White (P1+P3)', 'Team Black (P2+P4)'];
MODE.teamColour = ['white','black'];
} else if (MODE.key === 'classic2'){
MODE.teamOf = [0, 0, 1];
MODE.teamLabel = ['Player 1 (White)', 'Player 2 (Black)'];
MODE.teamColour = ['white','black'];
} else {
// family/point: each player is their own "team"
MODE.teamOf = [0];
MODE.teamLabel = [];
MODE.teamColour = [];
for (let i=1;i<=MODE.players;i++){
MODE.teamOf.push(i-1);
MODE.teamLabel.push('Player '+i);
MODE.teamColour.push(null); // any colour
}
}
const numTeams = Math.max(...MODE.teamOf.slice(1)) + 1;
MATCH = {
mode: MODE,
teamScores: Array(numTeams).fill(0),
boardNum: 0,
startingPlayer: 1,
ended: false
};
nextBoard();
}
function nextBoard(){
MATCH.boardNum++;
G = {
coins: [],
striker: null,
turn: MATCH.startingPlayer, // 1..players
pocketedThisTurn: [], // {type, by}
pocketedAll: [], // history of all pocketed pieces this board
teamPocketed: {}, // {teamIdx: [type list]}
queenPocketedBy: null, // player index who pocketed queen
queenCovered: false,
queenInPocketPending: false, // pocketed but not yet covered
shooting:false, aiming:false, placing:true,
dragStart:null, dragCur:null,
power: 0.65,
striker_owed: Array(MATCH.mode.players + 1).fill(0), // owed pieces back to center on foul
ownPocketedCount: Array(MATCH.mode.players + 1).fill(0), // how many own pieces each player pocketed (for "first own piece" rule)
boardOver: false
};
for (let t=0; t<MATCH.teamScores.length; t++) G.teamPocketed[t] = [];
setupCoins();
placeStriker();
rebuildAllNodes();
document.getElementById('boardBadge').textContent = 'Board ' + MATCH.boardNum;
document.getElementById('queenBadge').style.display = 'none';
document.getElementById('boardModal').classList.remove('show');
document.getElementById('modeModal').classList.remove('show');
updateUI();
}
// ===== Coins setup (official: queen center, 6-piece inner ring, 12-piece outer ring; alternating colours; line points to first player) =====
function setupCoins(){
G.coins = [];
G.coins.push({x:CX, y:CY, vx:0, vy:0, r:COIN_R, type:'queen', alive:true});
const r1 = COIN_R * 2.05;
for (let i=0;i<6;i++){
const a = (i/6)*Math.PI*2 - Math.PI/2;
G.coins.push({x:CX+Math.cos(a)*r1, y:CY+Math.sin(a)*r1,
vx:0,vy:0,r:COIN_R, type:i%2===0?'white':'black', alive:true});
}
const r2 = COIN_R * 4.0;
for (let i=0;i<12;i++){
const a = (i/12)*Math.PI*2 - Math.PI/2 + Math.PI/12;
G.coins.push({x:CX+Math.cos(a)*r2, y:CY+Math.sin(a)*r2,
vx:0,vy:0,r:COIN_R, type:i%2===0?'black':'white', alive:true});
}
}
// Striker side per player. In team4: P1=bottom, P2=right, P3=top, P4=left (sit clockwise).
// In 2P: P1 bottom, P2 top.
// In family/point with N players: round-robin sides.
function strikerSideFor(player){
const n = MATCH.mode.players;
if (MATCH.mode.team){
// P1 bottom, P2 right, P3 top, P4 left -> clockwise around board
return ['BOTTOM','RIGHT','TOP','LEFT'][player-1];
}
if (n === 2) return player === 1 ? 'BOTTOM' : 'TOP';
if (n === 3) return ['BOTTOM','TOP','RIGHT'][player-1];
if (n === 4) return ['BOTTOM','RIGHT','TOP','LEFT'][player-1];
return 'BOTTOM';
}
function placeStriker(){
const side = strikerSideFor(G.turn);
let x, y, isHoriz;
if (side === 'BOTTOM'){ x = CX; y = STRIKER_Y_BOTTOM; isHoriz = true; }
else if (side === 'TOP'){ x = CX; y = STRIKER_Y_TOP; isHoriz = true; }
else if (side === 'LEFT'){ x = STRIKER_X_LEFT; y = CY; isHoriz = false; }
else { x = STRIKER_X_RIGHT; y = CY; isHoriz = false; }
G.striker = {x, y, vx:0, vy:0, r:STRIKER_R, type:'striker', alive:true, side, isHoriz};
G.placing = true;
G.pocketedThisTurn = [];
document.getElementById('slide').value = 500;
// Auto-nudge if center spawn is blocked by a piece on the baseline
if (isHoriz) G.striker.x = findFreeStrikerPos(x, true);
else G.striker.y = findFreeStrikerPos(y, false);
// Show baseline hint
if (isHoriz){
baseHint.setAttribute('x1', BASE_MIN); baseHint.setAttribute('x2', BASE_MAX);
baseHint.setAttribute('y1', y); baseHint.setAttribute('y2', y);
} else {
baseHint.setAttribute('x1', x); baseHint.setAttribute('x2', x);
baseHint.setAttribute('y1', BASE_MIN); baseHint.setAttribute('y2', BASE_MAX);
}
baseHint.setAttribute('visibility','visible');
}
// ===== Node helpers =====
function makeCoinNode(c){
const g = document.createElementNS(SVG_NS, 'g');
g.setAttribute('class','coin');
const shadow = document.createElementNS(SVG_NS, 'circle');
shadow.setAttribute('r', c.r);
shadow.setAttribute('fill','rgba(0,0,0,.35)');
shadow.setAttribute('transform','translate(2,3)');
g.appendChild(shadow);
const body = document.createElementNS(SVG_NS, 'circle');
body.setAttribute('r', c.r);
let fill, stroke;
if (c.type==='white') { fill='#f6ecd0'; stroke='#8a7548'; }
else if (c.type==='black') { fill='#1c1c1c'; stroke='#000'; }
else if (c.type==='queen') { fill='#BB1D2D'; stroke='#5a0a0a'; }
else { fill='#e6ba55'; stroke='#5a3a10'; }
body.setAttribute('fill', fill);
body.setAttribute('stroke', stroke);
body.setAttribute('stroke-width','1.4');
g.appendChild(body);
const inner = document.createElementNS(SVG_NS, 'circle');
inner.setAttribute('r', c.r * 0.55);
inner.setAttribute('fill','none');
inner.setAttribute('stroke','rgba(0,0,0,.2)');
inner.setAttribute('stroke-width','0.8');
g.appendChild(inner);
const hi = document.createElementNS(SVG_NS, 'circle');
hi.setAttribute('cx', -c.r * 0.35);
hi.setAttribute('cy', -c.r * 0.4);
hi.setAttribute('r', c.r * 0.28);
hi.setAttribute('fill','rgba(255,255,255,.55)');
g.appendChild(hi);
if (c.type==='queen'){
const q = document.createElementNS(SVG_NS, 'text');
q.setAttribute('text-anchor','middle');
q.setAttribute('dominant-baseline','central');
q.setAttribute('font-family','sans-serif');
q.setAttribute('font-weight','bold');
q.setAttribute('font-size','10');
q.setAttribute('fill','#fff');
q.textContent = 'Q';
g.appendChild(q);
}
c.node = g;
coinsGrp.appendChild(g);
updateCoinNode(c);
return g;
}
function updateCoinNode(c){
if (!c.node) return;
if (c.pocketed || !c.alive){ c.node.style.display='none'; return; }
c.node.style.display='';
c.node.setAttribute('transform','translate('+c.x+','+c.y+')');
}
function clearCoinNodes(){
while (coinsGrp.firstChild) coinsGrp.removeChild(coinsGrp.firstChild);
}
function rebuildAllNodes(){
clearCoinNodes();
for (const c of G.coins) makeCoinNode(c);
if (G.striker) makeCoinNode(G.striker);
}
function refreshNodePositions(){
for (const c of G.coins) updateCoinNode(c);
if (G.striker) updateCoinNode(G.striker);
}
// ===== Input =====
function svgPoint(e){
const t = e.touches ? e.touches[0] : e;
const pt = game.createSVGPoint();
pt.x = t.clientX; pt.y = t.clientY;
const m = game.getScreenCTM();
if (!m) return {x:0,y:0};
const p = pt.matrixTransform(m.inverse());
return {x:p.x, y:p.y};
}
const dist = (a,b) => Math.hypot(a.x-b.x, a.y-b.y);
const clamp = (v,a,b) => Math.max(a, Math.min(b, v));
function down(e){
e.preventDefault();
if (G.shooting || G.boardOver) return;
const p = svgPoint(e); const s = G.striker; if (!s) return;
if (dist(p,s) < 180){
G.dragStart = {x:s.x,y:s.y}; G.dragCur = p; G.aiming = true;
updateAim();
}
}
function move(e){
if (!G.aiming) return;
e.preventDefault();
G.dragCur = svgPoint(e);
updateAim();
}
function up(e){
if (!G.aiming) return;
e.preventDefault();
const s = G.striker, p = G.dragCur;
G.aiming = false;
hideAim();
if (!p) return;
const dx = s.x - p.x, dy = s.y - p.y, len = Math.hypot(dx,dy);
if (len < 8){ G.dragStart=null; G.dragCur=null; return; }
const pull = clamp(len/180, 0, 1);
const sp = G.power * pull * MAX_SPEED;
s.vx = (dx/len) * sp;
s.vy = (dy/len) * sp;
G.placing = false; G.shooting = true;
baseHint.setAttribute('visibility','hidden');
G.dragStart = null; G.dragCur = null;
}
game.addEventListener('mousedown', down);
game.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
game.addEventListener('touchstart', down, {passive:false});
game.addEventListener('touchmove', move, {passive:false});
game.addEventListener('touchend', up, {passive:false});
function updateAim(){
if (!G.aiming || !G.dragStart || !G.dragCur){ hideAim(); return; }
const s = G.striker;
const dx = s.x - G.dragCur.x, dy = s.y - G.dragCur.y;
const len = Math.hypot(dx,dy);
if (len < 6){ hideAim(); return; }
const dirx = dx/len, diry = dy/len;
const pull = clamp(len/180, 0, 1);
aimLine.setAttribute('x1', s.x); aimLine.setAttribute('y1', s.y);
aimLine.setAttribute('x2', s.x + dirx*(140 + pull*240));
aimLine.setAttribute('y2', s.y + diry*(140 + pull*240));
aimLine.setAttribute('stroke','rgba(255,220,120,'+(0.55+pull*0.4)+')');
aimLine.setAttribute('visibility','visible');
pullLine.setAttribute('x1', s.x); pullLine.setAttribute('y1', s.y);
pullLine.setAttribute('x2', G.dragCur.x); pullLine.setAttribute('y2', G.dragCur.y);
pullLine.setAttribute('visibility','visible');
const r = s.r + 8;
powerArc.setAttribute('cx', s.x); powerArc.setAttribute('cy', s.y); powerArc.setAttribute('r', r);
powerArc.setAttribute('transform','rotate(-90 '+s.x+' '+s.y+')');
const circ = 2*Math.PI*r;
powerArc.setAttribute('stroke-dasharray', (circ*pull)+' '+circ);
powerArc.setAttribute('stroke','hsl('+(120 - pull*120)+',85%,55%)');
powerArc.setAttribute('visibility','visible');
}
function hideAim(){
aimLine.setAttribute('visibility','hidden');
pullLine.setAttribute('visibility','hidden');
powerArc.setAttribute('visibility','hidden');
}
// ===== Controls =====
const slide = document.getElementById('slide');
slide.addEventListener('input', () => {
if (!G.placing || G.shooting || !G.striker) return;
const v = +slide.value / 1000;
let target;
if (G.striker.isHoriz){
target = BASE_MIN + v * (BASE_MAX - BASE_MIN);
G.striker.x = findFreeStrikerPos(target, true);
} else {
target = BASE_MIN + v * (BASE_MAX - BASE_MIN);
G.striker.y = findFreeStrikerPos(target, false);
}
updateCoinNode(G.striker);
});
// Auto-find a free striker position along the baseline.
// If the requested position overlaps a coin, search outward (both directions)
// for the nearest free spot. Returns the final coordinate.
function findFreeStrikerPos(target, isHoriz){
const s = G.striker;
const minGap = s.r + COIN_R + 1.5;
const checkPos = (pos) => {
const sx = isHoriz ? pos : s.x;
const sy = isHoriz ? s.y : pos;
for (const c of G.coins){
if (!c.alive || c.pocketed) continue;
if (Math.hypot(c.x - sx, c.y - sy) < minGap) return false;
}
return true;
};
if (checkPos(target)) return clamp(target, BASE_MIN, BASE_MAX);
// Search outward in 1px steps up to the full baseline length
for (let step = 1; step <= (BASE_MAX - BASE_MIN); step++){
const a = target - step, b = target + step;
if (a >= BASE_MIN && checkPos(a)) return a;
if (b <= BASE_MAX && checkPos(b)) return b;
}
return clamp(target, BASE_MIN, BASE_MAX);
}
const powerEl = document.getElementById('power');
const powerVal = document.getElementById('powerVal');
powerEl.addEventListener('input', () => {
G.power = +powerEl.value/100;
powerVal.textContent = powerEl.value+'%';
});
document.getElementById('resetBtn').onclick = () => {
if (confirm('Reset current board?')) nextBoardRedo();
};
function nextBoardRedo(){
// restart current board without advancing match
MATCH.boardNum--;
nextBoard();
}
document.getElementById('rulesBtn').onclick = () => document.getElementById('rulesModal').classList.add('show');
document.getElementById('closeRules').onclick = () => document.getElementById('rulesModal').classList.remove('show');
document.getElementById('modeBtn').onclick = () => document.getElementById('modeModal').classList.add('show');
document.getElementById('nextBoard').onclick = () => nextBoard();
document.getElementById('endMatch').onclick = () => endMatchNow();
document.getElementById('newMatch').onclick = () => {
document.getElementById('matchModal').classList.remove('show');
document.getElementById('modeModal').classList.add('show');
};
// Mode selection buttons
document.querySelectorAll('#modeModal button[data-mode]').forEach(b => {
b.onclick = () => {
const m = b.dataset.mode;
let np = 2;
if (m === 'team4') np = 4;
else if (m === 'family' || m === 'point'){
const r = prompt('How many players? (2, 3 or 4)', '2');
np = parseInt(r) || 2;
if (np < 2) np = 2; if (np > 4) np = 4;
}
newMatch(m, np);
};
});
// ===== Physics =====
function step(dt){
// dt = fraction of one full-frame the substep represents.
if (dt === undefined) dt = 1;
const frictionStep = Math.pow(FRICTION, dt);
const all = [G.striker, ...G.coins].filter(c => c && c.alive && !c.pocketed);
// 1) Integrate motion
for (const c of all){
c.x += c.vx * dt; c.y += c.vy * dt;
c.vx *= frictionStep; c.vy *= frictionStep;
if (Math.hypot(c.vx,c.vy) < MIN_V){ c.vx=0; c.vy=0; }
}
// 2) COLLISIONS FIRST — a piece in the path must stop/deflect the striker
// BEFORE we even consider pocketing. Otherwise a fast striker would
// "tunnel through" a coin and get pocketed without bumping it.
for (let i=0;i<all.length;i++){
const a = all[i]; if (a.pocketed) continue;
for (let j=i+1;j<all.length;j++){
const b = all[j]; if (b.pocketed) continue;
const dx = b.x-a.x, dy = b.y-a.y;
const d = Math.hypot(dx,dy), m = a.r+b.r;
if (d > 0 && d < m){
const nx = dx/d, ny = dy/d, ov = (m-d)/2;
a.x -= nx*ov; a.y -= ny*ov;
b.x += nx*ov; b.y += ny*ov;
const rvx = b.vx-a.vx, rvy = b.vy-a.vy;
const vn = rvx*nx + rvy*ny;
if (vn < 0){
const J = -(1+REST)*vn/2;
a.vx -= J*nx; a.vy -= J*ny;
b.vx += J*nx; b.vy += J*ny;
}
}
}
}
// 3) POCKET PULL: any piece within PULL_R of a pocket center is being
// swallowed — its velocity bends toward the pocket and walls are
// disabled for it (so it can't bounce off the rail beside the hole).
// This is what creates the realistic "drops into the corner" feel.
const inPocketPull = new Array(all.length).fill(false);
for (let i=0;i<all.length;i++){
const c = all[i]; if (c.pocketed) continue;
for (const p of POCKETS){
const dx = p.x - c.x, dy = p.y - c.y;
const d = Math.hypot(dx, dy);
if (d < PULL_R){
inPocketPull[i] = true;
// Bend velocity toward pocket center (gentle attraction)
if (d > 0.5){
const speed = Math.hypot(c.vx, c.vy);
// Blend current direction with direction-to-pocket.
// Strength grows as the piece gets closer (1.0 at center, 0.0 at PULL_R)
const k = Math.min(1, (1 - d / PULL_R) * 1.4);
c.vx = c.vx * (1 - k) + (dx / d) * Math.max(speed, 1.5) * k;
c.vy = c.vy * (1 - k) + (dy / d) * Math.max(speed, 1.5) * k;
}
break;
}
}
}
// 4) Walls — solid, EXCEPT for pieces currently being swallowed by a pocket
for (let i=0;i<all.length;i++){
const c = all[i]; if (c.pocketed) continue;
if (inPocketPull[i]) continue; // skip walls — let the pocket take it
if (c.x - c.r < WALL_MIN){ c.x = WALL_MIN + c.r; c.vx = -c.vx * WALL_REST; }
if (c.x + c.r > WALL_MAX){ c.x = WALL_MAX - c.r; c.vx = -c.vx * WALL_REST; }
if (c.y - c.r < WALL_MIN){ c.y = WALL_MIN + c.r; c.vy = -c.vy * WALL_REST; }
if (c.y + c.r > WALL_MAX){ c.y = WALL_MAX - c.r; c.vy = -c.vy * WALL_REST; }
}
// 5) POCKET CAPTURE — anything close enough to the pocket center is gone.
for (const c of all){
if (c.pocketed) continue;
for (const p of POCKETS){
if (Math.hypot(c.x-p.x, c.y-p.y) < CAPTURE_R){
c.pocketed = true; c.alive = false; c.vx=c.vy=0;
G.pocketedThisTurn.push({type:c.type, by:G.turn, ref:c});
G.pocketedAll.push({type:c.type, by:G.turn});
break;
}
}
}
// 6) ESCAPE SAFETY: anything that somehow ended up far outside the play
// area without being captured — snap into the nearest pocket.
for (const c of all){
if (c.pocketed) continue;
const ESCAPE = 6;
if (c.x < WALL_MIN - ESCAPE || c.x > WALL_MAX + ESCAPE ||
c.y < WALL_MIN - ESCAPE || c.y > WALL_MAX + ESCAPE){
let np = POCKETS[0], nd = Infinity;
for (const p of POCKETS){
const d = Math.hypot(c.x-p.x, c.y-p.y);
if (d < nd){ nd = d; np = p; }
}
c.x = np.x; c.y = np.y; c.vx=0; c.vy=0;
c.pocketed = true; c.alive = false;
G.pocketedThisTurn.push({type:c.type, by:G.turn, ref:c});
G.pocketedAll.push({type:c.type, by:G.turn});
}
}
}
function atRest(){
if (!G.striker) return true;
for (const c of [G.striker, ...G.coins]){
if (!c || c.pocketed) continue;
if (Math.hypot(c.vx,c.vy) > 0) return false;
}
return true;
}
// ===== Rule helpers =====
function playerTeam(p){ return MATCH.mode.teamOf[p]; }
function teamColour(t){ return MATCH.mode.teamColour[t]; } // 'white'|'black'|null
function isOwnColour(player, type){
if (MATCH.mode.anyColour) return type==='white' || type==='black';
const col = teamColour(playerTeam(player));
return type === col;
}
function isOppColour(player, type){
if (MATCH.mode.anyColour) return false;
const col = teamColour(playerTeam(player));
return (type==='white' || type==='black') && type !== col;
}
function piecesLeftFor(team){
if (MATCH.mode.anyColour) return null; // no per-team piece count
const col = teamColour(team);
return G.coins.filter(c => c.type === col && !c.pocketed).length;
}
function nextTurnPlayer(){
// clockwise rotation through MATCH.mode.players
G.turn = (G.turn % MATCH.mode.players) + 1;
}
// Return a piece of given type to the centre (for fouls / jump-offs / uncovered queen)
function returnPieceToCentre(type){
// First, see if there's an existing pocketed piece of that type we can revive
let c = G.coins.find(c => c.type === type && c.pocketed);
if (!c){
// spawn a new one (used when queen was already removed)
c = {type:type, r:COIN_R, alive:true, vx:0, vy:0, pocketed:false};
G.coins.push(c);
} else {
c.pocketed = false; c.alive = true; c.vx = 0; c.vy = 0;
}
placeFreeAt(c, CX, CY);
if (!c.node) makeCoinNode(c);
updateCoinNode(c);
}
function placeFreeAt(c, x, y){
c.x = x; c.y = y;
for (let t=0; t<300; t++){
let ok = true;
for (const o of [G.striker, ...G.coins]){
if (!o || o === c || o.pocketed || !o.alive) continue;
if (Math.hypot(o.x-c.x, o.y-c.y) < o.r + c.r + 2){ ok = false; break; }
}
// also avoid pockets
for (const p of POCKETS){
if (Math.hypot(p.x-c.x, p.y-c.y) < POCKET_R + c.r + 4){ ok = false; break; }
}
if (ok) return;
const a = Math.random()*Math.PI*2;
const r = 6 + t*0.6;
c.x = x + Math.cos(a)*r;
c.y = y + Math.sin(a)*r;
}
}
// ===== Turn resolution following PDF rules =====
function resolveTurn(){
const player = G.turn;
const team = playerTeam(player);
const po = G.pocketedThisTurn;
const strikerFoul = po.some(p => p.type === 'striker');
const ownN = po.filter(p => p.type !== 'striker' && isOwnColour(player, p.type)).length;
const oppN = po.filter(p => p.type !== 'striker' && isOppColour(player, p.type)).length;
const queenIn = po.some(p => p.type === 'queen');
let foul = false;
let extraTurn = false;
let messages = [];
// === Queen handling (covered = sink own piece same OR subsequent strike) ===
if (queenIn){
// Cannot pocket queen before sinking your first own piece (official rule).
// But: if pocketed before, treat as foul (queen returns).
const firstOwnYet = G.ownPocketedCount[player] > 0 || ownN > 0;
if (!firstOwnYet){
returnPieceToCentre('queen');
messages.push({text:'Queen pocketed too early — returned to centre', type:'foul'});
} else {
G.queenPocketedBy = player;
if (ownN > 0){
G.queenCovered = true;
messages.push({text:'♕ Queen covered!', type:'good'});
} else {
G.queenInPocketPending = true;
messages.push({text:'♕ Queen pocketed — must cover next strike', type:'good'});
}
}
} else if (G.queenInPocketPending && G.queenPocketedBy === player){
// had to cover this turn
if (ownN > 0){
G.queenCovered = true;
G.queenInPocketPending = false;
messages.push({text:'♕ Queen covered!', type:'good'});
} else {
// failed to cover -> queen back to centre, queen credit gone
returnPieceToCentre('queen');
G.queenInPocketPending = false;
G.queenPocketedBy = null;
G.queenCovered = false;
messages.push({text:'Failed to cover the Queen — returned to centre', type:'foul'});
}
}
// === Striker foul ===
if (strikerFoul){
foul = true;
// Penalty: one of player's already-pocketed pieces returns to centre.
// If player has none yet, they "owe one" — next own piece pocketed is voided & returned.
if (G.ownPocketedCount[player] > 0){
// remove one own piece from the team's pocketed list and return to centre
const col = MATCH.mode.anyColour ? (po.find(p => p.type==='white' || p.type==='black')?.type || 'white')
: teamColour(team);
// Find any pocketed piece of own colour
let returned = false;
const pocketedOwn = G.coins.filter(c => c.pocketed && (MATCH.mode.anyColour ? (c.type==='white'||c.type==='black') : c.type===col));
if (pocketedOwn.length > 0){
const target = pocketedOwn[pocketedOwn.length-1];
returnPieceToCentre(target.type);
G.ownPocketedCount[player] = Math.max(0, G.ownPocketedCount[player] - 1);
returned = true;
}
messages.push({text:'Foul: Striker pocketed — piece returned to centre', type:'foul'});
} else {
G.striker_owed[player]++;
messages.push({text:'Foul: Striker pocketed — you owe one piece', type:'foul'});
}
}
// === Count this turn's own pockets towards player count ===
G.ownPocketedCount[player] += ownN;
// If player owes pieces (from earlier striker fouls), pay them now by removing same number of own pieces just pocketed
while (G.striker_owed[player] > 0 && ownN > 0){
// remove ONE just-pocketed own piece and return it
const ownThisTurn = po.find(p => p.type !== 'striker' && isOwnColour(player, p.type));
if (!ownThisTurn) break;
returnPieceToCentre(ownThisTurn.type);
G.striker_owed[player]--;
// also decrement counters so it doesn't count
G.ownPocketedCount[player] = Math.max(0, G.ownPocketedCount[player] - 1);
// mark as removed so subsequent loop won't reprocess
const idx = po.indexOf(ownThisTurn);
po.splice(idx, 1);
}
// === Extra turn only if you sank your own (and didn't foul) ===
if (ownN > 0 && !strikerFoul) extraTurn = true;
// Special: striker foul + own pocketed = 2 come up. Per PDF "if you sink a piece in the same shot, then two come up and you shoot again"
// We interpret: own pocketed offsets the foul penalty (two come up), and player shoots again.
if (strikerFoul && ownN > 0){
extraTurn = true;
// We already returned one piece for the striker foul; we ALSO owe another (per "two come up").
// Find another pocketed own piece to return.
const col = MATCH.mode.anyColour ? null : teamColour(team);
const pocketedOwn = G.coins.filter(c => c.pocketed && (MATCH.mode.anyColour ? (c.type==='white'||c.type==='black') : c.type===col));
if (pocketedOwn.length > 0){
const target = pocketedOwn[pocketedOwn.length-1];
returnPieceToCentre(target.type);
G.ownPocketedCount[player] = Math.max(0, G.ownPocketedCount[player] - 1);
} else {
G.striker_owed[player]++;
}
}
// === Sinking opponent's piece ===
// Per PDF: "If you sink your opponent's piece, you lose your turn."
// (The piece STAYS pocketed — it benefits the opponent.)
if (oppN > 0 && !MATCH.mode.anyColour){
messages.push({text:'Sank opponent\'s piece — turn ends', type:'foul'});
// turn ends regardless (override extra turn from same-shot own pocket? PDF is strict: lose turn)
// We follow strict reading: pocketing opponent's = lose turn.
extraTurn = false;
}
// === Win/lose checks ===
if (!MATCH.mode.anyColour){
// Check: did this player sink their LAST own piece?
const ownLeftThis = piecesLeftFor(team);
const ownColForOpp = teamColour(1 - team); // opposite team's colour
// Find opp team idx
const oppTeam = MATCH.teamScores.length === 2 ? (team === 0 ? 1 : 0) : null;
const oppLeft = oppTeam !== null ? piecesLeftFor(oppTeam) : null;
// RULE: "If you sink your last piece before the queen, you lose the board, 3 pts and 1 pt for each opp piece left."
if (ownLeftThis === 0 && !G.queenCovered && oppTeam !== null){
return endBoard({
winnerTeam: oppTeam,
loser: team,
reason: 'Sank last piece without covering Queen',
score: 3 + (oppLeft || 0),
loserPenalty: true
});
}
// RULE: "If you sink their last piece, you lose the board and 3 points." (you sunk opp's last)
if (oppTeam !== null && oppLeft === 0){
return endBoard({
winnerTeam: oppTeam,
loser: team,
reason: 'Sank opponent\'s last piece',
score: 3,
loserPenalty: true
});
}
// RULE: normal board win — own pieces all pocketed AND queen requirement met
if (ownLeftThis === 0){
const queenOK = !G.queenInPocketPending; // queen is properly covered OR not yours
if (queenOK){
return endBoard({
winnerTeam: team,
loser: oppTeam,
reason: 'All own pieces pocketed',
score: (oppLeft || 0) + (G.queenCovered && G.queenPocketedBy && playerTeam(G.queenPocketedBy)===team ? MATCH.mode.queenPoints : 0),
queenBonus: G.queenCovered && G.queenPocketedBy && playerTeam(G.queenPocketedBy)===team
});
}
}
} else {
// Family/Point Carrom: any colour. Score per-piece-per-player.
// No "last piece" rule for these variants (per PDF).
}
// === Show messages ===
for (const m of messages) toast(m.text, m.type);
// Cleanup: remove striker node
if (G.striker && G.striker.node && G.striker.node.parentNode){
G.striker.node.parentNode.removeChild(G.striker.node);
}
// Next turn
if (!extraTurn) nextTurnPlayer();
placeStriker();
if (G.striker) makeCoinNode(G.striker);
updateUI();
}
// ===== Board end =====
function endBoard(result){
G.boardOver = true;
G.shooting = false;
if (MATCH.mode.anyColour){
// Family/Point Carrom scoring: count each player's pocketed pieces
// We need per-player accounting — for simplicity in this build, treat each player as own team (already done in setup).
// Tally:
const tally = {};
for (const p of G.pocketedAll){
const t = playerTeam(p.by);
let pts = 0;
if (p.type === 'queen') pts = MATCH.mode.queenPoints;
else if (p.type === 'white') pts = MATCH.mode.whitePts;
else if (p.type === 'black') pts = MATCH.mode.blackPts;
tally[t] = (tally[t] || 0) + pts;
}
for (const k in tally) MATCH.teamScores[k] += tally[k];
result = {
winnerTeam: null,
reason: 'Round scored',
score: 0
};
// Show scoreboard
showBoardModal(result);
} else {
// Classic / Team / Point Carrom (non-anyColour): give winning team `result.score`
if (result.winnerTeam !== null && result.winnerTeam !== undefined){
MATCH.teamScores[result.winnerTeam] += result.score;
}
showBoardModal(result);
}
// Check match end
if (MATCH.teamScores.some(s => s >= MATCH.mode.targetScore) || MATCH.boardNum >= 8 || MATCH.mode.oneBoard){
setTimeout(() => endMatchNow(), 50);
} else {
// Loser starts next board (per common carrom convention)
if (result.loser !== undefined && result.loser !== null){
// pick a player from the losing team
for (let p=1; p<=MATCH.mode.players; p++){
if (playerTeam(p) === result.loser){ MATCH.startingPlayer = p; break; }
}
}
}
updateUI();
}
function showBoardModal(result){
const T = document.getElementById('boardT');
const S = document.getElementById('boardS');
if (result.winnerTeam !== null && result.winnerTeam !== undefined){
T.textContent = MATCH.mode.teamLabel[result.winnerTeam] + ' wins this board';
S.innerHTML = '<b>' + result.reason + '</b><br/>' +
'+' + result.score + ' points' +
(result.queenBonus ? ' (incl. Queen bonus)' : '') +
'<br/><br/>' +
MATCH.teamScores.map((s,i) => MATCH.mode.teamLabel[i]+': '+s+' pts').join('<br/>');
} else {
T.textContent = 'Board scored';
S.innerHTML = MATCH.teamScores.map((s,i) => MATCH.mode.teamLabel[i]+': '+s+' pts').join('<br/>');
}
document.getElementById('boardModal').classList.add('show');
}
function endMatchNow(){
MATCH.ended = true;
const maxS = Math.max(...MATCH.teamScores);
const winners = MATCH.teamScores.map((s,i) => s===maxS ? i : -1).filter(i => i!==-1);
document.getElementById('boardModal').classList.remove('show');
const T = document.getElementById('matchT');
const S = document.getElementById('matchS');
if (winners.length === 1){
T.textContent = '\ud83c\udfc6 ' + MATCH.mode.teamLabel[winners[0]] + ' wins the match!';
} else {
T.textContent = 'It\'s a tie!';
}
S.innerHTML = 'Final scores after ' + MATCH.boardNum + ' board(s):<br/><br/>' +
MATCH.teamScores.map((s,i) => MATCH.mode.teamLabel[i]+': <b>'+s+'</b> pts').join('<br/>');
document.getElementById('matchModal').classList.add('show');
}
// ===== UI =====
function updateUI(){
// Build player cards
const bar = document.getElementById('playersBar');
bar.innerHTML = '';
const n = MATCH.mode.players;
bar.style.gridTemplateColumns = 'repeat('+Math.min(n,4)+', 1fr)';
for (let p=1; p<=n; p++){
const t = playerTeam(p);
const col = teamColour(t); // 'white' | 'black' | null
const div = document.createElement('div');
div.className = 'pl' + (p === G.turn ? ' active' : '');
let dotHTML = '';
if (col === 'white') dotHTML = '<span class="dot w"></span>';
else if (col === 'black') dotHTML = '<span class="dot b"></span>';
// ===== Pocketed display =====
// Rule: a piece's COLOUR decides whose box it shows in (and who gets the
// point), NOT who pocketed it. If the opponent pockets your colour, it
// still counts for you. Striker is NEVER shown here.
let display;
if (MATCH.mode.anyColour){
// Family/Point Carrom: any colour counts for whoever pocketed it
if (MATCH.mode.team){
display = G.pocketedAll.filter(x => x.type !== 'striker' && playerTeam(x.by) === t);
} else {
display = G.pocketedAll.filter(x => x.type !== 'striker' && x.by === p);
}
} else {
// Classic / Team modes: piece colour decides ownership
display = G.pocketedAll.filter(x => x.type === col);
// Queen: show in the box of whoever covered it (queen is shared bonus)
if (G.queenCovered && G.queenPocketedBy && playerTeam(G.queenPocketedBy) === t){
// queen already included via type==='queen' below; add it explicitly
display = display.concat(G.pocketedAll.filter(x => x.type === 'queen'));
}
}
const dotsHTML = display.map(x => {
const cls = x.type === 'white' ? 'w' : (x.type === 'black' ? 'b' : 'q');
return '<span class="pp '+cls+'"></span>';
}).join('');
div.innerHTML =
'<div class="name">'+dotHTML+'P'+p+(MATCH.mode.team ? ' ('+MATCH.mode.teamLabel[t].split(' ')[1]+')' : '')+'</div>'+
'<div class="pocketed">'+dotsHTML+'</div>'+
'<div class="matchpts">'+MATCH.teamScores[t]+' pts</div>';
bar.appendChild(div);
}
document.getElementById('turnText').textContent = 'P'+G.turn+' to play';
document.getElementById('boardBadge').textContent = 'Board '+MATCH.boardNum;
document.getElementById('queenBadge').style.display = G.queenInPocketPending ? '' : 'none';
document.getElementById('slideLabel').textContent = 'Striker ⇄ (P'+G.turn+')';
}
// ===== Toast =====
let toastTimer = null;
function toast(text, type){
const el = document.getElementById('toast');
el.className = 'toast' + (type ? ' '+type : '') + ' show';
el.textContent = text;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 2400);
}
// ===== Main loop =====
function loop(){
if (G && G.shooting){
// ===== Adaptive sub-stepping to prevent tunneling =====
// The old code did "for(let i=0;i<3;i++) step()" which moved every piece
// by 3*velocity per frame. At MAX_SPEED=42 that's ~126 px/frame which
// skips clean past coins (diameter 28 px) and pockets — so the striker
// appeared to "pass through" pieces and into the pocket.
//
// We now split the same total motion into many small substeps based on
// the fastest piece's actual speed, so no piece ever moves more than
// ~30% of a coin radius per substep => collisions can't be missed.
const TOTAL_DT = 3; // same total advance as old "3 steps"
let maxV = 0;
for (const c of [G.striker, ...G.coins]){
if (!c || c.pocketed || !c.alive) continue;
const v = Math.hypot(c.vx, c.vy);
if (v > maxV) maxV = v;
}
const maxStep = COIN_R * 0.3; // max px any piece may move per substep
const maxMovePerFrame = maxV * TOTAL_DT;
let substeps = Math.max(3, Math.ceil(maxMovePerFrame / maxStep));
if (substeps > 80) substeps = 80; // safety cap
const dt = TOTAL_DT / substeps;
for (let i = 0; i < substeps; i++){
step(dt);
if (atRest()) break; // early exit when everything stops
}
refreshNodePositions();
if (atRest()){
G.shooting = false;
resolveTurn();
}
} else if (G){
refreshNodePositions();
}
requestAnimationFrame(loop);
}
// Default: show mode modal on first load. Pre-init with classic2 just for the loop to have G.
newMatch('classic2', 2);
document.getElementById('modeModal').classList.add('show'); // ask user to confirm mode
loop();
})();
</script>
<script defer src="https://static.cloudflareinsights.com/beacon.min.js/v833ccba57c9e4d2798f2e76cebdd09a11778172276447" integrity="sha512-57MDmcccJXYtNnH+ZiBwzC4jb2rvgVCEokYN+L/nLlmO8rfYT/gIpW2A569iJ/3b+0UEasghjuZH/ma3wIs/EQ==" data-cf-beacon='{"version":"2024.11.0","token":"1954b47c125f43f8b4338b3c4f804e4f","server_timing":{"name":{"cfCacheStatus":true,"cfEdge":true,"cfExtPri":true,"cfL4":true,"cfOrigin":true,"cfSpeedBrain":true},"location_startswith":null}}' crossorigin="anonymous"></script>
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'a07743f8be69b77e',t:'MTc4MDc0NzMxOQ=='};var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
</html>