599 lines
20 KiB
JavaScript
599 lines
20 KiB
JavaScript
/**
|
|
* Auto-Snake: AI-Powered Snake Game
|
|
* Implements Advanced Pathfinding (BFS + Longest Path Survival)
|
|
* to ensure the snake fills the grid and avoids infinite loops.
|
|
*/
|
|
|
|
const CONFIG = {
|
|
GRID_SIZE: 20,
|
|
TILE_SIZE: 0,
|
|
COLORS: {
|
|
BG: '#020617',
|
|
SNAKE_HEAD: '#4ade80',
|
|
SNAKE_BODY: '#22c55e',
|
|
SNAKE_BODY_GRADIENT: '#166534',
|
|
APPLE: '#ef4444',
|
|
APPLE_SHINE: '#fca5a5',
|
|
WALL: '#475569',
|
|
GRID: 'rgba(56, 189, 248, 0.05)'
|
|
}
|
|
};
|
|
|
|
class Game {
|
|
constructor() {
|
|
this.canvas = document.getElementById('gameCanvas');
|
|
this.ctx = this.canvas.getContext('2d');
|
|
|
|
this.winCount = parseInt(localStorage.getItem('winCount') || '0');
|
|
this.bestScore = parseInt(localStorage.getItem('bestScore') || '0');
|
|
this.lastWinTime = parseInt(localStorage.getItem('lastWinTime') || Date.now());
|
|
|
|
this.speedRange = document.getElementById('speedRange');
|
|
this.winDisplay = document.getElementById('winCount');
|
|
this.timerDisplay = document.getElementById('timer');
|
|
this.scoreDisplay = document.getElementById('currentScore');
|
|
this.bestScoreDisplay = document.getElementById('bestScore');
|
|
this.resetBtn = document.getElementById('resetBtn');
|
|
this.toggleMazeBtn = document.getElementById('toggleMazeBtn');
|
|
this.overlay = document.getElementById('gameOverlay');
|
|
|
|
this.snake = [];
|
|
this.apple = { x: 0, y: 0 };
|
|
this.mazeEnabled = false;
|
|
this.walls = [];
|
|
this.isGameOver = false;
|
|
this.isWin = false;
|
|
this.frameCount = 0;
|
|
|
|
this.lastFrameTime = 0;
|
|
|
|
this.init();
|
|
this.setupEventListeners();
|
|
this.reset();
|
|
this.gameLoop(0);
|
|
}
|
|
|
|
init() {
|
|
const resize = () => {
|
|
const container = this.canvas.parentElement;
|
|
const size = Math.min(container.clientWidth, 600);
|
|
this.canvas.width = size;
|
|
this.canvas.height = size;
|
|
CONFIG.TILE_SIZE = size / CONFIG.GRID_SIZE;
|
|
};
|
|
window.addEventListener('resize', resize);
|
|
resize();
|
|
|
|
this.winDisplay.textContent = this.winCount;
|
|
this.bestScoreDisplay.textContent = this.bestScore;
|
|
this.updateTimer();
|
|
setInterval(() => this.updateTimer(), 1000);
|
|
}
|
|
|
|
setupEventListeners() {
|
|
this.resetBtn.addEventListener('click', () => {
|
|
this.winCount = 0;
|
|
this.bestScore = 0;
|
|
localStorage.setItem('winCount', '0');
|
|
localStorage.setItem('bestScore', '0');
|
|
this.winDisplay.textContent = '0';
|
|
this.bestScoreDisplay.textContent = '0';
|
|
this.reset();
|
|
});
|
|
|
|
this.toggleMazeBtn.addEventListener('click', () => {
|
|
this.mazeEnabled = !this.mazeEnabled;
|
|
this.reset();
|
|
});
|
|
}
|
|
|
|
reset() {
|
|
const mid = Math.floor(CONFIG.GRID_SIZE / 2);
|
|
this.snake = [
|
|
{ x: mid, y: mid },
|
|
{ x: mid, y: mid + 1 },
|
|
{ x: mid, y: mid + 2 }
|
|
];
|
|
this.isGameOver = false;
|
|
this.isWin = false;
|
|
this.overlay.classList.add('hidden');
|
|
|
|
this.generateMaze();
|
|
this.spawnApple();
|
|
this.scoreDisplay.textContent = '0';
|
|
}
|
|
|
|
generateMaze() {
|
|
this.walls = [];
|
|
if (!this.mazeEnabled) return;
|
|
|
|
const padding = 4;
|
|
const length = 8;
|
|
for (let i = padding; i < padding + length; i++) {
|
|
this.walls.push({ x: i, y: padding });
|
|
this.walls.push({ x: CONFIG.GRID_SIZE - 1 - i, y: CONFIG.GRID_SIZE - 1 - padding });
|
|
this.walls.push({ x: padding, y: CONFIG.GRID_SIZE - 1 - i });
|
|
this.walls.push({ x: CONFIG.GRID_SIZE - 1 - padding, y: i });
|
|
}
|
|
}
|
|
|
|
spawnApple() {
|
|
let possible = [];
|
|
for (let y = 0; y < CONFIG.GRID_SIZE; y++) {
|
|
for (let x = 0; x < CONFIG.GRID_SIZE; x++) {
|
|
if (!this.isOccupied(x, y)) possible.push({ x, y });
|
|
}
|
|
}
|
|
|
|
if (possible.length === 0) {
|
|
this.handleWin();
|
|
return;
|
|
}
|
|
this.apple = possible[Math.floor(Math.random() * possible.length)];
|
|
}
|
|
|
|
isOccupied(x, y, ignoreTail = false) {
|
|
if (x < 0 || x >= CONFIG.GRID_SIZE || y < 0 || y >= CONFIG.GRID_SIZE) return true;
|
|
const snakeToCheck = ignoreTail ? this.snake.slice(0, -1) : this.snake;
|
|
if (snakeToCheck.some(s => s.x === x && s.y === y)) return true;
|
|
if (this.walls.some(w => w.x === x && w.y === y)) return true;
|
|
return false;
|
|
}
|
|
|
|
updateTimer() {
|
|
const diff = Math.floor((Date.now() - this.lastWinTime) / 1000);
|
|
const mins = Math.floor(diff / 60).toString().padStart(2, '0');
|
|
const secs = (diff % 60).toString().padStart(2, '0');
|
|
this.timerDisplay.textContent = `${mins}:${secs}`;
|
|
}
|
|
|
|
handleWin() {
|
|
this.isWin = true;
|
|
this.winCount++;
|
|
this.lastWinTime = Date.now();
|
|
localStorage.setItem('winCount', this.winCount);
|
|
localStorage.setItem('lastWinTime', this.lastWinTime);
|
|
this.winDisplay.textContent = this.winCount;
|
|
this.overlay.classList.remove('hidden');
|
|
document.getElementById('overlayTitle').textContent = 'VICTORY!';
|
|
document.getElementById('overlayMessage').textContent = 'Perfect cycle complete. Restarting...';
|
|
setTimeout(() => this.reset(), 5000);
|
|
}
|
|
|
|
handleGameOver() {
|
|
this.isGameOver = true;
|
|
this.overlay.classList.remove('hidden');
|
|
document.getElementById('overlayTitle').textContent = 'RECALCULATING';
|
|
document.getElementById('overlayMessage').textContent = 'Wait, adjusting strategy...';
|
|
setTimeout(() => this.reset(), 1000);
|
|
}
|
|
|
|
bfs(start, target, ignoreTail = false) {
|
|
const queue = [[start]];
|
|
const visited = new Set();
|
|
visited.add(`${start.x},${start.y}`);
|
|
|
|
while (queue.length > 0) {
|
|
const path = queue.shift();
|
|
const curr = path[path.length - 1];
|
|
if (curr.x === target.x && curr.y === target.y) return path;
|
|
|
|
const neighbors = [
|
|
{ x: curr.x, y: curr.y - 1 }, { x: curr.x, y: curr.y + 1 },
|
|
{ x: curr.x - 1, y: curr.y }, { x: curr.x + 1, y: curr.y }
|
|
];
|
|
|
|
for (const n of neighbors) {
|
|
if (!this.isOccupied(n.x, n.y, ignoreTail) && !visited.has(`${n.x},${n.y}`)) {
|
|
visited.add(`${n.x},${n.y}`);
|
|
queue.push([...path, n]);
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
getLongestPath(start, target) {
|
|
let path = this.bfs(start, target, true);
|
|
if (!path) return null;
|
|
|
|
let extended = true;
|
|
while (extended) {
|
|
extended = false;
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
const p1 = path[i];
|
|
const p2 = path[i+1];
|
|
|
|
const dirs = [
|
|
{ x: 0, y: -1 }, { x: 0, y: 1 },
|
|
{ x: -1, y: 0 }, { x: 1, y: 0 }
|
|
];
|
|
|
|
for (const d of dirs) {
|
|
const n1 = { x: p1.x + d.x, y: p1.y + d.y };
|
|
const n2 = { x: p2.x + d.x, y: p2.y + d.y };
|
|
|
|
if (!this.isOccupied(n1.x, n1.y, true) && !this.isOccupied(n2.x, n2.y, true) &&
|
|
!path.some(p => p.x === n1.x && p.y === n1.y) &&
|
|
!path.some(p => p.x === n2.x && p.y === n2.y)) {
|
|
|
|
path.splice(i + 1, 0, n1, n2);
|
|
extended = true;
|
|
break;
|
|
}
|
|
}
|
|
if (extended) break;
|
|
}
|
|
}
|
|
return path;
|
|
}
|
|
|
|
canReachTail(virtualSnake) {
|
|
if (virtualSnake.length < 2) return true;
|
|
const head = virtualSnake[0];
|
|
const tail = virtualSnake[virtualSnake.length - 1];
|
|
|
|
const originalSnake = this.snake;
|
|
this.snake = virtualSnake;
|
|
const path = this.bfs(head, tail, true);
|
|
this.snake = originalSnake;
|
|
return !!path;
|
|
}
|
|
|
|
update() {
|
|
if (this.isGameOver || this.isWin) return;
|
|
|
|
const head = this.snake[0];
|
|
const tail = this.snake[this.snake.length - 1];
|
|
|
|
// 1. Path to apple
|
|
let path = this.bfs(head, this.apple, true);
|
|
let nextMove = null;
|
|
|
|
if (path && path.length > 1) {
|
|
const virtualSnake = [path[1], ...this.snake];
|
|
if (!(path[1].x === this.apple.x && path[1].y === this.apple.y)) {
|
|
virtualSnake.pop();
|
|
}
|
|
|
|
if (this.canReachTail(virtualSnake)) {
|
|
nextMove = path[1];
|
|
}
|
|
}
|
|
|
|
// 2. Fallback: Longest path to tail
|
|
if (!nextMove) {
|
|
const pathToTail = this.getLongestPath(head, tail);
|
|
if (pathToTail && pathToTail.length > 1) {
|
|
nextMove = pathToTail[1];
|
|
} else {
|
|
nextMove = this.getSurvivalMove();
|
|
}
|
|
}
|
|
|
|
if (!nextMove) {
|
|
this.handleGameOver();
|
|
return;
|
|
}
|
|
|
|
this.snake.unshift(nextMove);
|
|
if (nextMove.x === this.apple.x && nextMove.y === this.apple.y) {
|
|
const score = this.snake.length - 3;
|
|
this.scoreDisplay.textContent = score;
|
|
if (score > this.bestScore) {
|
|
this.bestScore = score;
|
|
this.bestScoreDisplay.textContent = score;
|
|
localStorage.setItem('bestScore', score);
|
|
}
|
|
this.spawnApple();
|
|
} else {
|
|
this.snake.pop();
|
|
}
|
|
this.frameCount++;
|
|
}
|
|
|
|
getSurvivalMove() {
|
|
const head = this.snake[0];
|
|
const neighbors = [
|
|
{ x: head.x, y: head.y - 1 }, { x: head.x, y: head.y + 1 },
|
|
{ x: head.x - 1, y: head.y }, { x: head.x + 1, y: head.y }
|
|
].filter(n => !this.isOccupied(n.x, n.y));
|
|
|
|
if (neighbors.length === 0) return null;
|
|
return neighbors.sort((a, b) => this.getReachableArea(b) - this.getReachableArea(a))[0];
|
|
}
|
|
|
|
getReachableArea(pos) {
|
|
const visited = new Set();
|
|
const stack = [pos];
|
|
visited.add(`${pos.x},${pos.y}`);
|
|
let count = 0;
|
|
|
|
while (stack.length > 0) {
|
|
const curr = stack.pop();
|
|
count++;
|
|
|
|
const neighbors = [
|
|
{ x: curr.x, y: curr.y - 1 }, { x: curr.x, y: curr.y + 1 },
|
|
{ x: curr.x - 1, y: curr.y }, { x: curr.x + 1, y: curr.y }
|
|
];
|
|
|
|
for (const n of neighbors) {
|
|
if (!this.isOccupied(n.x, n.y) && !visited.has(`${n.x},${n.y}`)) {
|
|
visited.add(`${n.x},${n.y}`);
|
|
stack.push(n);
|
|
}
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
draw() {
|
|
const { ctx, canvas } = this;
|
|
const ts = CONFIG.TILE_SIZE;
|
|
|
|
ctx.fillStyle = CONFIG.COLORS.BG;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Grid
|
|
ctx.strokeStyle = CONFIG.COLORS.GRID;
|
|
ctx.lineWidth = 0.5;
|
|
for (let i = 0; i <= CONFIG.GRID_SIZE; i++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(i * ts, 0); ctx.lineTo(i * ts, canvas.height); ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, i * ts); ctx.lineTo(canvas.width, i * ts); ctx.stroke();
|
|
}
|
|
|
|
// Walls
|
|
this.walls.forEach(w => {
|
|
const x = w.x * ts, y = w.y * ts;
|
|
ctx.fillStyle = CONFIG.COLORS.WALL;
|
|
this.drawTexturedRect(ctx, x, y, ts, ts, 'wall');
|
|
});
|
|
|
|
// Apple
|
|
const ax = this.apple.x * ts + ts/2, ay = this.apple.y * ts + ts/2;
|
|
this.drawApple(ctx, ax, ay, ts);
|
|
|
|
// Snake
|
|
for (let i = this.snake.length - 1; i >= 0; i--) {
|
|
const s = this.snake[i];
|
|
const x = s.x * ts, y = s.y * ts;
|
|
if (i === 0) {
|
|
this.drawSnakeHead(ctx, x, y, ts);
|
|
} else if (i === this.snake.length - 1) {
|
|
this.drawSnakeTail(ctx, x, y, ts, i);
|
|
} else {
|
|
this.drawSnakeBody(ctx, x, y, ts, i);
|
|
}
|
|
}
|
|
}
|
|
|
|
drawApple(ctx, x, y, ts) {
|
|
const radius = ts / 2 - 2;
|
|
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
|
ctx.beginPath();
|
|
ctx.ellipse(x, y + ts/4, radius, radius/2, 0, 0, Math.PI*2);
|
|
ctx.fill();
|
|
|
|
const grad = ctx.createRadialGradient(x - ts/6, y - ts/6, ts/10, x, y, ts/2);
|
|
grad.addColorStop(0, '#ff4d4d');
|
|
grad.addColorStop(1, '#8b0000');
|
|
ctx.fillStyle = grad;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
|
ctx.beginPath();
|
|
ctx.arc(x - ts/5, y - ts/5, ts/8, 0, Math.PI*2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = '#22c55e';
|
|
ctx.beginPath();
|
|
ctx.ellipse(x + 2, y - ts/2.5, ts/4, ts/8, Math.PI/4, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
drawSnakeHead(ctx, x, y, ts) {
|
|
const next = this.snake[1];
|
|
let rotation = 0;
|
|
if (next) {
|
|
if (next.x < this.snake[0].x) rotation = Math.PI / 2;
|
|
else if (next.x > this.snake[0].x) rotation = -Math.PI / 2;
|
|
else if (next.y < this.snake[0].y) rotation = Math.PI;
|
|
else if (next.y > this.snake[0].y) rotation = 0;
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.translate(x + ts/2, y + ts/2);
|
|
ctx.rotate(rotation);
|
|
|
|
const grad = ctx.createRadialGradient(0, 0, ts/4, 0, 0, ts/2);
|
|
grad.addColorStop(0, CONFIG.COLORS.SNAKE_HEAD);
|
|
grad.addColorStop(1, CONFIG.COLORS.SNAKE_BODY);
|
|
ctx.fillStyle = grad;
|
|
|
|
this.drawRoundedRect(ctx, -ts/2 + 1, -ts/2 + 1, ts - 2, ts + 2, 12);
|
|
|
|
// Apple tracking eyes
|
|
const dx = this.apple.x - this.snake[0].x;
|
|
const dy = this.apple.y - this.snake[0].y;
|
|
const angle = Math.atan2(dy, dx) - rotation + Math.PI/2;
|
|
const eyeOffset = ts * 0.05;
|
|
const ex = Math.cos(angle) * eyeOffset;
|
|
const ey = Math.sin(angle) * eyeOffset;
|
|
|
|
ctx.fillStyle = '#fff';
|
|
ctx.beginPath();
|
|
ctx.arc(-ts*0.22, -ts*0.2, ts*0.14, 0, Math.PI*2);
|
|
ctx.arc(ts*0.22, -ts*0.2, ts*0.14, 0, Math.PI*2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = '#000';
|
|
ctx.beginPath();
|
|
ctx.arc(-ts*0.22 + ex, -ts*0.2 + ey, ts*0.07, 0, Math.PI*2);
|
|
ctx.arc(ts*0.22 + ex, -ts*0.2 + ey, ts*0.07, 0, Math.PI*2);
|
|
ctx.fill();
|
|
|
|
// Nostrils
|
|
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
|
ctx.beginPath();
|
|
ctx.arc(-ts*0.1, -ts*0.4, 2, 0, Math.PI*2);
|
|
ctx.arc(ts*0.1, -ts*0.4, 2, 0, Math.PI*2);
|
|
ctx.fill();
|
|
|
|
if (this.frameCount % 20 < 10) {
|
|
ctx.strokeStyle = '#ef4444';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -ts/2);
|
|
ctx.lineTo(0, -ts/2 - 10);
|
|
ctx.lineTo(-4, -ts/2 - 14);
|
|
ctx.moveTo(0, -ts/2 - 10);
|
|
ctx.lineTo(4, -ts/2 - 14);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawSnakeBody(ctx, x, y, ts, index) {
|
|
const curr = this.snake[index];
|
|
const next = this.snake[index - 1];
|
|
const prev = this.snake[index + 1];
|
|
|
|
if (!next || !prev) return;
|
|
|
|
ctx.save();
|
|
ctx.translate(x + ts/2, y + ts/2);
|
|
|
|
const grad = ctx.createLinearGradient(-ts/2, -ts/2, ts/2, ts/2);
|
|
grad.addColorStop(0, CONFIG.COLORS.SNAKE_BODY);
|
|
grad.addColorStop(1, CONFIG.COLORS.SNAKE_BODY_GRADIENT);
|
|
ctx.fillStyle = grad;
|
|
|
|
const dirNext = { x: next.x - curr.x, y: next.y - curr.y };
|
|
const dirPrev = { x: prev.x - curr.x, y: prev.y - curr.y };
|
|
|
|
const isHorizontal = next.y === prev.y;
|
|
const isVertical = next.x === prev.x;
|
|
|
|
if (isHorizontal) {
|
|
this.drawRoundedRect(ctx, -ts/2, -ts/2 + 2, ts, ts - 4, 4);
|
|
} else if (isVertical) {
|
|
this.drawRoundedRect(ctx, -ts/2 + 2, -ts/2, ts - 4, ts, 4);
|
|
} else {
|
|
// Corner - smooth arc connection
|
|
ctx.beginPath();
|
|
if ((dirNext.x === 1 && dirPrev.y === 1) || (dirNext.y === 1 && dirPrev.x === 1)) {
|
|
// Bottom-right
|
|
ctx.arc(ts/2, ts/2, ts - 2, Math.PI, Math.PI * 1.5);
|
|
ctx.lineTo(ts/2, ts/2);
|
|
} else if ((dirNext.x === -1 && dirPrev.y === 1) || (dirNext.y === 1 && dirPrev.x === -1)) {
|
|
// Bottom-left
|
|
ctx.arc(-ts/2, ts/2, ts - 2, Math.PI * 1.5, 0);
|
|
ctx.lineTo(-ts/2, ts/2);
|
|
} else if ((dirNext.x === 1 && dirPrev.y === -1) || (dirNext.y === -1 && dirPrev.x === 1)) {
|
|
// Top-right
|
|
ctx.arc(ts/2, -ts/2, ts - 2, Math.PI * 0.5, Math.PI);
|
|
ctx.lineTo(ts/2, -ts/2);
|
|
} else {
|
|
// Top-left
|
|
ctx.arc(-ts/2, -ts/2, ts - 2, 0, Math.PI * 0.5);
|
|
ctx.lineTo(-ts/2, -ts/2);
|
|
}
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
|
|
// Animated scales
|
|
if ((index + Math.floor(this.frameCount/2)) % 6 === 0) {
|
|
ctx.fillStyle = 'rgba(255,255,255,0.12)';
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, ts/5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawSnakeTail(ctx, x, y, ts, index) {
|
|
const curr = this.snake[index];
|
|
const prev = this.snake[index - 1];
|
|
if (!prev) return;
|
|
|
|
let rotation = 0;
|
|
if (prev.x < curr.x) rotation = Math.PI / 2;
|
|
else if (prev.x > curr.x) rotation = -Math.PI / 2;
|
|
else if (prev.y < curr.y) rotation = Math.PI;
|
|
else if (prev.y > curr.y) rotation = 0;
|
|
|
|
ctx.save();
|
|
ctx.translate(x + ts/2, y + ts/2);
|
|
ctx.rotate(rotation);
|
|
|
|
const grad = ctx.createLinearGradient(-ts/2, -ts/2, ts/2, ts/2);
|
|
grad.addColorStop(0, CONFIG.COLORS.SNAKE_BODY);
|
|
grad.addColorStop(1, CONFIG.COLORS.SNAKE_BODY_GRADIENT);
|
|
ctx.fillStyle = grad;
|
|
|
|
// Tapered tail
|
|
ctx.beginPath();
|
|
ctx.moveTo(-ts/2 + 4, -ts/2);
|
|
ctx.lineTo(ts/2 - 4, -ts/2);
|
|
ctx.quadraticCurveTo(0, ts/2, 0, ts/2 + 6);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawTexturedRect(ctx, x, y, w, h, type) {
|
|
if (type === 'wall') {
|
|
const grad = ctx.createLinearGradient(x, y, x + w, y + h);
|
|
grad.addColorStop(0, '#475569');
|
|
grad.addColorStop(1, '#1e293b');
|
|
ctx.fillStyle = grad;
|
|
this.drawRoundedRect(ctx, x + 2, y + 2, w - 4, h - 4, 6);
|
|
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
|
ctx.strokeRect(x + 6, y + 6, w - 12, h - 12);
|
|
}
|
|
}
|
|
|
|
drawRoundedRect(ctx, x, y, w, h, r) {
|
|
ctx.beginPath();
|
|
if (ctx.roundRect) {
|
|
ctx.roundRect(x, y, w, h, r);
|
|
} else {
|
|
ctx.moveTo(x + r, y);
|
|
ctx.lineTo(x + w - r, y);
|
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
ctx.lineTo(x + w, y + h - r);
|
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
ctx.lineTo(x + r, y + h);
|
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
ctx.lineTo(x, y + r);
|
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
}
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
|
|
gameLoop(timestamp) {
|
|
const speed = parseInt(this.speedRange.value);
|
|
const interval = 1000 / speed;
|
|
|
|
if (timestamp - this.lastFrameTime >= interval) {
|
|
this.update();
|
|
this.draw();
|
|
this.lastFrameTime = timestamp;
|
|
}
|
|
|
|
requestAnimationFrame((t) => this.gameLoop(t));
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => new Game()); |