danmaku / index.html
bebechien's picture
Upload index.html
7ebea6e verified
<!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">
<!-- Left UI Panel -->
<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>
<!-- Game Canvas -->
<canvas id="game-canvas"></canvas>
<!-- Right UI Panel -->
<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>
<!-- Game Overlays -->
<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;
// --- Audio Synthesis with Tone.js ---
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();
}
// --- Event Listeners ---
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;
// Blossom Burst on key press (not hold)
if (e.code === 'KeyX' && !isGameOver) {
player.useBomb();
}
});
// --- Game Classes ---
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();
// Invincibility flash
if (this.invincible) {
ctx.globalAlpha = Math.abs(Math.sin(this.invincibilityTimer * 0.2));
}
// Ship Body
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();
// Engine glow
ctx.fillStyle = '#ff79c6';
ctx.fillRect(this.x - 5, this.y + this.height/2, 10, 5);
// Focused state indicator and hitbox
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();
}
// Hitbox center
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; // Reset graze on hit
if (lives <= 0) {
gameOver();
} else {
this.invincible = true;
this.invincibilityTimer = 120; // 2 seconds of invincibility
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; // 3 seconds invincibility
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);
// Visual effect
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) { // Simple aimed shot
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) { // Spiral pattern
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() {
// Move towards player if close
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();
}
}
// --- Game Logic Functions ---
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() {
// Player bullets vs enemies
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;
// Enemy bullets vs player
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);
// Collision
if (distHit < player.hitboxRadius + b.radius) {
player.takeHit();
enemyBullets.splice(i, 1);
break;
}
// Graze
if (!b.grazed && distHit < player.grazeRadius + b.radius) {
b.grazed = true;
grazeCount++;
blossomMeter += 5;
if (blossomMeter >= blossomMeterMax) {
blossomMeter = blossomMeterMax;
if(bombs < 5) bombs++; // Max 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';
}
// --- Main Game Loop ---
function gameLoop() {
if (isGameOver) return;
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
frameCount++;
spawnEnemies();
// Update and draw entities
player.update();
player.draw();
[particles, playerBullets, enemyBullets, enemies, scoreItems].forEach(arr => {
arr.forEach(item => {
item.update();
item.draw();
});
});
handleCollisions();
handleScoreItems();
// Clean up off-screen entities
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>