Last active
June 28, 2025 03:16
-
-
Save donyahmd/c556b35ef207d68e7e325fe65d0b8029 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="id"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Game Biliar 9 Bola Interaktif</title> | |
| <!-- Menambahkan pustaka Tone.js untuk audio --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.7.77/Tone.js"></script> | |
| <style> | |
| /* Styling untuk keseluruhan halaman */ | |
| body { | |
| background-color: #1a1a1a; | |
| color: #fff; | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| height: 100vh; | |
| margin: 0; | |
| overflow: hidden; | |
| /* Mencegah interaksi sentuh yang tidak diinginkan pada halaman */ | |
| touch-action: none; | |
| } | |
| /* Kontainer utama untuk game */ | |
| .game-container { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 10px; /* Mengurangi gap */ | |
| width: 100%; | |
| max-width: 95vw; /* Memastikan game tidak terlalu lebar di desktop */ | |
| } | |
| /* Styling untuk judul */ | |
| h1 { | |
| color: #4d94ff; | |
| text-shadow: 0 0 10px #4d94ff; | |
| margin-bottom: 0; | |
| font-size: 1.5em; | |
| } | |
| #gameStatus { | |
| background-color: rgba(0,0,0,0.5); | |
| padding: 8px 15px; | |
| border-radius: 10px; | |
| text-align: center; | |
| font-size: 1em; | |
| min-height: 2.5em; /* Sedikit lebih tinggi untuk pesan AI */ | |
| width: 90%; | |
| max-width: 700px; | |
| border: 1px solid rgba(255,255,255,0.2); | |
| transition: all 0.3s; | |
| } | |
| /* Styling untuk kanvas game (meja biliar) */ | |
| canvas#poolCanvas { | |
| background-color: #006400; /* Warna hijau laken */ | |
| border-radius: 15px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.5), inset 0 0 15px rgba(0,0,0,0.4); | |
| border: 15px solid #654321; /* Bingkai kayu */ | |
| width: 100%; | |
| max-width: 800px; | |
| height: auto; | |
| aspect-ratio: 2 / 1; /* Menjaga rasio aspek meja */ | |
| } | |
| .button-container { | |
| display: flex; | |
| gap: 15px; | |
| margin-top: 5px; | |
| } | |
| .action-button { | |
| color: white; | |
| border: none; | |
| padding: 10px 20px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-size: 0.9em; | |
| font-weight: bold; | |
| box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4); | |
| transition: all 0.2s ease-in-out; | |
| } | |
| .action-button:hover { transform: translateY(-2px); } | |
| .action-button:active { transform: translateY(0); } | |
| .action-button:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| #resetButton { background: linear-gradient(145deg, #f05a5a, #d63c3c); } | |
| #aiTipButton { background: linear-gradient(145deg, #5a9cf0, #3c7ad6); } | |
| /* UI Kontrol Spin dipindahkan ke kanan atas */ | |
| #spinControlContainer { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| width: 80px; | |
| height: 80px; | |
| background-color: rgba(0,0,0,0.4); | |
| border-radius: 50%; | |
| border: 2px solid rgba(255,255,255,0.3); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| z-index: 10; | |
| } | |
| #spinCanvas { width: 100%; height: 100%; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="game-container"> | |
| <h1>Biliar 9 Bola</h1> | |
| <div id="gameStatus">Selamat Datang!</div> | |
| <canvas id="poolCanvas"></canvas> | |
| <div class="button-container"> | |
| <button id="resetButton" class="action-button">Mulai Ulang</button> | |
| <button id="aiTipButton" class="action-button">✨ Dapatkan Saran AI</button> | |
| </div> | |
| </div> | |
| <div id="spinControlContainer" title="Atur Efek Bola"> | |
| <canvas id="spinCanvas"></canvas> | |
| </div> | |
| <script> | |
| // Inisialisasi Kanvas dan Konteks | |
| const canvas = document.getElementById('poolCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const gameStatusEl = document.getElementById('gameStatus'); | |
| const aiTipButton = document.getElementById('aiTipButton'); | |
| const spinCanvas = document.getElementById('spinCanvas'); | |
| const spinCtx = spinCanvas.getContext('2d'); | |
| spinCanvas.width = 80; | |
| spinCanvas.height = 80; | |
| // Pengaturan ukuran kanvas utama | |
| const baseWidth = 800; | |
| const baseHeight = 400; | |
| canvas.width = baseWidth; | |
| canvas.height = baseHeight; | |
| // --- EFEK SUARA --- | |
| let audioReady = false; | |
| const ballHitSynth = new Tone.MetalSynth({ frequency: 150, envelope: { attack: 0.001, decay: 0.1, release: 0.05 }, harmonicity: 8.5, modulationIndex: 20, resonance: 4000, octaves: 1.5 }).toDestination(); | |
| const cueStrikeSynth = new Tone.MetalSynth({ frequency: 100, envelope: { attack: 0.001, decay: 0.15, release: 0.05 }, harmonicity: 5.1, modulationIndex: 32, resonance: 3200, octaves: 1.5 }).toDestination(); | |
| const wallHitSynth = new Tone.MembraneSynth({ pitchDecay: 0.1, octaves: 2, envelope: { attack: 0.001, decay: 0.2, sustain: 0, release: 0.1 } }).toDestination(); | |
| const pocketSynth = new Tone.PluckSynth({ attackNoise: 0.5, dampening: 6000, resonance: 0.7 }).toDestination(); | |
| // --- KELAS DAN FUNGSI UTAMA --- | |
| class Vector { | |
| constructor(x, y) { this.x = x; this.y = y; } | |
| add(v) { return new Vector(this.x + v.x, this.y + v.y); } | |
| subtract(v) { return new Vector(this.x - v.x, this.y - v.y); } | |
| multiply(s) { return new Vector(this.x * s, this.y * s); } | |
| dot(v) { return this.x * v.x + this.y * v.y; } | |
| magnitude() { return Math.sqrt(this.x ** 2 + this.y ** 2); } | |
| normalize() { | |
| const mag = this.magnitude(); | |
| return mag > 0 ? new Vector(this.x / mag, this.y / mag) : new Vector(0, 0); | |
| } | |
| } | |
| // Variabel Fisika & Meja | |
| const ballRadius = 10; | |
| const restitution = 0.95; | |
| const cushionPerpendicularRestitution = 0.85; | |
| const cushionTangentialRestitution = 0.92; | |
| const highSpeedFriction = 0.975; | |
| const lowSpeedFriction = 0.995; | |
| const visualPocketRadius = 20; | |
| const effectivePocketRadius = 24; | |
| // Variabel State Game | |
| let balls = []; | |
| let cueBall; | |
| let isShooting = false; | |
| let shootPower = 0; | |
| let canShoot = true; | |
| let shotDirection = new Vector(0,0); | |
| let visualAimDirection = new Vector(1, 0); | |
| // Variabel untuk Aturan & Giliran | |
| let shotInProgress = false; | |
| let lowestBallOnTable = 1; | |
| let ballInHand = false; | |
| let turnStats = {}; | |
| // Variabel untuk Spin | |
| let cueBallSpin = {x: 0, y: 0}; | |
| const spinToVelocityFactor = 4; | |
| const wallSpinEffectFactor = 2; | |
| // Helper Input | |
| let mouse = { x: 0, y: 0 }; | |
| let startTouchPos = null; | |
| const pockets = [ { x: 0, y: 0 }, { x: baseWidth / 2, y: 0 }, { x: baseWidth, y: 0 }, { x: 0, y: baseHeight }, { x: baseWidth / 2, y: baseHeight }, { x: baseWidth, y: baseHeight } ]; | |
| const ballColors = [ '#ffd700', '#0000ff', '#ff0000', '#800080', '#ffa500', '#008000', '#a52a2a', '#000000', '#ffd700' ]; | |
| const ballTypes = [ 'solid', 'solid', 'solid', 'solid', 'solid', 'solid', 'solid', 'solid', 'stripe' ]; | |
| class Ball { | |
| constructor(x, y, color, number, type) { | |
| this.pos = new Vector(x, y); this.vel = new Vector(0, 0); | |
| this.color = color; this.number = number; this.type = type; | |
| this.radius = ballRadius; this.isPocketed = false; | |
| this.isPocketing = false; this.pocketingScale = 1; | |
| this.spin = new Vector(0, 0); this.isCueBall = !number; | |
| } | |
| update(subStep) { | |
| if (this.isPocketed) return false; | |
| if (this.isPocketing) { | |
| this.pocketingScale -= 0.08; this.vel = this.vel.multiply(0.9); | |
| if (this.pocketingScale <= 0) { | |
| this.pocketingScale = 0; this.isPocketed = true; this.isPocketing = false; this.vel = new Vector(0, 0); | |
| } | |
| return false; | |
| } | |
| const speed = this.vel.magnitude(); | |
| const frictionFactor = Math.min(speed / 10, 1); | |
| const dynamicFriction = lowSpeedFriction * (1 - frictionFactor) + highSpeedFriction * frictionFactor; | |
| this.vel = this.vel.multiply(dynamicFriction ** subStep); | |
| this.spin = this.spin.multiply(0.98 ** subStep); | |
| this.pos = this.pos.add(this.vel.multiply(subStep)); | |
| if (this.vel.magnitude() < 0.01) this.vel = new Vector(0, 0); | |
| const wallHit = this.handleWallCollision(); | |
| if (wallHit && turnStats.firstContact) turnStats.cushionHitAfterContact = true; | |
| return wallHit; | |
| } | |
| draw() { | |
| if (this.isPocketed) return; | |
| const currentRadius = this.radius * this.pocketingScale; | |
| ctx.beginPath(); | |
| ctx.arc(this.pos.x, this.pos.y, currentRadius, 0, Math.PI * 2); | |
| ctx.fillStyle = this.color; ctx.fill(); | |
| ctx.strokeStyle = '#222'; ctx.stroke(); | |
| if (this.type === 'stripe' && this.number) { | |
| ctx.fillStyle = 'white'; | |
| ctx.beginPath(); | |
| ctx.arc(this.pos.x, this.pos.y, currentRadius * 0.7, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| if (this.number && this.pocketingScale > 0.5) { | |
| ctx.fillStyle = (this.type === 'stripe' || this.number === 8) ? 'white' : 'black'; | |
| if (this.number === 9) ctx.fillStyle = 'black'; | |
| ctx.font = `bold ${10 * this.pocketingScale}px Arial`; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| ctx.fillText(this.number.toString(), this.pos.x, this.pos.y); | |
| } | |
| ctx.closePath(); | |
| } | |
| handleWallCollision() { | |
| let collided = false; | |
| if (this.pos.x - this.radius < 0 && this.vel.x < 0) { | |
| this.vel.x *= -cushionPerpendicularRestitution; this.vel.y *= cushionTangentialRestitution; | |
| this.vel.y += this.spin.x * wallSpinEffectFactor; this.spin.x *= -0.5; | |
| this.pos.x = this.radius; collided = true; | |
| } else if (this.pos.x + this.radius > baseWidth && this.vel.x > 0) { | |
| this.vel.x *= -cushionPerpendicularRestitution; this.vel.y *= cushionTangentialRestitution; | |
| this.vel.y -= this.spin.x * wallSpinEffectFactor; this.spin.x *= -0.5; | |
| this.pos.x = baseWidth - this.radius; collided = true; | |
| } | |
| if (this.pos.y - this.radius < 0 && this.vel.y < 0) { | |
| this.vel.y *= -cushionPerpendicularRestitution; this.vel.x *= cushionTangentialRestitution; | |
| this.vel.x -= this.spin.x * wallSpinEffectFactor; this.spin.x *= -0.5; | |
| this.pos.y = this.radius; collided = true; | |
| } else if (this.pos.y + this.radius > baseHeight && this.vel.y > 0) { | |
| this.vel.y *= -cushionPerpendicularRestitution; this.vel.x *= cushionTangentialRestitution; | |
| this.vel.x += this.spin.x * wallSpinEffectFactor; this.spin.x *= -0.5; | |
| this.pos.y = baseHeight - this.radius; collided = true; | |
| } | |
| return collided; | |
| } | |
| } | |
| // --- FUNGSI PENGATURAN & ATURAN GAME --- | |
| function setupGame() { | |
| balls = []; | |
| cueBall = new Ball(baseWidth / 4, baseHeight / 2, 'white', null, 'cue'); | |
| balls.push(cueBall); | |
| let otherBalls = [2, 3, 4, 5, 6, 7, 8]; | |
| otherBalls.sort(() => Math.random() - 0.5); | |
| const rackPositions = [ { col: 0, row: 0, num: 1 }, { col: 1, row: -0.5, num: otherBalls.pop() }, { col: 1, row: 0.5, num: otherBalls.pop() }, { col: 2, row: -1, num: otherBalls.pop() }, { col: 2, row: 0, num: 9 }, { col: 2, row: 1, num: otherBalls.pop() }, { col: 3, row: -0.5, num: otherBalls.pop() }, { col: 3, row: 0.5, num: otherBalls.pop() }, { col: 4, row: 0, num: otherBalls.pop() } ]; | |
| rackPositions.forEach(pos => { | |
| const x = baseWidth * 0.75 + pos.col * (ballRadius * 1.732); | |
| const y = baseHeight / 2 + pos.row * (ballRadius * 2); | |
| balls.push(new Ball(x, y, ballColors[pos.num - 1], pos.num, ballTypes[pos.num - 1])); | |
| }); | |
| updateLowestBallOnTable(); updateGameStatus(); resetTurnStats(); | |
| canShoot = true; ballInHand = false; | |
| } | |
| function resetTurnStats() { turnStats = { firstContact: null, pocketedBalls: [], cushionHitAfterContact: false, cueBallScratched: false }; } | |
| function updateLowestBallOnTable() { | |
| let lowest = 99; | |
| for(const ball of balls) { | |
| if (!ball.isPocketed && ball.number && ball.number < lowest) lowest = ball.number; | |
| } | |
| lowestBallOnTable = lowest === 99 ? null : lowest; | |
| } | |
| function updateGameStatus(message = "") { | |
| if (message) gameStatusEl.textContent = message; | |
| else if (ballInHand) gameStatusEl.textContent = "Ball-in-Hand! Tempatkan bola putih."; | |
| else gameStatusEl.textContent = `Giliran Anda. Target: Bola ${lowestBallOnTable}`; | |
| } | |
| function spotBall(number, position = {x: baseWidth * 0.75, y: baseHeight / 2}) { | |
| const ball = balls.find(b => b.number === number); | |
| if(ball) { | |
| ball.isPocketed = false; ball.isPocketing = false; ball.pocketingScale = 1; | |
| ball.pos = new Vector(position.x, position.y); ball.vel = new Vector(0,0); | |
| } | |
| } | |
| async function getAIComment(event) { | |
| let prompt = ""; | |
| switch (event.type) { | |
| case "foul": prompt = `You are a funny billiards commentator. The player just committed a foul: ${event.reason}. Give a short, witty, one-sentence comment.`; break; | |
| case "good_shot": prompt = `You are an encouraging billiards commentator. The player just made a good shot and pocketed ball #${event.ballNumber}. Give a short, one-sentence praise.`; break; | |
| case "win": prompt = `You are an excited billiards commentator. The player just legally pocketed the 9-ball to win the game! Give a short, one-sentence, celebratory comment.`; break; | |
| } | |
| if (!prompt) return; | |
| const text = await callGemini(prompt); | |
| if (text) updateGameStatus(text); | |
| } | |
| function evaluateShot() { | |
| shotInProgress = false; | |
| let foul = false; | |
| let foulReason = ""; | |
| if (turnStats.cueBallScratched) { foul = true; foulReason = "Scratch!"; } | |
| else if (!turnStats.firstContact) { foul = true; foulReason = "Tidak mengenai bola apa pun."; } | |
| else if (turnStats.firstContact.number !== lowestBallOnTable) { foul = true; foulReason = `Harus mengenai bola ${lowestBallOnTable} terlebih dahulu.`; } | |
| else if (turnStats.pocketedBalls.length === 0 && !turnStats.cushionHitAfterContact) { foul = true; foulReason = "Tidak ada bola masuk atau mengenai bantalan setelah kontak."; } | |
| if (foul) { | |
| ballInHand = true; getAIComment({type: 'foul', reason: foulReason}); | |
| if(turnStats.cueBallScratched) { | |
| cueBall.isPocketed = false; cueBall.isPocketing = false; | |
| cueBall.pocketingScale = 1; cueBall.pos = new Vector(baseWidth / 4, baseHeight/2); | |
| cueBall.vel = new Vector(0,0); | |
| } | |
| const nineBallPocketed = turnStats.pocketedBalls.find(b => b.number === 9); | |
| if (nineBallPocketed) spotBall(9, {x: baseWidth / 2, y: baseHeight / 2}); | |
| } else { | |
| const nineBallPocketed = turnStats.pocketedBalls.find(b => b.number === 9); | |
| if (nineBallPocketed) { canShoot = false; getAIComment({type: 'win'}); } | |
| else if (turnStats.pocketedBalls.length > 0) { | |
| getAIComment({type: 'good_shot', ballNumber: turnStats.pocketedBalls[0].number}); | |
| updateLowestBallOnTable(); | |
| } else { | |
| ballInHand = true; updateLowestBallOnTable(); | |
| updateGameStatus("Pindah giliran. Lawan mendapatkan ball-in-hand."); | |
| } | |
| } | |
| resetTurnStats(); | |
| } | |
| // --- FUNGSI FISIKA --- | |
| function handleCollisions() { | |
| let maxImpactVelocity = 0; | |
| for (let i = 0; i < balls.length; i++) { | |
| for (let j = i + 1; j < balls.length; j++) { | |
| const b1 = balls[i], b2 = balls[j]; | |
| if (b1.isPocketed || b2.isPocketed || b1.isPocketing || b2.isPocketing) continue; | |
| const distVec = b1.pos.subtract(b2.pos); | |
| if (distVec.magnitude() < b1.radius + b2.radius) { | |
| const normal = distVec.normalize(); | |
| const tangent = new Vector(-normal.y, normal.x); | |
| const correction = normal.multiply((b1.radius + b2.radius - distVec.magnitude()) / 2); | |
| b1.pos = b1.pos.add(correction); | |
| b2.pos = b2.pos.subtract(correction); | |
| const v1n_scalar = b1.vel.dot(normal), v1t_scalar = b1.vel.dot(tangent); | |
| const v2n_scalar = b2.vel.dot(normal), v2t_scalar = b2.vel.dot(tangent); | |
| const v1n_new = (v1n_scalar * (1 - restitution) + 2 * restitution * v2n_scalar) / (1 + restitution); | |
| const v2n_new = (v2n_scalar * (1 - restitution) + 2 * restitution * v1n_scalar) / (1 + restitution); | |
| b1.vel = normal.multiply(v1n_new).add(tangent.multiply(v1t_scalar)); | |
| b2.vel = normal.multiply(v2n_new).add(tangent.multiply(v2t_scalar)); | |
| // PERBAIKAN: Terapkan efek spin SETELAH tumbukan dasar | |
| if (!turnStats.firstContact && (b1.isCueBall || b2.isCueBall)) { | |
| let cue = b1.isCueBall ? b1 : b2; | |
| turnStats.firstContact = b1.isCueBall ? b2 : b1; | |
| const spinFactor = cue.spin.y * spinToVelocityFactor; | |
| const spinVel = shotDirection.multiply(-spinFactor); | |
| cue.vel = cue.vel.add(spinVel); | |
| cue.spin = new Vector(0,0); | |
| } | |
| const impactVelocity = Math.abs(v1n_new - v1n_scalar) + Math.abs(v2n_new - v2n_scalar); | |
| if (impactVelocity > maxImpactVelocity) maxImpactVelocity = impactVelocity; | |
| } | |
| } | |
| } | |
| return maxImpactVelocity; | |
| } | |
| function checkPockets() { | |
| for (const ball of balls) { | |
| if (ball.isPocketed || ball.isPocketing) continue; | |
| for (const pocket of pockets) { | |
| if (ball.pos.subtract(new Vector(pocket.x, pocket.y)).magnitude() < effectivePocketRadius) { | |
| ball.isPocketing = true; | |
| turnStats.pocketedBalls.push(ball); | |
| if (ball.isCueBall) turnStats.cueBallScratched = true; | |
| if(audioReady) pocketSynth.triggerAttackRelease("G2", "8n"); | |
| } | |
| } | |
| } | |
| } | |
| function allBallsStopped() { return balls.every(ball => ball.vel.magnitude() < 0.01 && !ball.isPocketing); } | |
| // --- FUNGSI GAMBAR --- | |
| function draw() { | |
| ctx.clearRect(0, 0, baseWidth, baseHeight); | |
| for (const pocket of pockets) { | |
| const gradient = ctx.createRadialGradient(pocket.x, pocket.y, visualPocketRadius * 0.2, pocket.x, pocket.y, visualPocketRadius); | |
| gradient.addColorStop(0, '#000000'); | |
| gradient.addColorStop(1, '#1a1a1a'); | |
| ctx.fillStyle = gradient; | |
| ctx.beginPath(); | |
| ctx.arc(pocket.x, pocket.y, visualPocketRadius, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| balls.forEach(ball => ball.draw()); | |
| if (canShoot && !cueBall.isPocketed && !cueBall.isPocketing) { | |
| if (ballInHand) { | |
| ctx.beginPath(); | |
| ctx.arc(mouse.x, mouse.y, ballRadius, 0, Math.PI * 2); | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; | |
| ctx.fill(); | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; | |
| ctx.stroke(); | |
| } else { | |
| drawPredictionLines(visualAimDirection); | |
| drawCue(visualAimDirection); | |
| } | |
| } | |
| } | |
| function drawLineSegment(startPos, endPos, style = {}) { | |
| const { color = 'rgba(255, 255, 255, 0.5)', dash = [5, 10], lineWidth = 1 } = style; | |
| ctx.beginPath(); | |
| ctx.moveTo(startPos.x, startPos.y); | |
| ctx.lineTo(endPos.x, endPos.y); | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = lineWidth; | |
| ctx.setLineDash(dash); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| } | |
| function drawRayLine(startPos, dir, style = {}) { | |
| if (dir.magnitude() === 0) return; | |
| let t_min = Infinity; | |
| if (dir.x > 0) t_min = Math.min(t_min, (baseWidth - startPos.x) / dir.x); | |
| if (dir.x < 0) t_min = Math.min(t_min, -startPos.x / dir.x); | |
| if (dir.y > 0) t_min = Math.min(t_min, (baseHeight - startPos.y) / dir.y); | |
| if (dir.y < 0) t_min = Math.min(t_min, -startPos.y / dir.y); | |
| const endPoint = startPos.add(dir.multiply(t_min)); | |
| drawLineSegment(startPos, endPoint, style); | |
| } | |
| function drawPredictionLines(dir) { | |
| let closestBall = null; | |
| let minDistToImpact = Infinity; | |
| for (const ball of balls) { | |
| if (ball === cueBall || ball.isPocketed) continue; | |
| const cueToBall = ball.pos.subtract(cueBall.pos); | |
| const projection = cueToBall.dot(dir); | |
| if (projection <= 0) continue; | |
| const distToLineSq = cueToBall.dot(cueToBall) - projection * projection; | |
| if (distToLineSq < (ballRadius * 2)**2) { | |
| const distToImpact = projection - Math.sqrt((ballRadius*2)**2 - distToLineSq); | |
| if (distToImpact < minDistToImpact) { | |
| minDistToImpact = distToImpact; | |
| closestBall = ball; | |
| } | |
| } | |
| } | |
| if (closestBall) { | |
| const impactPoint = cueBall.pos.add(dir.multiply(minDistToImpact)); | |
| drawLineSegment(cueBall.pos, impactPoint, { color: 'rgba(255, 255, 255, 0.4)', dash: [2, 4], lineWidth: 1 }); | |
| ctx.beginPath(); ctx.arc(impactPoint.x, impactPoint.y, ballRadius, 0, Math.PI * 2); | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; ctx.fill(); | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; ctx.stroke(); | |
| const normal = closestBall.pos.subtract(impactPoint).normalize(); | |
| drawRayLine(closestBall.pos, normal, { color: 'rgba(255, 255, 255, 0.9)', dash: [], lineWidth: 1.5 }); | |
| const tangent = new Vector(-normal.y, normal.x); | |
| const v1t_vec = tangent.multiply(dir.dot(tangent)); | |
| const spinFactor = cueBallSpin.y * 1.5; | |
| const spinVel = dir.multiply(-spinFactor); | |
| const cueVelAfterImpact = v1t_vec.add(spinVel); | |
| drawRayLine(impactPoint, cueVelAfterImpact.normalize(), { color: 'rgba(173, 216, 230, 0.9)', dash: [8, 8], lineWidth: 1.5 }); | |
| } else { | |
| drawRayLine(cueBall.pos, dir); | |
| } | |
| } | |
| function drawCue(dir) { | |
| const cueStart = new Vector(cueBall.pos.x, cueBall.pos.y); | |
| const cueTip = cueStart.subtract(dir.multiply(15 + shootPower)); | |
| const cueEnd = cueTip.subtract(dir.multiply(250)); | |
| ctx.beginPath(); ctx.moveTo(cueTip.x, cueTip.y); ctx.lineTo(cueEnd.x, cueEnd.y); | |
| ctx.strokeStyle = '#cd853f'; ctx.lineWidth = 5; ctx.stroke(); | |
| ctx.lineWidth = 1; | |
| } | |
| function drawSpinControl() { | |
| const center = { x: spinCanvas.width / 2, y: spinCanvas.height / 2 }; | |
| const radius = spinCanvas.width / 2 - 5; | |
| spinCtx.clearRect(0, 0, spinCanvas.width, spinCanvas.height); | |
| spinCtx.beginPath(); | |
| spinCtx.arc(center.x, center.y, radius, 0, 2 * Math.PI); | |
| spinCtx.fillStyle = 'white'; spinCtx.fill(); | |
| spinCtx.strokeStyle = '#ccc'; spinCtx.stroke(); | |
| const markerX = center.x + cueBallSpin.x * radius; | |
| const markerY = center.y + cueBallSpin.y * radius; | |
| spinCtx.beginPath(); | |
| spinCtx.arc(markerX, markerY, 4, 0, 2 * Math.PI); | |
| spinCtx.fillStyle = 'rgba(255, 0, 0, 0.8)'; spinCtx.fill(); | |
| } | |
| async function callGemini(prompt) { | |
| aiTipButton.disabled = true; | |
| const originalStatus = gameStatusEl.textContent; | |
| updateGameStatus("AI sedang berpikir..."); | |
| try { | |
| const payload = { contents: [{ parts: [{ text: prompt }] }] }; | |
| const apiKey = ""; | |
| const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`; | |
| const response = await fetch(apiUrl, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`API Error: ${response.status}`); | |
| } | |
| const result = await response.json(); | |
| if (result.candidates && result.candidates[0].content && result.candidates[0].content.parts[0]) { | |
| return result.candidates[0].content.parts[0].text; | |
| } else { | |
| return "Maaf, AI tidak dapat memberikan jawaban saat ini."; | |
| } | |
| } catch (error) { | |
| console.error("Gemini API call failed:", error); | |
| return "Gagal menghubungi AI. Silakan coba lagi."; | |
| } finally { | |
| updateGameStatus(originalStatus); | |
| aiTipButton.disabled = false; | |
| } | |
| } | |
| async function getAITip() { | |
| let tableState = `Cue ball is at (${Math.round(cueBall.pos.x)}, ${Math.round(cueBall.pos.y)}). `; | |
| const onTable = balls.filter(b => !b.isPocketed && b.number); | |
| tableState += `Balls on table are: ` + onTable.map(b => `#${b.number} at (${Math.round(b.pos.x)}, ${Math.round(b.pos.y)})`).join(', '); | |
| const prompt = `You are a professional 9-ball billiards coach. The target ball is #${lowestBallOnTable}. The table layout is: ${tableState}. Pockets are at the corners and middle of long rails. Give a concise, one-sentence pro tip for the next shot. Mention if spin is a good idea. For example: "Try a soft shot on the ${lowestBallOnTable} to the corner pocket, using a little topspin to move the cue ball forward for the next shot."`; | |
| const tip = await callGemini(prompt); | |
| updateGameStatus(tip); | |
| } | |
| function gameLoop() { | |
| if (shotInProgress && allBallsStopped()) { | |
| evaluateShot(); | |
| } | |
| canShoot = !shotInProgress; | |
| aiTipButton.disabled = !canShoot || ballInHand || shotInProgress; | |
| const subSteps = 5; | |
| let wallWasHitInFrame = false; | |
| let maxImpactInFrame = 0; | |
| for (let i = 0; i < subSteps; i++) { | |
| balls.forEach(ball => { | |
| if(ball.update(1/subSteps)) wallWasHitInFrame = true; | |
| }); | |
| const impactThisStep = handleCollisions(); | |
| if(impactThisStep > maxImpactInFrame) maxImpactInFrame = impactThisStep; | |
| } | |
| checkPockets(); | |
| if (wallWasHitInFrame && audioReady && wallHitSynth.state !== "started") { | |
| wallHitSynth.triggerAttack("G1"); | |
| } | |
| if (maxImpactInFrame > 0.1 && audioReady && ballHitSynth.state !== "started") { | |
| const volume = Math.min(maxImpactInFrame * 2 - 10, 0); | |
| ballHitSynth.volume.value = volume; | |
| ballHitSynth.triggerAttack(); | |
| } | |
| if (canShoot && cueBall && !cueBall.isPocketing && !ballInHand) { | |
| const targetDirection = isShooting ? shotDirection : new Vector(mouse.x, mouse.y).subtract(cueBall.pos).normalize(); | |
| if (targetDirection.magnitude() > 0) { | |
| const smoothingFactor = 0.25; | |
| visualAimDirection.x += (targetDirection.x - visualAimDirection.x) * smoothingFactor; | |
| visualAimDirection.y += (targetDirection.y - visualAimDirection.y) * smoothingFactor; | |
| visualAimDirection = visualAimDirection.normalize(); | |
| } | |
| } | |
| draw(); | |
| drawSpinControl(); | |
| requestAnimationFrame(gameLoop); | |
| } | |
| function getEventPosition(e, targetCanvas) { | |
| const rect = targetCanvas.getBoundingClientRect(); | |
| const scaleX = targetCanvas.width / rect.width; | |
| const scaleY = targetCanvas.height / rect.height; | |
| let clientX, clientY; | |
| if (e.touches && e.touches.length > 0) { | |
| clientX = e.touches[0].clientX; | |
| clientY = e.touches[0].clientY; | |
| } else { clientX = e.clientX; clientY = e.clientY; } | |
| return new Vector((clientX - rect.left) * scaleX, (clientY - rect.top) * scaleY); | |
| } | |
| function handlePointerDown(e) { | |
| e.preventDefault(); | |
| if (!audioReady) { Tone.start(); audioReady = true; } | |
| const pos = getEventPosition(e, canvas); | |
| if (ballInHand) { | |
| cueBall.pos = pos; ballInHand = false; updateGameStatus(); return; | |
| } | |
| if (canShoot) { | |
| isShooting = true; shootPower = 0; | |
| const targetDirection = pos.subtract(cueBall.pos).normalize(); | |
| if (targetDirection.magnitude() > 0) { | |
| shotDirection = targetDirection; visualAimDirection = targetDirection; | |
| } | |
| startTouchPos = pos; | |
| } | |
| } | |
| function handlePointerMove(e) { | |
| e.preventDefault(); | |
| const pos = getEventPosition(e, canvas); | |
| mouse.x = pos.x; mouse.y = pos.y; | |
| if (isShooting) { | |
| const dragDistance = pos.subtract(startTouchPos).magnitude(); | |
| shootPower = dragDistance; | |
| if (shootPower > 150) shootPower = 150; | |
| } | |
| } | |
| function handlePointerUp(e) { | |
| e.preventDefault(); | |
| if (isShooting && canShoot) { | |
| isShooting = false; startTouchPos = null; | |
| const power = Math.min(shootPower / 5, 25); | |
| if (power > 0.1) { | |
| resetTurnStats(); shotInProgress = true; | |
| cueBall.spin = new Vector(cueBallSpin.x, cueBallSpin.y); | |
| if(audioReady && cueStrikeSynth.state !== "started") { | |
| const volume = Math.min(power / 20 - 10, 0); | |
| cueStrikeSynth.volume.value = volume; cueStrikeSynth.triggerAttack(); | |
| } | |
| cueBall.vel = shotDirection.multiply(power); | |
| cueBallSpin = {x: 0, y: 0}; | |
| } | |
| shootPower = 0; | |
| } | |
| } | |
| function handleSpinControl(e) { | |
| e.preventDefault(); | |
| const pos = getEventPosition(e, spinCanvas); | |
| const center = { x: spinCanvas.width / 2, y: spinCanvas.height / 2 }; | |
| const radius = spinCanvas.width / 2 - 5; | |
| let dx = pos.x - center.x; let dy = pos.y - center.y; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| if (dist > radius) { dx *= radius / dist; dy *= radius / dist; } | |
| cueBallSpin.x = dx / radius; cueBallSpin.y = dy / radius; | |
| } | |
| canvas.addEventListener('pointerdown', handlePointerDown); | |
| canvas.addEventListener('pointermove', handlePointerMove); | |
| canvas.addEventListener('pointerup', handlePointerUp); | |
| canvas.addEventListener('pointerleave', handlePointerUp); | |
| spinCanvas.addEventListener('pointerdown', handleSpinControl); | |
| spinCanvas.addEventListener('pointermove', (e) => { if(e.buttons > 0) handleSpinControl(e); }); | |
| document.getElementById('resetButton').addEventListener('click', setupGame); | |
| aiTipButton.addEventListener('click', getAITip); | |
| setupGame(); | |
| gameLoop(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment