|
|
<!DOCTYPE html>
|
|
|
<html lang="en">
|
|
|
<head>
|
|
|
<meta charset="UTF-8">
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
<title>Nova Blossom</title>
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.7.77/Tone.js"></script>
|
|
|
<style>
|
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
|
|
|
|
|
|
body {
|
|
|
margin: 0;
|
|
|
padding: 0;
|
|
|
display: flex;
|
|
|
justify-content: center;
|
|
|
align-items: center;
|
|
|
height: 100vh;
|
|
|
background-color: #0c0a1a;
|
|
|
font-family: 'Inter', sans-serif;
|
|
|
color: #e0e0ff;
|
|
|
overflow: hidden;
|
|
|
}
|
|
|
|
|
|
#game-container {
|
|
|
display: flex;
|
|
|
justify-content: center;
|
|
|
align-items: center;
|
|
|
width: 100%;
|
|
|
height: 100%;
|
|
|
}
|
|
|
|
|
|
#ui-wrapper {
|
|
|
display: flex;
|
|
|
justify-content: center;
|
|
|
max-width: 1000px;
|
|
|
width: 100%;
|
|
|
aspect-ratio: 4 / 3;
|
|
|
max-height: 95vh;
|
|
|
position: relative;
|
|
|
}
|
|
|
|
|
|
#game-canvas {
|
|
|
background-color: #121020;
|
|
|
border-left: 1px solid #4a4a8a;
|
|
|
border-right: 1px solid #4a4a8a;
|
|
|
width: 60%;
|
|
|
height: 100%;
|
|
|
}
|
|
|
|
|
|
.ui-panel {
|
|
|
width: 20%;
|
|
|
padding: 20px;
|
|
|
box-sizing: border-box;
|
|
|
background-color: #1a182f;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
justify-content: space-between;
|
|
|
}
|
|
|
|
|
|
.ui-panel h2 {
|
|
|
color: #ff79c6;
|
|
|
text-align: center;
|
|
|
margin-top: 0;
|
|
|
border-bottom: 1px solid #4a4a8a;
|
|
|
padding-bottom: 10px;
|
|
|
}
|
|
|
|
|
|
.info-box {
|
|
|
margin-bottom: 20px;
|
|
|
}
|
|
|
|
|
|
.info-box p {
|
|
|
margin: 5px 0;
|
|
|
}
|
|
|
|
|
|
.info-box .label {
|
|
|
color: #8be9fd;
|
|
|
font-size: 0.9em;
|
|
|
}
|
|
|
|
|
|
.info-box .value {
|
|
|
color: #f1fa8c;
|
|
|
font-size: 1.5em;
|
|
|
font-weight: bold;
|
|
|
text-shadow: 0 0 5px #f1fa8c;
|
|
|
}
|
|
|
|
|
|
#blossom-meter-container {
|
|
|
width: 100%;
|
|
|
height: 25px;
|
|
|
background-color: #282a36;
|
|
|
border: 1px solid #4a4a8a;
|
|
|
border-radius: 5px;
|
|
|
overflow: hidden;
|
|
|
margin-top: auto;
|
|
|
}
|
|
|
|
|
|
#blossom-meter-fill {
|
|
|
width: 0%;
|
|
|
height: 100%;
|
|
|
background: linear-gradient(90deg, #ff79c6, #ff5555);
|
|
|
transition: width 0.1s linear;
|
|
|
}
|
|
|
|
|
|
.game-overlay {
|
|
|
position: absolute;
|
|
|
top: 0;
|
|
|
left: 0;
|
|
|
width: 100%;
|
|
|
height: 100%;
|
|
|
background-color: rgba(12, 10, 26, 0.8);
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
justify-content: center;
|
|
|
align-items: center;
|
|
|
text-align: center;
|
|
|
z-index: 10;
|
|
|
}
|
|
|
|
|
|
.game-overlay h1 {
|
|
|
font-size: 3em;
|
|
|
color: #ff79c6;
|
|
|
margin-bottom: 10px;
|
|
|
}
|
|
|
|
|
|
.game-overlay p {
|
|
|
font-size: 1.2em;
|
|
|
max-width: 60%;
|
|
|
margin-bottom: 20px;
|
|
|
}
|
|
|
|
|
|
.game-overlay button {
|
|
|
padding: 15px 30px;
|
|
|
font-size: 1.2em;
|
|
|
background-color: #bd93f9;
|
|
|
color: #1a182f;
|
|
|
border: none;
|
|
|
border-radius: 8px;
|
|
|
cursor: pointer;
|
|
|
font-weight: bold;
|
|
|
transition: background-color 0.3s, transform 0.2s;
|
|
|
box-shadow: 0 4px 15px rgba(189, 147, 249, 0.4);
|
|
|
}
|
|
|
|
|
|
.game-overlay button:hover {
|
|
|
background-color: #ff79c6;
|
|
|
transform: scale(1.05);
|
|
|
}
|
|
|
|
|
|
.controls {
|
|
|
margin-top: 30px;
|
|
|
background: rgba(40, 42, 54, 0.7);
|
|
|
padding: 15px;
|
|
|
border-radius: 10px;
|
|
|
}
|
|
|
|
|
|
.controls p {
|
|
|
margin: 5px 0;
|
|
|
font-size: 1em;
|
|
|
}
|
|
|
|
|
|
.controls strong {
|
|
|
color: #8be9fd;
|
|
|
}
|
|
|
</style>
|
|
|
</head>
|
|
|
<body>
|
|
|
|
|
|
<div id="game-container">
|
|
|
<div id="ui-wrapper">
|
|
|
|
|
|
<div class="ui-panel">
|
|
|
<div>
|
|
|
<h2>STATUS</h2>
|
|
|
<div class="info-box">
|
|
|
<p class="label">LIVES</p>
|
|
|
<p class="value" id="lives-display">3</p>
|
|
|
</div>
|
|
|
<div class="info-box">
|
|
|
<p class="label">BLOSSOM BURSTS</p>
|
|
|
<p class="value" id="bombs-display">1</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div>
|
|
|
<p class="label">BLOSSOM METER</p>
|
|
|
<div id="blossom-meter-container">
|
|
|
<div id="blossom-meter-fill"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
|
|
|
<canvas id="game-canvas"></canvas>
|
|
|
|
|
|
|
|
|
<div class="ui-panel">
|
|
|
<div>
|
|
|
<h2>SCORE</h2>
|
|
|
<div class="info-box">
|
|
|
<p class="label">HIGH SCORE</p>
|
|
|
<p class="value" id="highscore-display">0</p>
|
|
|
</div>
|
|
|
<div class="info-box">
|
|
|
<p class="label">CURRENT SCORE</p>
|
|
|
<p class="value" id="score-display">0</p>
|
|
|
</div>
|
|
|
<div class="info-box">
|
|
|
<p class="label">GRAZE</p>
|
|
|
<p class="value" id="graze-display">0</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
|
|
|
<div class="game-overlay" id="start-screen">
|
|
|
<h1>Nova Blossom</h1>
|
|
|
<p>Weave through celestial curtains of bullets and turn danger into power. Graze enemy fire to charge your Blossom Burst and unleash your true potential.</p>
|
|
|
<button id="start-button">Start Game</button>
|
|
|
<div class="controls">
|
|
|
<p><strong>Arrow Keys:</strong> Move</p>
|
|
|
<p><strong>Shift:</strong> Focus (Slow Movement / Laser Shot)</p>
|
|
|
<p><strong>X:</strong> Use Blossom Burst (Bomb)</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="game-overlay" id="game-over-screen" style="display: none;">
|
|
|
<h1>Game Over</h1>
|
|
|
<p id="final-score">Your score: 0</p>
|
|
|
<button id="restart-button">Play Again</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<script>
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
const canvas = document.getElementById('game-canvas');
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
|
const scoreDisplay = document.getElementById('score-display');
|
|
|
const highscoreDisplay = document.getElementById('highscore-display');
|
|
|
const livesDisplay = document.getElementById('lives-display');
|
|
|
const bombsDisplay = document.getElementById('bombs-display');
|
|
|
const grazeDisplay = document.getElementById('graze-display');
|
|
|
const blossomMeterFill = document.getElementById('blossom-meter-fill');
|
|
|
const startScreen = document.getElementById('start-screen');
|
|
|
const gameOverScreen = document.getElementById('game-over-screen');
|
|
|
const finalScoreDisplay = document.getElementById('final-score');
|
|
|
const startButton = document.getElementById('start-button');
|
|
|
const restartButton = document.getElementById('restart-button');
|
|
|
|
|
|
let canvasWidth, canvasHeight;
|
|
|
let player, keys = {}, playerBullets = [], enemyBullets = [], enemies = [], particles = [], scoreItems = [];
|
|
|
let score = 0, highScore = localStorage.getItem('novaBlossomHighScore') || 0;
|
|
|
let grazeCount = 0;
|
|
|
let lives = 3;
|
|
|
let bombs = 3;
|
|
|
let blossomMeter = 0;
|
|
|
const blossomMeterMax = 500;
|
|
|
let frameCount = 0;
|
|
|
let isGameOver = true;
|
|
|
let audioInitialized = false;
|
|
|
|
|
|
|
|
|
let synths = {};
|
|
|
function initAudio() {
|
|
|
if (audioInitialized) return;
|
|
|
synths.playerShoot = new Tone.PolySynth(Tone.Synth, {
|
|
|
oscillator: { type: "triangle" },
|
|
|
envelope: { attack: 0.005, decay: 0.1, sustain: 0, release: 0.1 },
|
|
|
volume: -20
|
|
|
}).toDestination();
|
|
|
synths.playerLaser = new Tone.NoiseSynth({
|
|
|
noise: { type: 'pink' },
|
|
|
envelope: { attack: 0.01, decay: 0.05, sustain: 0, release: 0.1 },
|
|
|
volume: -15
|
|
|
}).toDestination();
|
|
|
synths.graze = new Tone.Synth({
|
|
|
oscillator: { type: "sine" },
|
|
|
envelope: { attack: 0.001, decay: 0.1, sustain: 0, release: 0.1 },
|
|
|
volume: -10
|
|
|
}).toDestination();
|
|
|
synths.enemyHit = new Tone.Synth({
|
|
|
oscillator: { type: "square" },
|
|
|
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.2 },
|
|
|
volume: -15
|
|
|
}).toDestination();
|
|
|
synths.itemCollect = new Tone.Synth({
|
|
|
oscillator: { type: "sine" },
|
|
|
envelope: { attack: 0.001, decay: 0.2, sustain: 0, release: 0.2 },
|
|
|
volume: -15
|
|
|
}).toDestination();
|
|
|
synths.playerHit = new Tone.NoiseSynth({
|
|
|
noise: { type: 'white' },
|
|
|
envelope: { attack: 0.01, decay: 0.3, sustain: 0, release: 0.3 },
|
|
|
volume: -5
|
|
|
}).toDestination();
|
|
|
synths.blossomBurst = new Tone.MembraneSynth({
|
|
|
pitchDecay: 0.01,
|
|
|
octaves: 10,
|
|
|
oscillator: { type: "sine" },
|
|
|
envelope: { attack: 0.001, decay: 0.8, sustain: 0.01, release: 1.4, attackCurve: "exponential" },
|
|
|
volume: -2
|
|
|
}).toDestination();
|
|
|
audioInitialized = true;
|
|
|
}
|
|
|
|
|
|
function resizeCanvas() {
|
|
|
const wrapper = document.getElementById('ui-wrapper');
|
|
|
canvas.width = canvas.offsetWidth;
|
|
|
canvas.height = canvas.offsetHeight;
|
|
|
canvasWidth = canvas.width;
|
|
|
canvasHeight = canvas.height;
|
|
|
}
|
|
|
|
|
|
window.addEventListener('resize', resizeCanvas);
|
|
|
|
|
|
function setupGame() {
|
|
|
resizeCanvas();
|
|
|
isGameOver = false;
|
|
|
score = 0;
|
|
|
grazeCount = 0;
|
|
|
lives = 3;
|
|
|
bombs = 1;
|
|
|
blossomMeter = 0;
|
|
|
frameCount = 0;
|
|
|
player = new Player();
|
|
|
playerBullets = [];
|
|
|
enemyBullets = [];
|
|
|
enemies = [];
|
|
|
particles = [];
|
|
|
scoreItems = [];
|
|
|
updateUI();
|
|
|
gameOverScreen.style.display = 'none';
|
|
|
gameLoop();
|
|
|
}
|
|
|
|
|
|
|
|
|
startButton.addEventListener('click', () => {
|
|
|
Tone.start();
|
|
|
initAudio();
|
|
|
startScreen.style.display = 'none';
|
|
|
setupGame();
|
|
|
});
|
|
|
|
|
|
restartButton.addEventListener('click', () => {
|
|
|
setupGame();
|
|
|
});
|
|
|
|
|
|
window.addEventListener('keydown', e => keys[e.code] = true);
|
|
|
window.addEventListener('keyup', e => {
|
|
|
keys[e.code] = false;
|
|
|
|
|
|
if (e.code === 'KeyX' && !isGameOver) {
|
|
|
player.useBomb();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
|
|
|
class Player {
|
|
|
constructor() {
|
|
|
this.x = canvasWidth / 2;
|
|
|
this.y = canvasHeight - 50;
|
|
|
this.width = 30;
|
|
|
this.height = 40;
|
|
|
this.speed = 5;
|
|
|
this.focusedSpeed = 2.5;
|
|
|
this.hitboxRadius = 3;
|
|
|
this.grazeRadius = 30;
|
|
|
this.shootCooldown = 0;
|
|
|
this.invincible = false;
|
|
|
this.invincibilityTimer = 0;
|
|
|
}
|
|
|
|
|
|
update() {
|
|
|
const currentSpeed = keys['ShiftLeft'] || keys['ShiftRight'] ? this.focusedSpeed : this.speed;
|
|
|
|
|
|
if (keys['ArrowUp']) this.y -= currentSpeed;
|
|
|
if (keys['ArrowDown']) this.y += currentSpeed;
|
|
|
if (keys['ArrowLeft']) this.x -= currentSpeed;
|
|
|
if (keys['ArrowRight']) this.x += currentSpeed;
|
|
|
|
|
|
this.x = Math.max(this.width / 2, Math.min(canvasWidth - this.width / 2, this.x));
|
|
|
this.y = Math.max(this.height / 2, Math.min(canvasHeight - this.height / 2, this.y));
|
|
|
|
|
|
if (this.shootCooldown > 0) this.shootCooldown--;
|
|
|
this.shoot();
|
|
|
|
|
|
if (this.invincibilityTimer > 0) {
|
|
|
this.invincibilityTimer--;
|
|
|
if (this.invincibilityTimer <= 0) {
|
|
|
this.invincible = false;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
draw() {
|
|
|
ctx.save();
|
|
|
|
|
|
if (this.invincible) {
|
|
|
ctx.globalAlpha = Math.abs(Math.sin(this.invincibilityTimer * 0.2));
|
|
|
}
|
|
|
|
|
|
|
|
|
ctx.fillStyle = '#bd93f9';
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(this.x, this.y - this.height / 2);
|
|
|
ctx.lineTo(this.x - this.width / 2, this.y + this.height / 2);
|
|
|
ctx.lineTo(this.x + this.width / 2, this.y + this.height / 2);
|
|
|
ctx.closePath();
|
|
|
ctx.fill();
|
|
|
ctx.strokeStyle = '#f1fa8c';
|
|
|
ctx.lineWidth = 2;
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
|
ctx.fillStyle = '#ff79c6';
|
|
|
ctx.fillRect(this.x - 5, this.y + this.height/2, 10, 5);
|
|
|
|
|
|
|
|
|
if (keys['ShiftLeft'] || keys['ShiftRight']) {
|
|
|
ctx.fillStyle = 'rgba(255, 121, 198, 0.3)';
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(this.x, this.y, this.grazeRadius, 0, Math.PI * 2);
|
|
|
ctx.fill();
|
|
|
|
|
|
ctx.fillStyle = '#f1fa8c';
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(this.x, this.y, this.hitboxRadius + 2, 0, Math.PI * 2);
|
|
|
ctx.fill();
|
|
|
}
|
|
|
|
|
|
|
|
|
ctx.fillStyle = '#ff5555';
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(this.x, this.y, this.hitboxRadius, 0, Math.PI * 2);
|
|
|
ctx.fill();
|
|
|
ctx.restore();
|
|
|
}
|
|
|
|
|
|
shoot() {
|
|
|
if (this.shootCooldown <= 0) {
|
|
|
const isFocused = keys['ShiftLeft'] || keys['ShiftRight'];
|
|
|
if (isFocused) {
|
|
|
playerBullets.push(new PlayerBullet(this.x, this.y, 0, -15, 10, '#8be9fd'));
|
|
|
playerBullets.push(new PlayerBullet(this.x - 7, this.y, 0, -15, 8, '#8be9fd'));
|
|
|
playerBullets.push(new PlayerBullet(this.x + 7, this.y, 0, -15, 8, '#8be9fd'));
|
|
|
this.shootCooldown = 4;
|
|
|
synths.playerLaser?.triggerAttackRelease("32n", Tone.now());
|
|
|
} else {
|
|
|
playerBullets.push(new PlayerBullet(this.x - 10, this.y, -0.5, -12, 5, '#50fa7b'));
|
|
|
playerBullets.push(new PlayerBullet(this.x + 10, this.y, 0.5, -12, 5, '#50fa7b'));
|
|
|
playerBullets.push(new PlayerBullet(this.x, this.y - 10, 0, -12, 5, '#50fa7b'));
|
|
|
this.shootCooldown = 6;
|
|
|
synths.playerShoot?.triggerAttackRelease(["C5", "E5"], "16n", Tone.now());
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
takeHit() {
|
|
|
if (this.invincible) return;
|
|
|
|
|
|
synths.playerHit?.triggerAttackRelease("8n", Tone.now());
|
|
|
lives--;
|
|
|
grazeCount = 0;
|
|
|
if (lives <= 0) {
|
|
|
gameOver();
|
|
|
} else {
|
|
|
this.invincible = true;
|
|
|
this.invincibilityTimer = 120;
|
|
|
for (let i = 0; i < 50; i++) {
|
|
|
particles.push(new Particle(this.x, this.y, Math.random() * 4 + 2, '#ff5555', 60));
|
|
|
}
|
|
|
}
|
|
|
updateUI();
|
|
|
}
|
|
|
|
|
|
useBomb() {
|
|
|
if (bombs > 0) {
|
|
|
synths.blossomBurst?.triggerAttackRelease("C2", "1s", Tone.now());
|
|
|
bombs--;
|
|
|
this.invincible = true;
|
|
|
this.invincibilityTimer = 180;
|
|
|
|
|
|
enemyBullets.forEach(b => {
|
|
|
scoreItems.push(new ScoreItem(b.x, b.y, 50));
|
|
|
for (let i = 0; i < 2; i++) {
|
|
|
particles.push(new Particle(b.x, b.y, Math.random() * 2 + 1, '#ff79c6', 30));
|
|
|
}
|
|
|
});
|
|
|
enemyBullets = [];
|
|
|
|
|
|
enemies.forEach(e => e.health -= 50);
|
|
|
|
|
|
|
|
|
particles.push(new Particle(this.x, this.y, canvasWidth, 'rgba(255, 121, 198, 0.5)', 30, true));
|
|
|
|
|
|
updateUI();
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
class Bullet {
|
|
|
constructor(x, y, vx, vy, radius, color) {
|
|
|
this.x = x;
|
|
|
this.y = y;
|
|
|
this.vx = vx;
|
|
|
this.vy = vy;
|
|
|
this.radius = radius;
|
|
|
this.color = color;
|
|
|
this.grazed = false;
|
|
|
}
|
|
|
|
|
|
update() {
|
|
|
this.x += this.vx;
|
|
|
this.y += this.vy;
|
|
|
}
|
|
|
|
|
|
draw() {
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = this.color;
|
|
|
ctx.fill();
|
|
|
ctx.strokeStyle = 'white';
|
|
|
ctx.lineWidth = 1;
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
class PlayerBullet extends Bullet {
|
|
|
constructor(x, y, vx, vy, damage, color) {
|
|
|
super(x, y, vx, vy, 5, color);
|
|
|
this.damage = damage;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
class Enemy {
|
|
|
constructor(x, y, health, type) {
|
|
|
this.x = x;
|
|
|
this.y = y;
|
|
|
this.health = health;
|
|
|
this.type = type;
|
|
|
this.width = 40;
|
|
|
this.height = 40;
|
|
|
this.shootTimer = Math.random() * 60;
|
|
|
}
|
|
|
|
|
|
update() {
|
|
|
this.y += 1.5;
|
|
|
this.shootTimer--;
|
|
|
if (this.shootTimer <= 0) {
|
|
|
this.shoot();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
draw() {
|
|
|
ctx.fillStyle = '#ff5555';
|
|
|
ctx.beginPath();
|
|
|
ctx.moveTo(this.x, this.y + this.height/2);
|
|
|
ctx.lineTo(this.x - this.width/2, this.y - this.height/2);
|
|
|
ctx.lineTo(this.x + this.width/2, this.y - this.height/2);
|
|
|
ctx.closePath();
|
|
|
ctx.fill();
|
|
|
ctx.strokeStyle = '#f8f8f2';
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
shoot() {
|
|
|
if (this.type === 1) {
|
|
|
const angle = Math.atan2(player.y - this.y, player.x - this.x);
|
|
|
const speed = 3;
|
|
|
enemyBullets.push(new Bullet(this.x, this.y, Math.cos(angle) * speed, Math.sin(angle) * speed, 5, '#f1fa8c'));
|
|
|
this.shootTimer = 80;
|
|
|
} else if (this.type === 2) {
|
|
|
const bulletsInSpiral = 8;
|
|
|
const speed = 2.5;
|
|
|
for (let i = 0; i < bulletsInSpiral; i++) {
|
|
|
const angle = (frameCount * 0.1 + (i / bulletsInSpiral) * Math.PI * 2);
|
|
|
enemyBullets.push(new Bullet(this.x, this.y, Math.cos(angle) * speed, Math.sin(angle) * speed, 4, '#ffb86c'));
|
|
|
}
|
|
|
this.shootTimer = 20;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
takeDamage(amount, timeOffset = 0) {
|
|
|
this.health -= amount;
|
|
|
synths.enemyHit?.triggerAttackRelease("G2", "8n", Tone.now() + timeOffset);
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
particles.push(new Particle(this.x + (Math.random() - 0.5) * this.width, this.y + (Math.random() - 0.5) * this.height, Math.random() * 2 + 1, '#8be9fd', 20));
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
class Particle {
|
|
|
constructor(x, y, radius, color, lifespan, isCircle=false) {
|
|
|
this.x = x;
|
|
|
this.y = y;
|
|
|
this.radius = isCircle ? 0 : radius;
|
|
|
this.maxRadius = radius;
|
|
|
this.color = color;
|
|
|
this.lifespan = lifespan;
|
|
|
this.isCircle = isCircle;
|
|
|
if (!isCircle) {
|
|
|
this.vx = (Math.random() - 0.5) * 5;
|
|
|
this.vy = (Math.random() - 0.5) * 5;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
update() {
|
|
|
this.lifespan--;
|
|
|
if(this.isCircle) {
|
|
|
this.radius += this.maxRadius / 30;
|
|
|
} else {
|
|
|
this.x += this.vx;
|
|
|
this.y += this.vy;
|
|
|
this.vx *= 0.95;
|
|
|
this.vy *= 0.95;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
draw() {
|
|
|
ctx.save();
|
|
|
ctx.globalAlpha = this.lifespan / 60;
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = this.color;
|
|
|
ctx.fill();
|
|
|
ctx.restore();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
class ScoreItem {
|
|
|
constructor(x, y, value) {
|
|
|
this.x = x;
|
|
|
this.y = y;
|
|
|
this.value = value;
|
|
|
this.radius = 5;
|
|
|
this.vy = 1;
|
|
|
}
|
|
|
|
|
|
update() {
|
|
|
|
|
|
const dist = Math.hypot(this.x - player.x, this.y - player.y);
|
|
|
if (dist < 100) {
|
|
|
const angle = Math.atan2(player.y - this.y, player.x - this.x);
|
|
|
this.x += Math.cos(angle) * 5;
|
|
|
this.y += Math.sin(angle) * 5;
|
|
|
} else {
|
|
|
this.y += this.vy;
|
|
|
this.vy += 0.1;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
draw() {
|
|
|
ctx.save();
|
|
|
ctx.beginPath();
|
|
|
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
|
|
|
ctx.fillStyle = '#f1fa8c';
|
|
|
ctx.fill();
|
|
|
ctx.shadowColor = '#f1fa8c';
|
|
|
ctx.shadowBlur = 10;
|
|
|
ctx.restore();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
function spawnEnemies() {
|
|
|
if (frameCount % 120 === 0) {
|
|
|
enemies.push(new Enemy(Math.random() * canvasWidth, -20, 50, 1));
|
|
|
}
|
|
|
if (frameCount > 300 && frameCount % 240 === 0) {
|
|
|
enemies.push(new Enemy(canvasWidth / 2, -20, 150, 2));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function handleCollisions() {
|
|
|
|
|
|
let enemyHitSoundOffset = 0;
|
|
|
for (let i = playerBullets.length - 1; i >= 0; i--) {
|
|
|
for (let j = enemies.length - 1; j >= 0; j--) {
|
|
|
const pb = playerBullets[i];
|
|
|
const e = enemies[j];
|
|
|
if (pb.x > e.x - e.width / 2 && pb.x < e.x + e.width / 2 &&
|
|
|
pb.y > e.y - e.height / 2 && pb.y < e.y + e.height / 2) {
|
|
|
e.takeDamage(pb.damage, enemyHitSoundOffset);
|
|
|
enemyHitSoundOffset += 0.01;
|
|
|
playerBullets.splice(i, 1);
|
|
|
|
|
|
if (e.health <= 0) {
|
|
|
score += 500;
|
|
|
for (let k = 0; k < 20; k++) particles.push(new Particle(e.x, e.y, Math.random() * 3 + 1, '#ff5555', 60));
|
|
|
for (let k = 0; k < 3; k++) scoreItems.push(new ScoreItem(e.x + (Math.random() - 0.5) * 20, e.y + (Math.random() - 0.5) * 20, 100));
|
|
|
enemies.splice(j, 1);
|
|
|
updateUI();
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (player.invincible) return;
|
|
|
|
|
|
|
|
|
let grazeSoundOffset = 0;
|
|
|
for (let i = enemyBullets.length - 1; i >= 0; i--) {
|
|
|
const b = enemyBullets[i];
|
|
|
const distHit = Math.hypot(player.x - b.x, player.y - b.y);
|
|
|
|
|
|
|
|
|
if (distHit < player.hitboxRadius + b.radius) {
|
|
|
player.takeHit();
|
|
|
enemyBullets.splice(i, 1);
|
|
|
break;
|
|
|
}
|
|
|
|
|
|
|
|
|
if (!b.grazed && distHit < player.grazeRadius + b.radius) {
|
|
|
b.grazed = true;
|
|
|
grazeCount++;
|
|
|
blossomMeter += 5;
|
|
|
if (blossomMeter >= blossomMeterMax) {
|
|
|
blossomMeter = blossomMeterMax;
|
|
|
if(bombs < 5) bombs++;
|
|
|
blossomMeter = 0;
|
|
|
}
|
|
|
synths.graze?.triggerAttackRelease("C6", "16n", Tone.now() + grazeSoundOffset);
|
|
|
grazeSoundOffset += 0.01;
|
|
|
updateUI();
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function handleScoreItems() {
|
|
|
let itemsCollected = 0;
|
|
|
let uiNeedsUpdate = false;
|
|
|
for (let i = scoreItems.length - 1; i >= 0; i--) {
|
|
|
const item = scoreItems[i];
|
|
|
const dist = Math.hypot(player.x - item.x, player.y - item.y);
|
|
|
if (dist < player.grazeRadius) {
|
|
|
score += item.value * (1 + Math.floor(grazeCount/100));
|
|
|
scoreItems.splice(i, 1);
|
|
|
synths.itemCollect?.triggerAttackRelease("A5", "16n", Tone.now() + itemsCollected * 0.02);
|
|
|
itemsCollected++;
|
|
|
uiNeedsUpdate = true;
|
|
|
}
|
|
|
}
|
|
|
if (uiNeedsUpdate) {
|
|
|
updateUI();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function updateUI() {
|
|
|
scoreDisplay.textContent = score;
|
|
|
highscoreDisplay.textContent = highScore;
|
|
|
livesDisplay.textContent = lives;
|
|
|
bombsDisplay.textContent = bombs;
|
|
|
grazeDisplay.textContent = grazeCount;
|
|
|
blossomMeterFill.style.width = `${(blossomMeter / blossomMeterMax) * 100}%`;
|
|
|
}
|
|
|
|
|
|
function gameOver() {
|
|
|
isGameOver = true;
|
|
|
if (score > highScore) {
|
|
|
highScore = score;
|
|
|
localStorage.setItem('novaBlossomHighScore', highScore);
|
|
|
}
|
|
|
finalScoreDisplay.textContent = `Your score: ${score}`;
|
|
|
gameOverScreen.style.display = 'flex';
|
|
|
}
|
|
|
|
|
|
|
|
|
function gameLoop() {
|
|
|
if (isGameOver) return;
|
|
|
|
|
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
|
|
|
|
frameCount++;
|
|
|
spawnEnemies();
|
|
|
|
|
|
|
|
|
player.update();
|
|
|
player.draw();
|
|
|
|
|
|
[particles, playerBullets, enemyBullets, enemies, scoreItems].forEach(arr => {
|
|
|
arr.forEach(item => {
|
|
|
item.update();
|
|
|
item.draw();
|
|
|
});
|
|
|
});
|
|
|
|
|
|
handleCollisions();
|
|
|
handleScoreItems();
|
|
|
|
|
|
|
|
|
playerBullets = playerBullets.filter(b => b.y > -10);
|
|
|
enemyBullets = enemyBullets.filter(b => b.x > -10 && b.x < canvasWidth + 10 && b.y > -10 && b.y < canvasHeight + 10);
|
|
|
enemies = enemies.filter(e => e.y < canvasHeight + e.height);
|
|
|
particles = particles.filter(p => p.lifespan > 0);
|
|
|
scoreItems = scoreItems.filter(item => item.y < canvasHeight + 10);
|
|
|
|
|
|
requestAnimationFrame(gameLoop);
|
|
|
}
|
|
|
});
|
|
|
</script>
|
|
|
</body>
|
|
|
</html>
|
|
|
|