Skip to content

Instantly share code, notes, and snippets.

@donyahmd
Last active June 28, 2025 03:16
Show Gist options
  • Select an option

  • Save donyahmd/c556b35ef207d68e7e325fe65d0b8029 to your computer and use it in GitHub Desktop.

Select an option

Save donyahmd/c556b35ef207d68e7e325fe65d0b8029 to your computer and use it in GitHub Desktop.
<!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