V1 of the game
This commit is contained in:
parent
b16675976a
commit
a125737c9d
21
index.html
Normal file
21
index.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Heartstopper</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="gameCanvas"></canvas>
|
||||
<div id="ui">
|
||||
<div id="score">Score: 0</div>
|
||||
<div id="gameOver" class="hidden">
|
||||
<h1>Heart Stopped</h1>
|
||||
<p>Score: <span id="finalScore">0</span></p>
|
||||
<button id="restartBtn">Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
325
script.js
Normal file
325
script.js
Normal file
|
|
@ -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();
|
||||
90
style.css
Normal file
90
style.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in a new issue