Created
July 13, 2025 08:56
-
-
Save tluyben/a769fa2bb0352df648d02c8302dd6499 to your computer and use it in GitHub Desktop.
shmup sonnet 3.7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Nemesis Horizontal Shmup</title> | |
<style> | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
} | |
body { | |
background-color: #111; | |
font-family: 'Arial', sans-serif; | |
overflow: hidden; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
height: 100vh; | |
color: white; | |
} | |
#game-container { | |
position: relative; | |
width: 800px; | |
height: 450px; | |
max-width: 100%; | |
max-height: 80vh; | |
background-color: #000; | |
overflow: hidden; | |
} | |
canvas { | |
display: block; | |
background-color: #000; | |
} | |
#start-screen, #game-over-screen { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
align-items: center; | |
background-color: rgba(0, 0, 0, 0.8); | |
z-index: 10; | |
} | |
#game-over-screen { | |
display: none; | |
} | |
h1 { | |
font-size: 3rem; | |
color: #ff3860; | |
text-shadow: 0 0 10px #ff3860; | |
margin-bottom: 1rem; | |
} | |
.btn { | |
padding: 12px 24px; | |
margin-top: 20px; | |
background-color: #ff3860; | |
color: white; | |
border: none; | |
border-radius: 5px; | |
font-size: 1.2rem; | |
cursor: pointer; | |
transition: all 0.3s; | |
} | |
.btn:hover { | |
background-color: #ff1443; | |
transform: scale(1.05); | |
} | |
#score-container { | |
position: absolute; | |
top: 10px; | |
left: 10px; | |
font-size: 1.2rem; | |
z-index: 5; | |
} | |
#lives-container { | |
position: absolute; | |
top: 10px; | |
right: 10px; | |
font-size: 1.2rem; | |
z-index: 5; | |
} | |
#controls-info { | |
margin-top: 20px; | |
font-size: 1rem; | |
text-align: center; | |
color: #ccc; | |
} | |
@media (max-width: 600px) { | |
h1 { | |
font-size: 2rem; | |
} | |
.btn { | |
font-size: 1rem; | |
padding: 10px 20px; | |
} | |
#score-container, #lives-container { | |
font-size: 1rem; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<div id="game-container"> | |
<canvas id="game-canvas"></canvas> | |
<div id="start-screen"> | |
<h1>NEMESIS</h1> | |
<p>Navigate through waves of enemies and survive!</p> | |
<button id="start-btn" class="btn">START GAME</button> | |
<div id="controls-info"> | |
<p>Use WASD or Arrow Keys to move</p> | |
<p>Press SPACE to shoot</p> | |
<p>Press P to pause</p> | |
</div> | |
</div> | |
<div id="game-over-screen"> | |
<h1>GAME OVER</h1> | |
<p>Your final score: <span id="final-score">0</span></p> | |
<button id="restart-btn" class="btn">PLAY AGAIN</button> | |
</div> | |
<div id="score-container">SCORE: <span id="score">0</span></div> | |
<div id="lives-container">LIVES: <span id="lives">3</span></div> | |
</div> | |
<script> | |
// Game variables | |
let canvas, ctx; | |
let player; | |
let bullets = []; | |
let enemies = []; | |
let explosions = []; | |
let score = 0; | |
let lives = 3; | |
let gameRunning = false; | |
let lastEnemySpawn = 0; | |
let enemySpawnInterval = 1500; | |
let lastTimestamp = 0; | |
let keys = {}; | |
let gameWidth, gameHeight; | |
let enemyTypes = []; | |
let backgroundStars = []; | |
// Audio contexts and sounds | |
let audioContext; | |
let shootSound, explosionSound, hitSound, gameOverSound; | |
// Game initialization | |
function initGame() { | |
canvas = document.getElementById('game-canvas'); | |
ctx = canvas.getContext('2d'); | |
resizeCanvas(); | |
window.addEventListener('resize', resizeCanvas); | |
document.getElementById('start-btn').addEventListener('click', startGame); | |
document.getElementById('restart-btn').addEventListener('click', restartGame); | |
window.addEventListener('keydown', (e) => { | |
keys[e.key] = true; | |
if (e.key === 'p' && gameRunning) togglePause(); | |
if (e.key === ' ' || e.key === 'Space') { | |
if (gameRunning) { | |
createBullet(); | |
} | |
} | |
}); | |
window.addEventListener('keyup', (e) => { | |
keys[e.key] = false; | |
}); | |
// Touch controls for mobile | |
setupTouchControls(); | |
// Initialize audio | |
setupAudio(); | |
// Create enemy types | |
createEnemyTypes(); | |
// Create stars for background | |
createStars(); | |
} | |
function resizeCanvas() { | |
const container = document.getElementById('game-container'); | |
const containerWidth = container.clientWidth; | |
const containerHeight = container.clientHeight; | |
canvas.width = containerWidth; | |
canvas.height = containerHeight; | |
gameWidth = canvas.width; | |
gameHeight = canvas.height; | |
// If player exists, adjust position after resize | |
if (player) { | |
player.y = Math.min(player.y, gameHeight - player.height); | |
} | |
} | |
function setupTouchControls() { | |
// Add touch areas for mobile controls | |
let touchStartY = 0; | |
let touchStartX = 0; | |
canvas.addEventListener('touchstart', (e) => { | |
e.preventDefault(); | |
const touch = e.touches[0]; | |
touchStartX = touch.clientX; | |
touchStartY = touch.clientY; | |
// Shoot on tap | |
if (gameRunning) { | |
createBullet(); | |
} | |
}); | |
canvas.addEventListener('touchmove', (e) => { | |
if (gameRunning && player) { | |
e.preventDefault(); | |
const touch = e.touches[0]; | |
// Calculate how far the finger has moved | |
const diffX = touch.clientX - touchStartX; | |
const diffY = touch.clientY - touchStartY; | |
// Move the player based on finger movement | |
player.x += diffX * 0.5; | |
player.y += diffY * 0.5; | |
// Keep player within bounds | |
player.x = Math.max(0, Math.min(player.x, gameWidth - player.width)); | |
player.y = Math.max(0, Math.min(player.y, gameHeight - player.height)); | |
// Update touch start position | |
touchStartX = touch.clientX; | |
touchStartY = touch.clientY; | |
} | |
}); | |
} | |
function setupAudio() { | |
try { | |
audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
// Create sound functions | |
shootSound = () => { | |
const oscillator = audioContext.createOscillator(); | |
const gainNode = audioContext.createGain(); | |
oscillator.type = 'square'; | |
oscillator.frequency.setValueAtTime(440, audioContext.currentTime); | |
oscillator.frequency.exponentialRampToValueAtTime(110, audioContext.currentTime + 0.1); | |
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); | |
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1); | |
oscillator.connect(gainNode); | |
gainNode.connect(audioContext.destination); | |
oscillator.start(); | |
oscillator.stop(audioContext.currentTime + 0.1); | |
}; | |
explosionSound = () => { | |
const noise = audioContext.createBufferSource(); | |
const buffer = audioContext.createBuffer(1, audioContext.sampleRate * 0.2, audioContext.sampleRate); | |
const data = buffer.getChannelData(0); | |
for (let i = 0; i < buffer.length; i++) { | |
data[i] = Math.random() * 2 - 1; | |
} | |
const gainNode = audioContext.createGain(); | |
gainNode.gain.setValueAtTime(0.4, audioContext.currentTime); | |
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2); | |
noise.buffer = buffer; | |
noise.connect(gainNode); | |
gainNode.connect(audioContext.destination); | |
noise.start(); | |
}; | |
hitSound = () => { | |
const oscillator = audioContext.createOscillator(); | |
const gainNode = audioContext.createGain(); | |
oscillator.type = 'sine'; | |
oscillator.frequency.setValueAtTime(220, audioContext.currentTime); | |
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); | |
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3); | |
oscillator.connect(gainNode); | |
gainNode.connect(audioContext.destination); | |
oscillator.start(); | |
oscillator.stop(audioContext.currentTime + 0.3); | |
}; | |
gameOverSound = () => { | |
const oscillator = audioContext.createOscillator(); | |
const gainNode = audioContext.createGain(); | |
oscillator.type = 'sawtooth'; | |
oscillator.frequency.setValueAtTime(110, audioContext.currentTime); | |
oscillator.frequency.exponentialRampToValueAtTime(55, audioContext.currentTime + 1); | |
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); | |
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 1); | |
oscillator.connect(gainNode); | |
gainNode.connect(audioContext.destination); | |
oscillator.start(); | |
oscillator.stop(audioContext.currentTime + 1); | |
}; | |
} catch (e) { | |
console.log('Web Audio API not supported:', e); | |
// Create empty functions if audio is not supported | |
shootSound = explosionSound = hitSound = gameOverSound = () => {}; | |
} | |
} | |
function createEnemyTypes() { | |
// Different enemy types with their own properties | |
enemyTypes = [ | |
{ | |
name: 'Scout', | |
color: '#ff3333', | |
width: 30, | |
height: 20, | |
speed: 3, | |
health: 1, | |
score: 10, | |
render: function(ctx, x, y) { | |
ctx.fillStyle = this.color; | |
ctx.beginPath(); | |
ctx.moveTo(x, y + this.height/2); | |
ctx.lineTo(x + this.width, y); | |
ctx.lineTo(x + this.width, y + this.height); | |
ctx.closePath(); | |
ctx.fill(); | |
// Engine glow | |
ctx.fillStyle = '#ffaa33'; | |
ctx.beginPath(); | |
ctx.arc(x + 5, y + this.height/2, 3, 0, Math.PI * 2); | |
ctx.fill(); | |
} | |
}, | |
{ | |
name: 'Bomber', | |
color: '#3366ff', | |
width: 40, | |
height: 25, | |
speed: 2, | |
health: 2, | |
score: 20, | |
render: function(ctx, x, y) { | |
// Main body | |
ctx.fillStyle = this.color; | |
ctx.fillRect(x, y, this.width, this.height); | |
// Wing details | |
ctx.fillStyle = '#6699ff'; | |
ctx.fillRect(x + 10, y - 5, 5, 5); | |
ctx.fillRect(x + 10, y + this.height, 5, 5); | |
// Engine glow | |
ctx.fillStyle = '#ff9933'; | |
ctx.beginPath(); | |
ctx.arc(x + 5, y + this.height/2, 4, 0, Math.PI * 2); | |
ctx.fill(); | |
} | |
}, | |
{ | |
name: 'Cruiser', | |
color: '#66cc33', | |
width: 50, | |
height: 30, | |
speed: 1.5, | |
health: 3, | |
score: 30, | |
render: function(ctx, x, y) { | |
// Main body | |
ctx.fillStyle = this.color; | |
ctx.beginPath(); | |
ctx.moveTo(x, y + this.height/2); | |
ctx.lineTo(x + 15, y); | |
ctx.lineTo(x + this.width, y + 5); | |
ctx.lineTo(x + this.width, y + this.height - 5); | |
ctx.lineTo(x + 15, y + this.height); | |
ctx.closePath(); | |
ctx.fill(); | |
// Details | |
ctx.fillStyle = '#99ff66'; | |
ctx.fillRect(x + 20, y + 10, 20, 10); | |
// Engine glow | |
ctx.fillStyle = '#ffcc33'; | |
ctx.beginPath(); | |
ctx.arc(x + 5, y + this.height/2, 5, 0, Math.PI * 2); | |
ctx.fill(); | |
} | |
} | |
]; | |
} | |
function createStars() { | |
const starCount = 100; | |
for (let i = 0; i < starCount; i++) { | |
backgroundStars.push({ | |
x: Math.random() * gameWidth, | |
y: Math.random() * gameHeight, | |
size: Math.random() * 2 + 0.5, | |
speed: Math.random() * 2 + 1 | |
}); | |
} | |
} | |
function startGame() { | |
if (audioContext && audioContext.state === 'suspended') { | |
audioContext.resume(); | |
} | |
document.getElementById('start-screen').style.display = 'none'; | |
document.getElementById('game-over-screen').style.display = 'none'; | |
// Reset game state | |
score = 0; | |
lives = 3; | |
bullets = []; | |
enemies = []; | |
explosions = []; | |
lastEnemySpawn = 0; | |
// Update UI | |
document.getElementById('score').textContent = score; | |
document.getElementById('lives').textContent = lives; | |
// Create player | |
player = { | |
x: 100, | |
y: gameHeight / 2 - 20, | |
width: 40, | |
height: 25, | |
speed: 5, | |
shootCooldown: 0, | |
invulnerable: 0, | |
render: function(ctx) { | |
// Don't render if invulnerable and should be blinking | |
if (this.invulnerable > 0 && Math.floor(this.invulnerable / 5) % 2 === 0) { | |
return; | |
} | |
// Main ship body | |
ctx.fillStyle = '#33ccff'; | |
ctx.beginPath(); | |
ctx.moveTo(this.x + this.width, this.y + this.height / 2); | |
ctx.lineTo(this.x + this.width - 10, this.y); | |
ctx.lineTo(this.x, this.y + 5); | |
ctx.lineTo(this.x, this.y + this.height - 5); | |
ctx.lineTo(this.x + this.width - 10, this.y + this.height); | |
ctx.closePath(); | |
ctx.fill(); | |
// Cockpit | |
ctx.fillStyle = '#ffffff'; | |
ctx.beginPath(); | |
ctx.arc(this.x + this.width - 15, this.y + this.height / 2, 5, 0, Math.PI * 2); | |
ctx.fill(); | |
// Engine glow | |
ctx.fillStyle = '#ff9933'; | |
ctx.beginPath(); | |
ctx.arc(this.x + 5, this.y + this.height / 2, 3, 0, Math.PI * 2); | |
ctx.fill(); | |
// Wing details | |
ctx.fillStyle = '#3399cc'; | |
ctx.fillRect(this.x + 15, this.y - 3, 10, 3); | |
ctx.fillRect(this.x + 15, this.y + this.height, 10, 3); | |
} | |
}; | |
gameRunning = true; | |
lastTimestamp = performance.now(); | |
requestAnimationFrame(gameLoop); | |
} | |
function restartGame() { | |
startGame(); | |
} | |
function togglePause() { | |
gameRunning = !gameRunning; | |
if (gameRunning) { | |
lastTimestamp = performance.now(); | |
requestAnimationFrame(gameLoop); | |
} | |
} | |
function createBullet() { | |
if (player.shootCooldown <= 0) { | |
bullets.push({ | |
x: player.x + player.width, | |
y: player.y + player.height / 2 - 2, | |
width: 15, | |
height: 4, | |
speed: 10 | |
}); | |
player.shootCooldown = 10; // Cooldown in frames | |
// Play shoot sound | |
shootSound(); | |
} | |
} | |
function spawnEnemy(timestamp) { | |
if (timestamp - lastEnemySpawn > enemySpawnInterval) { | |
// Choose a random enemy type | |
const enemyType = enemyTypes[Math.floor(Math.random() * enemyTypes.length)]; | |
// Create enemy | |
enemies.push({ | |
x: gameWidth, | |
y: Math.random() * (gameHeight - enemyType.height), | |
width: enemyType.width, | |
height: enemyType.height, | |
speed: enemyType.speed, | |
health: enemyType.health, | |
score: enemyType.score, | |
type: enemyType | |
}); | |
// Decrease spawn interval as game progresses (make it more challenging) | |
enemySpawnInterval = Math.max(500, 1500 - score / 20); | |
lastEnemySpawn = timestamp; | |
} | |
} | |
function createExplosion(x, y, size) { | |
explosions.push({ | |
x: x, | |
y: y, | |
size: size, | |
lifetime: 20, // Frames the explosion will last | |
currentFrame: 0 | |
}); | |
} | |
function updatePlayer(deltaTime) { | |
// Movement controls | |
if ((keys['ArrowUp'] || keys['w'] || keys['W']) && player.y > 0) { | |
player.y -= player.speed * (deltaTime / 16.67); | |
} | |
if ((keys['ArrowDown'] || keys['s'] || keys['S']) && player.y < gameHeight - player.height) { | |
player.y += player.speed * (deltaTime / 16.67); | |
} | |
if ((keys['ArrowLeft'] || keys['a'] || keys['A']) && player.x > 0) { | |
player.x -= player.speed * (deltaTime / 16.67); | |
} | |
if ((keys['ArrowRight'] || keys['d'] || keys['D']) && player.x < gameWidth - player.width) { | |
player.x += player.speed * (deltaTime / 16.67); | |
} | |
// Shooting cooldown | |
if (player.shootCooldown > 0) { | |
player.shootCooldown -= deltaTime / 16.67; | |
} | |
// Auto-shooting (for convenience) | |
if ((keys[' '] || keys['Space']) && player.shootCooldown <= 0) { | |
createBullet(); | |
} | |
// Invulnerability frames | |
if (player.invulnerable > 0) { | |
player.invulnerable -= 1; | |
} | |
} | |
function updateBullets() { | |
for (let i = bullets.length - 1; i >= 0; i--) { | |
bullets[i].x += bullets[i].speed; | |
// Remove bullets that go off screen | |
if (bullets[i].x > gameWidth) { | |
bullets.splice(i, 1); | |
} | |
} | |
} | |
function updateEnemies(deltaTime) { | |
for (let i = enemies.length - 1; i >= 0; i--) { | |
enemies[i].x -= enemies[i].speed * (deltaTime / 16.67); | |
// Remove enemies that go off screen | |
if (enemies[i].x + enemies[i].width < 0) { | |
enemies.splice(i, 1); | |
} | |
} | |
} | |
function updateExplosions() { | |
for (let i = explosions.length - 1; i >= 0; i--) { | |
explosions[i].currentFrame++; | |
// Remove explosions that have completed their animation | |
if (explosions[i].currentFrame >= explosions[i].lifetime) { | |
explosions.splice(i, 1); | |
} | |
} | |
} | |
function updateStars(deltaTime) { | |
for (let star of backgroundStars) { | |
star.x -= star.speed * (deltaTime / 16.67); | |
// Wrap stars around when they go off screen | |
if (star.x < 0) { | |
star.x = gameWidth; | |
star.y = Math.random() * gameHeight; | |
} | |
} | |
} | |
function checkCollisions() { | |
// Check bullet-enemy collisions | |
for (let i = bullets.length - 1; i >= 0; i--) { | |
const bullet = bullets[i]; | |
for (let j = enemies.length - 1; j >= 0; j--) { | |
const enemy = enemies[j]; | |
// Simple collision detection (bounding box) | |
if ( | |
bullet.x < enemy.x + enemy.width && | |
bullet.x + bullet.width > enemy.x && | |
bullet.y < enemy.y + enemy.height && | |
bullet.y + bullet.height > enemy.y | |
) { | |
// Remove bullet | |
bullets.splice(i, 1); | |
// Reduce enemy health | |
enemy.health--; | |
// If enemy is destroyed | |
if (enemy.health <= 0) { | |
// Add score | |
score += enemy.score; | |
document.getElementById('score').textContent = score; | |
// Create explosion | |
createExplosion(enemy.x + enemy.width/2, enemy.y + enemy.height/2, enemy.width); | |
// Play explosion sound | |
explosionSound(); | |
// Remove enemy | |
enemies.splice(j, 1); | |
} else { | |
// Play hit sound | |
hitSound(); | |
} | |
// Break out of inner loop since bullet is gone | |
break; | |
} | |
} | |
} | |
// Check player-enemy collisions (only if player is not invulnerable) | |
if (player.invulnerable <= 0) { | |
for (let i = enemies.length - 1; i >= 0; i--) { | |
const enemy = enemies[i]; | |
if ( | |
player.x < enemy.x + enemy.width && | |
player.x + player.width > enemy.x && | |
player.y < enemy.y + enemy.height && | |
player.y + player.height > enemy.y | |
) { | |
// Player got hit | |
lives--; | |
document.getElementById('lives').textContent = lives; | |
// Create explosion | |
createExplosion(enemy.x + enemy.width/2, enemy.y + enemy.height/2, enemy.width); | |
// Remove enemy | |
enemies.splice(i, 1); | |
// Play explosion sound | |
explosionSound(); | |
// Make player invulnerable for a short time | |
player.invulnerable = 60; // 60 frames = ~1 second | |
// Check if game over | |
if (lives <= 0) { | |
gameOver(); | |
} | |
break; | |
} | |
} | |
} | |
} | |
function gameOver() { | |
gameRunning = false; | |
document.getElementById('final-score').textContent = score; | |
document.getElementById('game-over-screen').style.display = 'flex'; | |
// Play game over sound | |
gameOverSound(); | |
} | |
function renderGame() { | |
// Clear canvas | |
ctx.clearRect(0, 0, gameWidth, gameHeight); | |
// Draw stars | |
for (const star of backgroundStars) { | |
ctx.fillStyle = `rgba(255, 255, 255, ${star.size / 2.5})`; | |
ctx.fillRect(star.x, star.y, star.size, star.size); | |
} | |
// Draw player | |
if (player) { | |
player.render(ctx); | |
} | |
// Draw bullets | |
ctx.fillStyle = '#ffdd33'; | |
for (const bullet of bullets) { | |
ctx.fillRect(bullet.x, bullet.y, bullet.width, bullet.height); | |
} | |
// Draw enemies | |
for (const enemy of enemies) { | |
enemy.type.render(ctx, enemy.x, enemy.y); | |
} | |
// Draw explosions | |
for (const explosion of explosions) { | |
const progress = explosion.currentFrame / explosion.lifetime; | |
const radius = explosion.size * (1 - progress); | |
// Create radial gradient for explosion | |
const gradient = ctx.createRadialGradient( | |
explosion.x, explosion.y, 0, | |
explosion.x, explosion.y, radius | |
); | |
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); | |
gradient.addColorStop(0.4, 'rgba(255, 200, 50, 0.8)'); | |
gradient.addColorStop(0.6, 'rgba(255, 100, 50, 0.6)'); | |
gradient.addColorStop(1, 'rgba(255, 50, 50, 0)'); | |
ctx.fillStyle = gradient; | |
ctx.beginPath(); | |
ctx.arc(explosion.x, explosion.y, radius, 0, Math.PI * 2); | |
ctx.fill(); | |
} | |
} | |
function gameLoop(timestamp) { | |
if (!gameRunning) return; | |
const deltaTime = timestamp - lastTimestamp; | |
lastTimestamp = timestamp; | |
// Spawn enemies | |
spawnEnemy(timestamp); | |
// Update game objects | |
updatePlayer(deltaTime); | |
updateBullets(); | |
updateEnemies(deltaTime); | |
updateExplosions(); | |
updateStars(deltaTime); | |
// Check collisions | |
checkCollisions(); | |
// Render game | |
renderGame(); | |
// Continue game loop | |
requestAnimationFrame(gameLoop); | |
} | |
// Initialize the game when the page loads | |
window.addEventListener('load', initGame); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment