From a125737c9dba5e6e1d81deeac5cf923e6c09ea4f Mon Sep 17 00:00:00 2001 From: ewen Date: Thu, 27 Nov 2025 09:29:03 +0100 Subject: [PATCH] V1 of the game --- index.html | 21 ++++ script.js | 325 +++++++++++++++++++++++++++++++++++++++++++++++++++++ style.css | 90 +++++++++++++++ 3 files changed, 436 insertions(+) create mode 100644 index.html create mode 100644 script.js create mode 100644 style.css diff --git a/index.html b/index.html new file mode 100644 index 0000000..1d308f4 --- /dev/null +++ b/index.html @@ -0,0 +1,21 @@ + + + + + + Heartstopper + + + + +
+
Score: 0
+ +
+ + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..74faff2 --- /dev/null +++ b/script.js @@ -0,0 +1,325 @@ +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(); diff --git a/style.css b/style.css new file mode 100644 index 0000000..9f219b1 --- /dev/null +++ b/style.css @@ -0,0 +1,90 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background-color: #111; + color: #fff; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + overflow: hidden; + height: 100vh; + width: 100vw; +} + +canvas { + display: block; +} + +#ui { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +#score { + position: absolute; + top: 20px; + left: 20px; + font-size: 24px; + font-weight: bold; + opacity: 0.8; +} + +#gameOver { + background: rgba(0, 0, 0, 0.85); + padding: 40px; + border-radius: 12px; + text-align: center; + pointer-events: auto; + backdrop-filter: blur(5px); + border: 1px solid #333; + box-shadow: 0 10px 30px rgba(0,0,0,0.5); +} + +#gameOver.hidden { + display: none; +} + +#gameOver h1 { + color: #ff4d4d; + margin-bottom: 10px; + font-size: 3em; + text-transform: uppercase; + letter-spacing: 2px; +} + +#gameOver p { + font-size: 1.5em; + margin-bottom: 30px; +} + +#restartBtn { + background: #ff4d4d; + color: white; + border: none; + padding: 12px 30px; + font-size: 1.2em; + border-radius: 30px; + cursor: pointer; + transition: transform 0.2s, background 0.2s; + text-transform: uppercase; + font-weight: bold; +} + +#restartBtn:hover { + background: #ff3333; + transform: scale(1.05); +} + +#restartBtn:active { + transform: scale(0.95); +}