230 lines
8.1 KiB
JavaScript
230 lines
8.1 KiB
JavaScript
/**
|
|
* Neon Ping-Pong Bot AI Module
|
|
* Copyright (c) 2026 Wspólnie - wspolpraca@togethere.cloud
|
|
* All rights reserved. Unauthorized copying, distribution, or modification is prohibited.
|
|
*
|
|
* AI dla bota w różnych poziomach trudności
|
|
* Skalowalne dla przyszłych ulepszeń
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
class BotAI {
|
|
constructor() {
|
|
// Konfiguracja dla różnych poziomów trudności
|
|
this.difficulties = {
|
|
easy: {
|
|
maxSpeed: 2.7,
|
|
reactionDelay: 11,
|
|
accuracy: 0.45,
|
|
predictionEnabled: true,
|
|
predictionStrength: 0.21,
|
|
returnToCenter: true,
|
|
aimStrength: 0.018,
|
|
gain: 0.072,
|
|
deadzone: 17,
|
|
closeRangeBoost: 1.012
|
|
},
|
|
medium: {
|
|
maxSpeed: 4.2,
|
|
reactionDelay: 7,
|
|
accuracy: 0.53,
|
|
predictionEnabled: true,
|
|
predictionStrength: 0.41,
|
|
returnToCenter: true,
|
|
aimStrength: 0.072,
|
|
gain: 0.114,
|
|
deadzone: 11,
|
|
closeRangeBoost: 1.06
|
|
},
|
|
hard: {
|
|
maxSpeed: 5.2,
|
|
reactionDelay: 5,
|
|
accuracy: 0.57,
|
|
predictionEnabled: true,
|
|
predictionStrength: 0.52,
|
|
returnToCenter: true,
|
|
aimStrength: 0.108,
|
|
gain: 0.132,
|
|
deadzone: 10,
|
|
closeRangeBoost: 1.09
|
|
},
|
|
extreme: {
|
|
maxSpeed: 7.5,
|
|
reactionDelay: 2,
|
|
accuracy: 0.92,
|
|
predictionEnabled: true,
|
|
predictionStrength: 0.88,
|
|
returnToCenter: true,
|
|
aimStrength: 0.185,
|
|
gain: 0.195,
|
|
deadzone: 3,
|
|
closeRangeBoost: 1.22
|
|
}
|
|
};
|
|
|
|
this.reactionCounter = 0;
|
|
this._cachedTargetY = null;
|
|
}
|
|
|
|
/**
|
|
* Aktualizacja pozycji bota
|
|
* @param {Object} bot - Obiekt bota z pozycją i wymiarami
|
|
* @param {Object} ball - Obiekt piłki z pozycją i prędkością
|
|
* @param {String} difficulty - Poziom trudności ('easy', 'medium', 'hard')
|
|
*/
|
|
update(bot, ball, difficulty = 'easy', canvasHeight = 450) {
|
|
let config = this.difficulties[difficulty];
|
|
if (!config) {
|
|
console.warn(`Nieznany poziom trudności: ${difficulty}. Używam 'easy'.`);
|
|
config = this.difficulties.easy;
|
|
}
|
|
|
|
// Opóźnienie reakcji: bot aktualizuje "cel" co N klatek (żeby nie był nadludzki)
|
|
this.reactionCounter++;
|
|
if (this.reactionCounter >= config.reactionDelay || this._cachedTargetY === null) {
|
|
this.reactionCounter = 0;
|
|
this._cachedTargetY = this.computeTargetY(bot, ball, config, canvasHeight);
|
|
}
|
|
|
|
const botCenter = bot.y + bot.height / 2;
|
|
const desiredCenter = this._cachedTargetY;
|
|
|
|
// Sterowanie płynne: prędkość zależy od dystansu
|
|
const error = desiredCenter - botCenter;
|
|
const deadzone = Number.isFinite(config.deadzone) ? config.deadzone : 6;
|
|
if (Math.abs(error) <= deadzone) {
|
|
return;
|
|
}
|
|
|
|
// Gain: jak agresywnie goni cel; większe na trudniejszych
|
|
const gain = Number.isFinite(config.gain) ? config.gain : (config.predictionEnabled ? 0.22 : 0.18);
|
|
|
|
// Mały boost prędkości, gdy piłka jest już blisko bota (na wyższych trudnościach)
|
|
let maxSpeed = config.maxSpeed;
|
|
if (ball && typeof ball.dx === 'number' && ball.dx > 0) {
|
|
const distanceToBotX = (bot.x - ball.x);
|
|
if (Number.isFinite(distanceToBotX) && distanceToBotX < 220) {
|
|
const boost = Number.isFinite(config.closeRangeBoost) ? config.closeRangeBoost : 1.0;
|
|
maxSpeed = maxSpeed * boost;
|
|
}
|
|
}
|
|
|
|
const step = this.clamp(error * gain, -maxSpeed, maxSpeed);
|
|
bot.y += step;
|
|
|
|
// Bezpieczny clamp w canvas (uwzględnia rozmiar paletki)
|
|
if (bot.y < 0) bot.y = 0;
|
|
if (bot.y + bot.height > canvasHeight) bot.y = canvasHeight - bot.height;
|
|
}
|
|
|
|
computeTargetY(bot, ball, config, canvasHeight) {
|
|
const radius = ball.radius || 0;
|
|
const minY = radius;
|
|
const maxY = Math.max(minY, canvasHeight - radius);
|
|
|
|
// Gdy piłka leci od bota (w lewo), bot wraca w okolice środka
|
|
if (ball.dx <= 0) {
|
|
if (config.returnToCenter) {
|
|
const center = canvasHeight / 2;
|
|
return this.clamp(center, minY, maxY);
|
|
}
|
|
return this.clamp(ball.y, minY, maxY);
|
|
}
|
|
|
|
// Predykcja: gdzie piłka przetnie linię bota (z odbiciami)
|
|
let targetY = ball.y;
|
|
if (config.predictionEnabled) {
|
|
targetY = this.predictBallPosition(ball, bot, canvasHeight, config.predictionStrength);
|
|
}
|
|
|
|
// Strategia: na trudniejszych poziomach lekko celuje w krawędzie (żeby wybijać pod kątem)
|
|
if (config.aimStrength && config.aimStrength > 0) {
|
|
const direction = ball.dy >= 0 ? 1 : -1;
|
|
targetY += direction * bot.height * config.aimStrength;
|
|
}
|
|
|
|
// Realistyczny błąd (skalowany paletką, nie stałe 100px)
|
|
const maxError = (1 - config.accuracy) * bot.height * 0.9;
|
|
targetY += (Math.random() - 0.5) * 2 * maxError;
|
|
|
|
return this.clamp(targetY, minY, maxY);
|
|
}
|
|
|
|
/**
|
|
* Przewiduje pozycję piłki gdy dotrze do bota
|
|
* @param {Object} ball - Obiekt piłki
|
|
* @param {Object} bot - Obiekt bota
|
|
* @param {Number} strength - Siła predykcji (0-1)
|
|
* @returns {Number} Przewidywana pozycja Y
|
|
*/
|
|
predictBallPosition(ball, bot, canvasHeight, strength) {
|
|
// Czas dotarcia do osi bota (dx > 0 gwarantowane wyżej)
|
|
const dx = ball.dx;
|
|
if (dx <= 0) return ball.y;
|
|
|
|
const distanceToBotX = (bot.x - ball.x);
|
|
const timeToReach = distanceToBotX / dx;
|
|
if (!Number.isFinite(timeToReach) || timeToReach <= 0) return ball.y;
|
|
|
|
const radius = ball.radius || 0;
|
|
const top = radius;
|
|
const bottom = Math.max(top, canvasHeight - radius);
|
|
|
|
let predictedY = ball.y + (ball.dy * timeToReach);
|
|
|
|
// Odbicia od ścian: odbijaj w przedziale [top, bottom]
|
|
let guard = 0;
|
|
while (predictedY < top || predictedY > bottom) {
|
|
if (predictedY < top) {
|
|
predictedY = top + (top - predictedY);
|
|
} else if (predictedY > bottom) {
|
|
predictedY = bottom - (predictedY - bottom);
|
|
}
|
|
guard++;
|
|
if (guard > 20) break;
|
|
}
|
|
|
|
// Mieszaj predykcję z bieżącą pozycją (żeby łatwiejsze poziomy nie były "laserem")
|
|
const mixed = ball.y * (1 - strength) + predictedY * strength;
|
|
return this.clamp(mixed, top, bottom);
|
|
}
|
|
|
|
clamp(value, min, max) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
/**
|
|
* Ustawia niestandardową konfigurację dla poziomu trudności
|
|
* @param {String} difficulty - Nazwa poziomu trudności
|
|
* @param {Object} config - Konfiguracja
|
|
*/
|
|
setCustomDifficulty(difficulty, config) {
|
|
this.difficulties[difficulty] = {
|
|
...this.difficulties.easy,
|
|
...config
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pobiera konfigurację dla poziomu trudności
|
|
* @param {String} difficulty - Nazwa poziomu trudności
|
|
* @returns {Object} Konfiguracja
|
|
*/
|
|
getDifficultyConfig(difficulty) {
|
|
return this.difficulties[difficulty] || this.difficulties.easy;
|
|
}
|
|
}
|
|
|
|
// Eksportuj klasę
|
|
if (typeof window !== 'undefined') {
|
|
window.BotAI = BotAI;
|
|
// Utwórz globalną instancję
|
|
window.botAI = new BotAI();
|
|
// Freeze object to prevent modifications
|
|
Object.freeze(window.botAI.difficulties);
|
|
}
|
|
|
|
})(); // End of IIFE
|