378 lines
13 KiB
JavaScript
378 lines
13 KiB
JavaScript
const canvas = document.getElementById("board");
|
|
if (!canvas) alert("Canvas tidak ditemukan! ID harus 'board'");
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
const CANVAS_W = canvas.width;
|
|
const CANVAS_H = canvas.height;
|
|
|
|
const size = 8;
|
|
const tileSize = CANVAS_W / size;
|
|
|
|
let board = [];
|
|
let currentTurn = 1; // 1 = red, 2 = white
|
|
let selected = null;
|
|
let history = [];
|
|
let gameOver = false;
|
|
|
|
let aiEnabled = (typeof GAME_MODE !== "undefined" && GAME_MODE === "pvai");
|
|
let aiPlays = 2;
|
|
let aiThinkingDelay = 300;
|
|
|
|
let hintsEnabled = (typeof ENABLE_HINTS !== "undefined") ? !!ENABLE_HINTS : true;
|
|
|
|
// SOUND EFFECTS
|
|
const SFX = {
|
|
move: new Audio("assets/sound/move.mp3"),
|
|
capture: new Audio("assets/sound/capture.mp3")
|
|
};
|
|
|
|
let soundEnabled = true;
|
|
function playSound(name){
|
|
if(!soundEnabled || !SFX[name]) return;
|
|
SFX[name].currentTime = 0;
|
|
SFX[name].play().catch(()=>{});
|
|
}
|
|
|
|
// TIMER PERMAINAN
|
|
let timerSeconds = 0;
|
|
let timerInterval = null;
|
|
let timerRunning = false;
|
|
function startTimer(){ if(timerRunning) return; timerRunning=true; timerInterval=setInterval(()=>{ timerSeconds++; const min=String(Math.floor(timerSeconds/60)).padStart(2,'0'); const sec=String(timerSeconds%60).padStart(2,'0'); const dom=document.getElementById('timer'); if(dom) dom.textContent = `${min}:${sec}`; },1000); }
|
|
function stopTimer(){ timerRunning=false; clearInterval(timerInterval); }
|
|
function resetTimer(){ stopTimer(); timerSeconds=0; const dom=document.getElementById('timer'); if(dom) dom.textContent='00:00'; }
|
|
|
|
/********** HELPERS **********/
|
|
function opponentOf(p){ return p===1?2:1; }
|
|
function inside(r,c){ return Number.isInteger(r) && Number.isInteger(c) && r>=0 && r<size && c>=0 && c<size; }
|
|
function belongsTo(v, player){
|
|
return (player===1 && (v===1||v===3)) || (player===2 && (v===2||v===4));
|
|
}
|
|
|
|
// RESET BOARD
|
|
function resetBoard(){
|
|
console.debug("resetBoard()");
|
|
board = Array.from({length:size}, ()=>Array(size).fill(0));
|
|
for(let r=0;r<3;r++) for(let c=0;c<size;c++) if((r+c)%2===1) board[r][c] = 2;
|
|
for(let r=size-3;r<size;r++) for(let c=0;c<size;c++) if((r+c)%2===1) board[r][c] = 1;
|
|
|
|
currentTurn = 1;
|
|
selected = null;
|
|
history = [];
|
|
gameOver = false;
|
|
|
|
const old = document.getElementById("endPopup");
|
|
if(old) old.remove();
|
|
|
|
resetTimer();
|
|
startTimer();
|
|
|
|
draw();
|
|
// TRIGGER AI JIKA PERMAINAN DIMULAI OLEH AI
|
|
if(aiEnabled && currentTurn === aiPlays){
|
|
setTimeout(()=> triggerAI(), 120);
|
|
}
|
|
}
|
|
|
|
// MENGGAMBAR PAPAN PADA CANVAS
|
|
function draw(){
|
|
// MENGGAMBAR PAPAN
|
|
ctx.clearRect(0,0,CANVAS_W,CANVAS_H);
|
|
|
|
for(let r=0;r<size;r++){
|
|
for(let c=0;c<size;c++){
|
|
const x = c * tileSize;
|
|
const y = r * tileSize;
|
|
ctx.fillStyle = (r+c)%2===0? "#f3f4f6" : "#111827";
|
|
ctx.fillRect(x,y,tileSize,tileSize);
|
|
|
|
if(selected && selected.r===r && selected.c===c){
|
|
ctx.fillStyle = "rgba(152, 253, 0, 0.58)";
|
|
ctx.fillRect(x,y,tileSize,tileSize);
|
|
}
|
|
|
|
const v = board[r][c];
|
|
if(v!==0) drawPiece(x+tileSize/2, y+tileSize/2, v);
|
|
}
|
|
}
|
|
|
|
if(selected && hintsEnabled && !gameOver){
|
|
const moves = generateMovesForPiece(selected.r, selected.c, currentTurn);
|
|
drawMoveHints(moves);
|
|
}
|
|
}
|
|
|
|
function drawPiece(cx,cy,v){
|
|
ctx.beginPath();
|
|
ctx.arc(cx,cy,tileSize*0.35,0,Math.PI*2);
|
|
ctx.fillStyle = (v===1||v===3) ? "#ef4444" : "#ffffff";
|
|
ctx.fill(); ctx.closePath();
|
|
if(v===3||v===4){
|
|
ctx.beginPath();
|
|
ctx.arc(cx,cy,tileSize*0.15,0,Math.PI*2);
|
|
ctx.fillStyle = "#fbbf24"; ctx.fill(); ctx.closePath();
|
|
}
|
|
}
|
|
|
|
// MENGGAMBAR HINTS MOVE
|
|
function drawMoveHints(moves){
|
|
if(!moves || moves.length===0) return;
|
|
for(const m of moves){
|
|
if(!inside(m.r2,m.c2)) continue;
|
|
const x = m.c2*tileSize, y = m.r2*tileSize;
|
|
ctx.fillStyle = m.capture ? "rgba(220,38,38,0.25)" : "rgba(34,197,94,0.25)";
|
|
ctx.fillRect(x,y,tileSize,tileSize);
|
|
ctx.beginPath();
|
|
ctx.arc(x+tileSize/2, y+tileSize/2, tileSize*0.10, 0, Math.PI*2);
|
|
ctx.fillStyle = m.capture ? "#dc2626" : "#22c55e";
|
|
ctx.fill(); ctx.closePath();
|
|
|
|
// GARIS HINT DARI MEMILIH PION KE TUJUAN
|
|
const sx = selected.c*tileSize + tileSize/2;
|
|
const sy = selected.r*tileSize + tileSize/2;
|
|
const tx = x+tileSize/2, ty = y+tileSize/2;
|
|
ctx.beginPath(); ctx.moveTo(sx,sy); ctx.lineWidth = 2; ctx.strokeStyle = "rgba(255,255,255,0.3)"; ctx.lineTo(tx,ty); ctx.stroke(); ctx.closePath();
|
|
}
|
|
}
|
|
|
|
// LOGIKA UNTUK MOVE
|
|
function tryMove(r1,c1,r2,c2){
|
|
console.debug("tryMove", {r1,c1,r2,c2, gameOver, currentTurn, aiEnabled});
|
|
if(gameOver) { console.debug("move blocked: gameOver"); return false; }
|
|
if(!inside(r1,c1) || !inside(r2,c2)) { console.debug("move blocked: outside"); return false; }
|
|
const v = board[r1][c1];
|
|
if(!v) { console.debug("move blocked: no piece at source"); return false; }
|
|
if(!belongsTo(v, currentTurn)) { console.debug("move blocked: piece does not belong to currentTurn", currentTurn); return false; }
|
|
if(board[r2][c2] !== 0) { console.debug("move blocked: dest occupied"); return false; }
|
|
|
|
const dr = r2 - r1, dc = c2 - c1;
|
|
const isKing = (v===3||v===4);
|
|
const dir = (v===1||v===3) ? -1 : 1;
|
|
|
|
// GERAKAN BIASA
|
|
if(Math.abs(dr)===1 && Math.abs(dc)===1){
|
|
if(isKing || dr === dir){
|
|
saveHistory();
|
|
board[r2][c2] = v;
|
|
board[r1][c1] = 0;
|
|
|
|
const before = v;
|
|
crownIfNeeded(r2,c2);
|
|
|
|
playSound("move");
|
|
|
|
if((before === 1 && board[r2][c2] === 3) || (before === 2 && board[r2][c2] === 4)){
|
|
playSound("king");
|
|
}
|
|
|
|
switchTurn();
|
|
draw();
|
|
if(!checkWinCondition()) triggerAI();
|
|
return true;
|
|
|
|
} else { console.debug("move blocked: wrong direction for non-king"); return false; }
|
|
}
|
|
|
|
// MEMAKAN PION
|
|
if(Math.abs(dr)===2 && Math.abs(dc)===2){
|
|
const mr = r1 + dr/2, mc = c1 + dc/2;
|
|
if(!inside(mr,mc)) { console.debug("capture blocked: middle outside"); return false; }
|
|
const mid = board[mr][mc];
|
|
if(mid===0) { console.debug("capture blocked: no mid piece"); return false; }
|
|
if(belongsTo(mid, currentTurn)) { console.debug("capture blocked: mid belongs to same player"); return false; }
|
|
if(isKing || (dr/2) === dir){
|
|
saveHistory();
|
|
board[r2][c2] = v;
|
|
board[r1][c1] = 0;
|
|
board[mr][mc] = 0;
|
|
|
|
const before = v;
|
|
crownIfNeeded(r2,c2);
|
|
playSound("capture");
|
|
|
|
if((before === 1 && board[r2][c2] === 3) || (before === 2 && board[r2][c2] === 4)){
|
|
playSound("king");
|
|
}
|
|
|
|
switchTurn();
|
|
draw();
|
|
if(!checkWinCondition()) triggerAI();
|
|
return true;
|
|
} else { console.debug("capture blocked: wrong direction for non-king"); return false; }
|
|
}
|
|
|
|
console.debug("move blocked: invalid delta");
|
|
return false;
|
|
}
|
|
|
|
function crownIfNeeded(r,c){
|
|
if(board[r][c] === 1 && r === 0) board[r][c] = 3;
|
|
if(board[r][c] === 2 && r === size - 1) board[r][c] = 4;
|
|
}
|
|
|
|
function switchTurn(){ currentTurn = (currentTurn===1?2:1); }
|
|
function saveHistory(){ history.push(JSON.parse(JSON.stringify(board))); if(history.length>60) history.shift(); }
|
|
|
|
// MEMBUAT GERAKAN
|
|
function generateMovesForPiece(r,c,player){
|
|
const v = board[r][c];
|
|
if(v===0) return [];
|
|
const isKing = (v===3||v===4);
|
|
const dir = player===1? -1 : 1;
|
|
const res = [];
|
|
|
|
const dirs = isKing ? [[1,1],[1,-1],[-1,1],[-1,-1]] : [[dir,1],[dir,-1]];
|
|
for(const [dr,dc] of dirs){
|
|
const r2 = r+dr, c2 = c+dc;
|
|
if(inside(r2,c2) && board[r2][c2]===0) res.push({r1:r,c1:c,r2:r2,c2:c2,capture:false});
|
|
}
|
|
|
|
const capDirs = [[1,1],[1,-1],[-1,1],[-1,-1]];
|
|
for(const [dr,dc] of capDirs){
|
|
const mr = r+dr, mc = c+dc;
|
|
const r2 = r+dr*2, c2 = c+dc*2;
|
|
if(!inside(mr,mc) || !inside(r2,c2)) continue;
|
|
if(board[r2][c2] !== 0) continue;
|
|
const mid = board[mr][mc];
|
|
if(mid !== 0 && !belongsTo(mid, player)){
|
|
if(isKing || dr === dir) res.push({r1:r,c1:c,r2:r2,c2:c2,capture:true, mr, mc});
|
|
}
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
function getAllMovesFor(player){
|
|
const all = [];
|
|
for(let r=0;r<size;r++) for(let c=0;c<size;c++) if(belongsTo(board[r][c], player)) all.push(...generateMovesForPiece(r,c,player));
|
|
return all;
|
|
}
|
|
|
|
// CEK KONDISI MENANG
|
|
function checkWinCondition(){
|
|
if(gameOver) return true;
|
|
let red=0, white=0;
|
|
for(let r=0;r<size;r++) for(let c=0;c<size;c++){
|
|
const v = board[r][c];
|
|
if(v===1||v===3) red++;
|
|
if(v===2||v===4) white++;
|
|
}
|
|
|
|
if(red===0){ showEnd("KALAH 😢","Semua pion merah hilang","loss"); return true; }
|
|
if(white===0){ showEnd("MENANG 🎉","Semua pion putih hilang","win"); return true; }
|
|
|
|
const redMoves = getAllMovesFor(1), whiteMoves = getAllMovesFor(2);
|
|
if(currentTurn===1 && redMoves.length===0){ showEnd("KALAH 😢","Merah tidak dapat bergerak","loss"); return true; }
|
|
if(currentTurn===2 && whiteMoves.length===0){ showEnd("MENANG 🎉","Putih tidak dapat bergerak","win"); return true; }
|
|
|
|
return false;
|
|
}
|
|
|
|
// AI MOVE
|
|
function aiMakeMove(){
|
|
if(!aiEnabled || currentTurn !== aiPlays || gameOver) { console.debug("aiMakeMove blocked", {aiEnabled, currentTurn, aiPlays, gameOver}); return; }
|
|
const moves = getAllMovesFor(aiPlays);
|
|
if(moves.length === 0) { checkWinCondition(); return; }
|
|
|
|
let bestScore = -Infinity, bestMoves = [];
|
|
for(const m of moves){
|
|
let s = 0;
|
|
if(m.capture) s += 100;
|
|
if(aiPlays===2 && m.r2 === size-1) s += 50;
|
|
s += (m.r2 || 0);
|
|
if(s > bestScore){ bestScore = s; bestMoves = [m]; } else if(s === bestScore) bestMoves.push(m);
|
|
}
|
|
const chosen = bestMoves[Math.floor(Math.random()*bestMoves.length)];
|
|
setTimeout(()=>{ tryMove(chosen.r1, chosen.c1, chosen.r2, chosen.c2); draw(); checkWinCondition(); }, aiThinkingDelay);
|
|
}
|
|
|
|
function triggerAI(){ setTimeout(()=>{ if(aiEnabled && currentTurn===aiPlays && !gameOver) aiMakeMove(); }, 120); }
|
|
|
|
// MENGKLIK PADA KANVAS
|
|
canvas.addEventListener("click", (e)=>{
|
|
console.debug("canvas click", {gameOver, aiEnabled, currentTurn});
|
|
if(gameOver) return;
|
|
if(aiEnabled && currentTurn === aiPlays) { console.debug("click ignored: AI turn"); return; }
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = e.clientX - rect.left, y = e.clientY - rect.top;
|
|
const c = Math.floor(x / tileSize), r = Math.floor(y / tileSize);
|
|
|
|
console.debug("click coords", {x,y, r, c});
|
|
|
|
if(!inside(r,c)) return;
|
|
const v = board[r][c];
|
|
|
|
if(selected){
|
|
if(tryMove(selected.r, selected.c, r, c)){
|
|
selected = null;
|
|
} else if(v!==0 && belongsTo(v, currentTurn)){
|
|
selected = {r,c};
|
|
} else {
|
|
selected = null;
|
|
}
|
|
} else {
|
|
if(v!==0 && belongsTo(v, currentTurn)){
|
|
selected = {r,c};
|
|
}
|
|
}
|
|
draw();
|
|
});
|
|
|
|
// TOGGLE HINTS DENGAN 'H'
|
|
document.addEventListener("keydown", (e)=>{ if(e.key.toLowerCase()==='h'){ hintsEnabled = !hintsEnabled; draw(); } });
|
|
|
|
// BUTTONS
|
|
const newBtn = document.getElementById("newBtn");
|
|
if(newBtn) newBtn.onclick = ()=> resetBoard();
|
|
|
|
const undoBtn = document.getElementById("undoBtn");
|
|
if(undoBtn) undoBtn.onclick = ()=>{ if(history.length>0){ board = history.pop(); draw(); } };
|
|
|
|
// LEADERBOARD LOAD & SAVE
|
|
async function loadLeaderboard(){
|
|
const box = document.getElementById("leaderboardList");
|
|
if(!box) return;
|
|
try{
|
|
const res = await fetch("load_leaderboard.php");
|
|
if(!res.ok) throw new Error("Network");
|
|
const data = await res.json();
|
|
box.innerHTML = "";
|
|
if(!data || data.length===0){ box.innerHTML = "<p>Belum ada data</p>"; return; }
|
|
data.forEach((row,i)=>{
|
|
const div = document.createElement("div");
|
|
div.className = "lb-row" + (typeof CURRENT_USERNAME!=='undefined' && row.username === CURRENT_USERNAME ? " me" : "");
|
|
div.innerHTML = `<span>${i+1}. ${escapeHtml(row.username)}</span><span>${row.wins}W | ${row.losses}L</span>`;
|
|
box.appendChild(div);
|
|
});
|
|
}catch(err){ console.error("loadLeaderboard error", err); }
|
|
}
|
|
function escapeHtml(s){ if(!s) return ''; return String(s).replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]); }
|
|
window.addEventListener("load", ()=>{ loadLeaderboard(); setInterval(loadLeaderboard,10000); });
|
|
|
|
async function saveResult(result){
|
|
if(typeof CURRENT_USER_ID === 'undefined') return;
|
|
try{
|
|
const res = await fetch("save_result.php",{ method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({result}) });
|
|
if(res.ok) await loadLeaderboard();
|
|
}catch(err){ console.error("saveResult error", err); }
|
|
}
|
|
|
|
// END GAME POPUP
|
|
function showEnd(title, msg, result){
|
|
if(document.getElementById("endPopup")) return;
|
|
gameOver = true;
|
|
stopTimer();
|
|
|
|
const div = document.createElement("div");
|
|
div.id = "endPopup";
|
|
div.style = `position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.75);display:flex;justify-content:center;align-items:center;z-index:999999999;pointer-events:auto;`;
|
|
div.innerHTML = `<div style="background:#1e293b;padding:24px;border-radius:12px;width:310px;text-align:center;color:white;"><h2 style="margin:0 0 8px">${title}</h2><p style="margin:0 0 16px">${msg}</p><div style="display:flex;gap:8px;justify-content:center;"><button id="popupNew" style="background:#0ea5a4;color:white;border:none;padding:10px 18px;border-radius:8px;">New Game</button><button id="popupClose" style="background:#64748b;color:white;border:none;padding:10px 18px;border-radius:8px;">Close</button></div></div>`;
|
|
document.body.appendChild(div);
|
|
// save result async
|
|
saveResult(result).catch(()=>{});
|
|
document.getElementById("popupNew").onclick = ()=>{ div.remove(); resetBoard(); };
|
|
document.getElementById("popupClose").onclick = ()=> div.remove();
|
|
}
|
|
|
|
// START GAME
|
|
resetBoard(); |