heartstopper/script.js
2025-11-27 09:29:03 +01:00

326 lines
8.4 KiB
JavaScript

const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score');
const finalScoreEl = document.getElementById('finalScore');
const gameOverEl = document.getElementById('gameOver');
const restartBtn = document.getElementById('restartBtn');
// Game State
let score = 0;
let gameActive = true;
let animationId;
let frames = 0;
// Entities
let bullets = [];
let particles = [];
// Mouse
const mouse = {
x: innerWidth / 2,
y: innerHeight / 2
};
// Resize handling
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
// Input handling
window.addEventListener('mousemove', (e) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
window.addEventListener('touchmove', (e) => {
mouse.x = e.touches[0].clientX;
mouse.y = e.touches[0].clientY;
});
// Utility
function randomRange(min, max) {
return Math.random() * (max - min) + min;
}
// Classes
class Heart {
constructor() {
this.x = canvas.width / 2;
this.y = canvas.height / 2;
this.baseSize = 30;
this.size = this.baseSize;
this.pulseSpeed = 0.05;
this.angle = 0;
}
draw() {
// Update center position on resize
this.x = canvas.width / 2;
this.y = canvas.height / 2;
// Pulse animation
this.angle += this.pulseSpeed;
const scale = 1 + Math.sin(this.angle) * 0.1;
ctx.save();
ctx.translate(this.x, this.y);
ctx.scale(scale, scale);
// Draw Heart
ctx.beginPath();
ctx.fillStyle = '#ff4d4d';
ctx.shadowColor = '#ff4d4d';
ctx.shadowBlur = 20;
// Heart shape using bezier curves
// Starting from top center
ctx.moveTo(0, -10);
ctx.bezierCurveTo(15, -25, 35, -10, 0, 25);
ctx.bezierCurveTo(-35, -10, -15, -25, 0, -10);
ctx.fill();
ctx.restore();
}
}
class Shield {
constructor() {
this.radius = 80; // Distance from heart
this.angle = 0;
this.width = 60; // Arc length in radians (approx)
this.thickness = 10;
this.color = '#4d94ff';
}
update() {
const dx = mouse.x - canvas.width / 2;
const dy = mouse.y - canvas.height / 2;
this.angle = Math.atan2(dy, dx);
}
draw() {
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(this.angle);
ctx.beginPath();
// Draw an arc for the shield
// We want the shield to be centered on the angle
// So we draw from -arcLength/2 to +arcLength/2
const arcLength = 0.8; // Radians
ctx.arc(0, 0, this.radius, -arcLength / 2, arcLength / 2);
ctx.lineWidth = this.thickness;
ctx.strokeStyle = this.color;
ctx.lineCap = 'round';
ctx.shadowColor = this.color;
ctx.shadowBlur = 15;
ctx.stroke();
ctx.restore();
}
}
class Bullet {
constructor() {
const edge = Math.floor(Math.random() * 4); // 0: top, 1: right, 2: bottom, 3: left
if (edge === 0) { // Top
this.x = Math.random() * canvas.width;
this.y = -20;
} else if (edge === 1) { // Right
this.x = canvas.width + 20;
this.y = Math.random() * canvas.height;
} else if (edge === 2) { // Bottom
this.x = Math.random() * canvas.width;
this.y = canvas.height + 20;
} else { // Left
this.x = -20;
this.y = Math.random() * canvas.height;
}
this.radius = 5;
this.color = '#fff';
// Calculate velocity towards center
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const angle = Math.atan2(centerY - this.y, centerX - this.x);
// Speed increases with score
const speedMultiplier = 1 + (score * 0.01);
this.velocity = {
x: Math.cos(angle) * (2 + Math.random()) * speedMultiplier,
y: Math.sin(angle) * (2 + Math.random()) * speedMultiplier
};
}
update() {
this.x += this.velocity.x;
this.y += this.velocity.y;
}
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.shadowColor = this.color;
ctx.shadowBlur = 5;
ctx.fill();
}
}
class Particle {
constructor(x, y, color) {
this.x = x;
this.y = y;
this.radius = Math.random() * 3;
this.color = color;
this.velocity = {
x: (Math.random() - 0.5) * 5,
y: (Math.random() - 0.5) * 5
};
this.alpha = 1;
}
update() {
this.x += this.velocity.x;
this.y += this.velocity.y;
this.alpha -= 0.02;
}
draw() {
ctx.save();
ctx.globalAlpha = this.alpha;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.fill();
ctx.restore();
}
}
// Game Objects
const heart = new Heart();
const shield = new Shield();
function spawnBullet() {
// Spawn rate increases with score
const spawnRate = Math.max(20, 100 - score);
if (frames % spawnRate === 0) {
bullets.push(new Bullet());
}
}
function checkCollisions() {
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
bullets.forEach((bullet, index) => {
// Distance to center (Heart)
const distToCenter = Math.hypot(bullet.x - centerX, bullet.y - centerY);
// 1. Check Shield Collision
// Shield is at a specific radius. If bullet is at that radius, check angle difference.
if (distToCenter < shield.radius + shield.thickness && distToCenter > shield.radius - shield.thickness) {
const angleToBullet = Math.atan2(bullet.y - centerY, bullet.x - centerX);
// Normalize angles to -PI to PI
let angleDiff = angleToBullet - shield.angle;
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
// Shield arc length is approx 0.8 radians. Half is 0.4.
// Add a bit of buffer for the bullet size
if (Math.abs(angleDiff) < 0.5) {
// Hit Shield
createExplosion(bullet.x, bullet.y, '#4d94ff');
bullets.splice(index, 1);
score += 10;
scoreEl.textContent = `Score: ${score}`;
return;
}
}
// 2. Check Heart Collision
if (distToCenter < 30) { // Heart approx radius
endGame();
}
});
}
function createExplosion(x, y, color) {
for (let i = 0; i < 8; i++) {
particles.push(new Particle(x, y, color));
}
}
function endGame() {
gameActive = false;
cancelAnimationFrame(animationId);
gameOverEl.classList.remove('hidden');
finalScoreEl.textContent = score;
}
function restartGame() {
score = 0;
scoreEl.textContent = `Score: 0`;
bullets = [];
particles = [];
gameActive = true;
gameOverEl.classList.add('hidden');
animate();
}
restartBtn.addEventListener('click', restartGame);
function animate() {
if (!gameActive) return;
animationId = requestAnimationFrame(animate);
frames++;
// Clear with trail effect
ctx.fillStyle = 'rgba(17, 17, 17, 0.2)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
heart.draw();
shield.update();
shield.draw();
spawnBullet();
// Update and draw bullets
bullets.forEach((bullet, index) => {
bullet.update();
bullet.draw();
// Remove off-screen bullets (shouldn't happen often as they move to center)
if (bullet.x < -50 || bullet.x > canvas.width + 50 ||
bullet.y < -50 || bullet.y > canvas.height + 50) {
bullets.splice(index, 1);
}
});
// Update and draw particles
particles.forEach((particle, index) => {
if (particle.alpha <= 0) {
particles.splice(index, 1);
} else {
particle.update();
particle.draw();
}
});
checkCollisions();
}
// Start
animate();