Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<!-- Chosen Palette: Retro Arcade (Deep Blue, Neon Green, Red, Yellow, White, Gold) --> | |
<!-- Application Structure Plan: A single-page application with a clear state-driven flow: MENU -> DEMO -> PLAYING -> PAUSED -> GAME_OVER -> HIGH_SCORE_ENTRY -> HIGH_SCORES. This structure ensures a focused, arcade-like experience. UI overlays manage menu, pause, game over, and high score interactions. Two-player mode is managed through alternating turns on life loss. The user flow is linear through game states, with high scores being an end-game display. --> | |
<!-- Visualization & Content Choices: | |
- Player/Enemy Ships: Report Info -> Player and Enemy models. Goal -> Represent game agents with more visual variety. Viz/Method -> 3D compound objects (THREE.Group) with more detailed geometries (e.g., Cylinders, Spheres, Cones, Boxes combined). Interaction -> Player control, AI movement, collision. Justification -> Enhances visual fidelity and differentiation. Library/Method -> Three.js. | |
- Formations/Waves/Armada: Report Info -> Enemy formations, attack waves, and an armada. Goal -> Create structured challenges with escalating difficulty. Viz/Method -> Positional data within JS objects driving enemy AI, complex formation logic for armada. Interaction -> Player must defeat waves to progress. Justification -> Central to Galaga's gameplay, armada adds unique challenge. Library/Method -> JavaScript logic. | |
- Tractor Beam: Report Info -> Boss Galaga capture mechanic. Goal -> Introduce risk/reward for dual fighter. Viz/Method -> Animated, transparent THREE.Mesh (Cone). Interaction -> Captures player, enabling dual-fighter retrieval. Justification -> Iconic and requested feature. Library/Method -> Three.js. | |
- Starfield Complexity: Report Info -> Dynamic background with more variation. Goal -> Simulate forward motion and enhance immersion. Viz/Method -> Multiple THREE.Points groups with varying sizes, colors, and speeds. Interaction -> None (ambient). Justification -> Visually richer background. Library/Method -> Three.js. | |
- HUD (Score/Lives/Wave): Report Info -> UI requirements. Goal -> Provide critical game state info for both players. Viz/Method -> HTML overlays with Tailwind CSS. Interaction -> Read-only. Justification -> Standard, non-intrusive method for displaying game data. Library/Method -> HTML/CSS. | |
- High Score Board: Report Info -> Persistent high score. Goal -> Provide a sense of progression and competition. Viz/Method -> HTML overlay with form for initials, dynamically updated list. Interaction -> User input for initials, display only. Justification -> Standard arcade feature. Library/Method -> JavaScript (localStorage for persistence). | |
- Auto Shield: Report Info -> Easier auto-shield. Goal -> Provide a brief period of invincibility after being hit. Viz/Method -> Player ship flashing, temporary state variable. Interaction -> Automatic on hit. Justification -> Improves player survivability and reduces frustration. Library/Method -> JavaScript logic. | |
- Heat-Seeking Missiles: Report Info -> Player missiles home-in. Goal -> Increase player's hit rate. Viz/Method -> Player projectile velocity adjustment towards nearest enemy. Interaction -> Automatic. Justification -> Enhances player experience, makes game feel more forgiving. Library/Method -> JavaScript logic. | |
- Shot Power-Up: Report Info -> Blue capsules for more missiles. Goal -> Reward player, add progression. Viz/Method -> Small 3D cylinder/capsule, moves towards player. Interaction -> Collectible to increase missile output. Justification -> Adds depth to power-up system. Library/Method -> Three.js (for model), JavaScript logic. | |
- Multi-Fighter Fleet: Report Info -> Acquire more ships. Goal -> Enhance offensive capabilities, visual progression. Viz/Method -> Multiple player ship models grouped under one main control. Interaction -> Dynamic visual arrangement. Justification -> Core Galaga '88 mechanic. Library/Method -> Three.js (for grouping), JavaScript logic. | |
- Sound Effects: Report Info -> Game audio. Goal -> Enhance immersion and provide feedback. Viz/Method -> Tone.js for music and sound effects. Interaction -> Automatic playback based on game events. Justification -> Crucial for arcade feel. Library/Method -> Tone.js. | |
- Mouse Control: Report Info -> Additional input. Goal -> Provide alternative control method. Viz/Method -> Mouse position mapping to ship X-coordinate. Interaction -> Direct player control. Justification -> Improves accessibility for different input preferences. Library/Method -> JavaScript DOM events. | |
--> | |
<!-- CONFIRMATION: NO SVG graphics used. NO Mermaid JS used. --> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
<title>Galaga '88 3D</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
<script src="https://unpkg.com/[email protected]/build/Tone.js"></script> | |
<style> | |
body { margin: 0; overflow: hidden; background-color: #000; font-family: 'Courier New', Courier, monospace; } | |
canvas { display: block; } | |
.hud-element { position: absolute; color: #ffffff; text-shadow: 2px 2px 4px #00ff00; } | |
.overlay { 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.7); z-index: 1000; } | |
.overlay-box { background-color: rgba(0, 15, 30, 0.8); border: 2px solid #00ff00; padding: 2rem 4rem; text-align: center; border-radius: 0.5rem;} | |
.overlay-title { font-size: 3rem; color: #ffff00; text-shadow: 2px 2px 6px #ff0000; margin-bottom: 1rem; } | |
.overlay-text { font-size: 1.2rem; color: #ffffff; margin-bottom: 2rem; } | |
.overlay-button { background-color: #ffff00; color: #000000; border: none; padding: 1rem 2rem; font-size: 1.5rem; cursor: pointer; transition: all 0.2s; border-radius: 0.3rem;} | |
.overlay-button:hover { background-color: #ffffff; color: #000000; transform: scale(1.05); } | |
.touch-controls { position: absolute; bottom: 0; width: 100%; height: 120px; display: flex; justify-content: space-between; align-items: center; z-index: 100; padding: 0 20px; } | |
.touch-button { width: 80px; height: 80px; border: 2px solid #00ff00; border-radius: 50%; display: flex; justify-content: center; align-items: center; color: #00ff00; font-size: 2rem; user-select: none; } | |
.touch-button.fire { width: 100px; height: 100px; color: #ffff00; border-color: #ffff00; } | |
#high-score-form input { | |
background-color: #333; | |
border: 1px solid #0f0; | |
color: #fff; | |
padding: 0.5rem; | |
margin-top: 1rem; | |
text-align: center; | |
text-transform: uppercase; | |
font-size: 1.5rem; | |
width: 8rem; | |
} | |
#high-scores-list { | |
list-style: none; | |
padding: 0; | |
margin: 1rem 0; | |
font-size: 1.2rem; | |
color: #fff; | |
} | |
#high-scores-list li { | |
padding: 0.2rem 0; | |
display: flex; | |
justify-content: space-between; | |
} | |
#high-scores-list li span:first-child { | |
color: #ffff00; | |
} | |
#high-scores-list li span:last-child { | |
color: #00ff00; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="score-p1-display" class="hud-element top-4 left-4 text-2xl">P1 SCORE: 0</div> | |
<div id="score-p2-display" class="hud-element top-4 left-4 ml-48 text-2xl" style="display: none;">P2 SCORE: 0</div> | |
<div id="high-score-current-display" class="hud-element top-4 left-1/2 -translate-x-1/2 text-2xl text-yellow-400">HIGH SCORE: 0</div> | |
<div id="current-player-display" class="hud-element bottom-4 right-4 text-2xl text-yellow-400">P1 TURN</div> | |
<div id="wave-display" class="hud-element top-4 right-4 text-2xl">WAVE: 1</div> | |
<div id="lives-display" class="hud-element bottom-4 left-4 text-2xl flex items-center gap-2">LIVES: </div> | |
<div id="menu-overlay" class="overlay"> | |
<div class="overlay-box"> | |
<h1 class="overlay-title">GALAGA '88 3D</h1> | |
<p class="overlay-text">Use [A]/[D] or [←]/[→] to move. [SPACE] to fire.<br> | |
[P] to Pause. Collect captured ships for Double Fighter!</p> | |
<div class="mb-4"> | |
<button id="start-1player-button" class="overlay-button mr-4">1 PLAYER</button> | |
<button id="start-2player-button" class="overlay-button">2 PLAYERS</button> | |
</div> | |
<button id="view-high-scores-button" class="overlay-button !bg-gray-700 !text-white !border !border-white mt-4">HIGH SCORES</button> | |
</div> | |
</div> | |
<div id="pause-overlay" class="overlay" style="display: none;"> | |
<div class="overlay-box"> | |
<h1 class="overlay-title">PAUSED</h1> | |
<button id="resume-button" class="overlay-button">RESUME</button> | |
</div> | |
</div> | |
<div id="game-over-overlay" class="overlay" style="display: none;"> | |
<div class="overlay-box"> | |
<h1 class="overlay-title">GAME OVER</h1> | |
<p id="final-score" class="overlay-text text-2xl"></p> | |
<form id="high-score-form" class="hidden"> | |
<p class="text-white text-lg">Enter Initials (3 letters):</p> | |
<input type="text" id="initials-input" maxlength="3" pattern="[A-Z]{3}" class="uppercase" required> | |
<button type="submit" class="overlay-button mt-4">SAVE SCORE</button> | |
</form> | |
<button id="restart-button" class="overlay-button mt-4">PLAY AGAIN</button> | |
</div> | |
</div> | |
<div id="high-scores-overlay" class="overlay" style="display: none;"> | |
<div class="overlay-box"> | |
<h1 class="overlay-title">HIGH SCORES</h1> | |
<ul id="high-scores-list"></ul> | |
<button id="back-to-menu-button" class="overlay-button mt-4">BACK TO MENU</button> | |
</div> | |
</div> | |
<div class="touch-controls"> | |
<div id="left-touch" class="touch-button"><span>←</span></div> | |
<div id="fire-touch" class="touch-button fire"><span>◎</span></div> | |
<div id="right-touch" class="touch-button"><span>→</span></div> | |
</div> | |
<script> | |
const GAME = { | |
state: 'MENU', // MENU, DEMO, PLAYING, PAUSED, GAME_OVER, HIGH_SCORE_ENTRY, HIGH_SCORES | |
players: { | |
p1: { score: 0, lives: 5, fighterCount: 1, missileCount: 1 }, | |
p2: { score: 0, lives: 5, fighterCount: 1, missileCount: 1 } | |
}, | |
currentPlayer: 'p1', | |
numPlayers: 1, // 1 or 2 | |
playerFleet: [], // Array to hold individual player ship meshes | |
playerShipGroup: null, // The main THREE.Group that contains all active player ships | |
enemies: [], | |
playerProjectiles: [], | |
enemyProjectiles: [], | |
explosions: [], | |
powerUps: [], // New array for power-up capsules | |
stars: [], | |
scene: null, | |
camera: null, | |
renderer: null, | |
clock: new THREE.Clock(), | |
input: { left: false, right: false }, | |
canShoot: true, | |
shootCooldown: 250, | |
playerBoundaryX: 20, | |
capturedShip: null, | |
wave: 1, | |
waveData: [ | |
{ grunts: 8, chargers: 0, bosses: 0, type: 'standard' }, | |
{ grunts: 10, chargers: 2, bosses: 0, type: 'standard' }, | |
{ grunts: 6, chargers: 4, bosses: 1, type: 'standard' }, | |
{ grunts: 0, chargers: 10, bosses: 2, type: 'standard' }, | |
{ grunts: 20, chargers: 0, bosses: 0, type: 'armada' }, // Armada wave | |
{ grunts: 15, chargers: 8, bosses: 2, type: 'standard' }, | |
], | |
currentWaveEnemiesSpawned: 0, | |
currentWaveTotalEnemies: 0, | |
invincible: false, | |
invincibilityDuration: 3000, // 3 seconds | |
highScores: JSON.parse(localStorage.getItem('galagaHighScores')) || [], | |
powerUpDropChance: 0.1, // 10% chance for power-up to drop | |
audioContextReady: false, | |
music: { | |
demo: null, | |
highScore: null | |
}, | |
sfx: { | |
playerShoot: null, | |
enemyShoot: null, | |
explosion: null, | |
powerUp: null, | |
playerHit: null, | |
captureBeam: null, | |
gameStart: null, | |
gameOver: null | |
}, | |
demoTimer: 0, | |
demoInterval: 10000, // 10 seconds for demo loop | |
musicAlternationTimer: 0, | |
musicAlternationInterval: 5000 // Alternate music every 5 seconds | |
}; | |
function init() { | |
setupScene(); | |
setupUI(); | |
// Setup audio context on first user interaction, then load and configure sounds | |
window.addEventListener('resize', onWindowResize, false); | |
document.body.addEventListener('click', initAudioContext, { once: true }); | |
animate(); | |
} | |
function initAudioContext() { | |
if (!GAME.audioContextReady) { | |
Tone.start().then(() => { | |
GAME.audioContextReady = true; | |
loadAndConfigureSounds(); // Now call this here to ensure Tone.js is ready | |
startDemoLoop(); | |
}).catch(e => console.error("Failed to start audio context:", e)); | |
} | |
} | |
function loadAndConfigureSounds() { | |
// Instantiate all synths and sequences | |
GAME.sfx.playerShoot = new Tone.PolySynth(Tone.Synth, { | |
oscillator: { type: "square" } | |
}).toDestination(); | |
GAME.sfx.playerShoot.volume.value = -10; | |
GAME.sfx.enemyShoot = new Tone.PolySynth(Tone.Synth, { | |
oscillator: { type: "sawtooth" } | |
}).toDestination(); | |
GAME.sfx.enemyShoot.volume.value = -15; | |
GAME.sfx.explosion = new Tone.NoiseSynth({ | |
noise: { type: "white" }, | |
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.1 } | |
}).toDestination(); | |
GAME.sfx.explosion.volume.value = -10; | |
GAME.sfx.powerUp = new Tone.PluckSynth().toDestination(); | |
GAME.sfx.powerUp.volume.value = -5; | |
GAME.sfx.playerHit = new Tone.MembraneSynth().toDestination(); | |
GAME.sfx.playerHit.volume.value = -5; | |
GAME.sfx.captureBeam = new Tone.AMSynth({ | |
harmonicity: 0.5, | |
oscillator: { type: "sine" }, | |
envelope: { attack: 0.1, decay: 0.2, sustain: 0.5, release: 0.5 }, | |
modulation: { type: "square" }, | |
modulationEnvelope: { attack: 0.2, decay: 0.5 } | |
}).toDestination(); | |
GAME.sfx.captureBeam.volume.value = -10; | |
GAME.sfx.gameStart = new Tone.Synth().toDestination(); | |
GAME.sfx.gameStart.volume.value = -5; | |
GAME.sfx.gameOver = new Tone.FMSynth().toDestination(); | |
GAME.sfx.gameOver.volume.value = -5; | |
// Sequences are created here, but they trigger attacks on the already defined sfx synths. | |
GAME.music.demo = new Tone.Sequence((time, note) => { | |
if(GAME.sfx.playerShoot) GAME.sfx.playerShoot.triggerAttackRelease(note, "8n", time); | |
}, ["C4", "E4", "G4", "C5"]); | |
GAME.music.demo.loop = true; | |
GAME.music.demo.bpm.value = 120; | |
GAME.music.demo.volume.value = -15; | |
GAME.music.highScore = new Tone.Sequence((time, note) => { | |
if(GAME.sfx.enemyShoot) GAME.sfx.enemyShoot.triggerAttackRelease(note, "8n", time); | |
}, ["G3", "D4", "B3", "E4"]); | |
GAME.music.highScore.loop = true; | |
GAME.music.highScore.bpm.value = 100; | |
GAME.music.highScore.volume.value = -15; | |
} | |
function startDemoLoop() { | |
if (GAME.state === 'MENU' || GAME.state === 'HIGH_SCORES') { | |
if (GAME.music.demo) GAME.music.demo.start(); | |
if (GAME.music.highScore) GAME.music.highScore.stop(); | |
GAME.state = 'DEMO'; | |
GAME.demoTimer = 0; | |
} | |
} | |
function stopAllMusic() { | |
if (GAME.music.demo && GAME.music.demo.state === 'started') GAME.music.demo.stop(); | |
if (GAME.music.highScore && GAME.music.highScore.state === 'started') GAME.music.highScore.stop(); | |
} | |
function setupScene() { | |
GAME.scene = new THREE.Scene(); | |
GAME.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
GAME.camera.position.set(0, 10, 30); | |
GAME.camera.lookAt(0, 0, 0); | |
GAME.renderer = new THREE.WebGLRenderer({ antialias: true }); | |
GAME.renderer.setSize(window.innerWidth, window.innerHeight); | |
document.body.appendChild(GAME.renderer.domElement); | |
const ambientLight = new THREE.AmbientLight(0x606060); | |
GAME.scene.add(ambientLight); | |
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
dirLight.position.set(0, 1, 1); | |
GAME.scene.add(dirLight); | |
createStarfield(); | |
createPlayerShipGroup(); | |
} | |
function setupUI() { | |
document.getElementById('start-1player-button').addEventListener('click', () => { GAME.numPlayers = 1; startGame(); }); | |
document.getElementById('start-2player-button').addEventListener('click', () => { GAME.numPlayers = 2; startGame(); }); | |
document.getElementById('restart-button').addEventListener('click', startGame); | |
document.getElementById('resume-button').addEventListener('click', togglePause); | |
document.getElementById('view-high-scores-button').addEventListener('click', showHighScores); | |
document.getElementById('back-to-menu-button').addEventListener('click', showMenu); | |
document.getElementById('high-score-form').addEventListener('submit', handleHighScoreSubmit); | |
document.addEventListener('keydown', e => { | |
if (e.key === 'a' || e.key === 'ArrowLeft') GAME.input.left = true; | |
if (e.key === 'd' || e.key === 'ArrowRight') GAME.input.right = true; | |
if (e.key === ' ' && GAME.state === 'PLAYING') firePlayerProjectile(); | |
if (e.key === 'p' && (GAME.state === 'PLAYING' || GAME.state === 'PAUSED')) togglePause(); | |
}); | |
document.addEventListener('keyup', e => { | |
if (e.key === 'a' || e.key === 'ArrowLeft') GAME.input.left = false; | |
if (e.key === 'd' || e.key === 'ArrowRight') GAME.input.right = false; | |
}); | |
const leftBtn = document.getElementById('left-touch'); | |
const rightBtn = document.getElementById('right-touch'); | |
const fireBtn = document.getElementById('fire-touch'); | |
leftBtn.addEventListener('touchstart', (e) => { e.preventDefault(); GAME.input.left = true; }); | |
leftBtn.addEventListener('touchend', (e) => { e.preventDefault(); GAME.input.left = false; }); | |
rightBtn.addEventListener('touchstart', (e) => { e.preventDefault(); GAME.input.right = true; }); | |
rightBtn.addEventListener('touchend', (e) => { e.preventDefault(); GAME.input.right = false; }); | |
fireBtn.addEventListener('touchstart', (e) => { e.preventDefault(); if (GAME.state === 'PLAYING') firePlayerProjectile(); }); | |
GAME.renderer.domElement.addEventListener('mousemove', onMouseMove, false); | |
updateHUD(); | |
} | |
function onMouseMove(event) { | |
if (GAME.state !== 'PLAYING' || !GAME.playerShipGroup) return; | |
const mouseX = (event.clientX / window.innerWidth) * 2 - 1; | |
const vector = new THREE.Vector3(mouseX, 0, 0.5); | |
vector.unproject(GAME.camera); | |
const dir = vector.sub(GAME.camera.position).normalize(); | |
const distance = (GAME.playerShipGroup.position.z - GAME.camera.position.z) / dir.z; | |
const pos = GAME.camera.position.clone().add(dir.multiplyScalar(distance)); | |
GAME.playerShipGroup.position.x = Math.max(-GAME.playerBoundaryX, Math.min(GAME.playerBoundaryX, pos.x)); | |
} | |
function showMenu() { | |
document.getElementById('menu-overlay').style.display = 'flex'; | |
document.getElementById('high-scores-overlay').style.display = 'none'; | |
GAME.state = 'MENU'; | |
resetGameVisuals(); | |
startDemoLoop(); | |
} | |
function startGame() { | |
stopAllMusic(); | |
if(GAME.audioContextReady && GAME.sfx.gameStart) GAME.sfx.gameStart.triggerAttackRelease("C5", "0.2"); | |
resetGame(); | |
GAME.state = 'PLAYING'; | |
document.getElementById('menu-overlay').style.display = 'none'; | |
document.getElementById('game-over-overlay').style.display = 'none'; | |
document.getElementById('high-scores-overlay').style.display = 'none'; | |
document.getElementById('score-p2-display').style.display = GAME.numPlayers === 2 ? 'block' : 'none'; | |
startWave(); | |
} | |
function resetGame() { | |
GAME.players.p1.score = 0; | |
GAME.players.p1.lives = 5; | |
GAME.players.p1.fighterCount = 1; | |
GAME.players.p1.missileCount = 1; | |
GAME.players.p2.score = 0; | |
GAME.players.p2.lives = 5; | |
GAME.players.p2.fighterCount = 1; | |
GAME.players.p2.missileCount = 1; | |
GAME.currentPlayer = 'p1'; | |
GAME.wave = 1; | |
resetGameVisuals(); | |
GAME.capturedShip = null; | |
GAME.invincible = false; | |
GAME.canShoot = true; | |
updateHUD(); | |
} | |
function resetGameVisuals() { | |
if(GAME.playerShipGroup && GAME.playerShipGroup.parent) GAME.scene.remove(GAME.playerShipGroup); | |
GAME.playerFleet = []; | |
createPlayerShipGroup(); | |
GAME.enemies.forEach(e => GAME.scene.remove(e.mesh)); | |
GAME.playerProjectiles.forEach(p => GAME.scene.remove(p.mesh)); | |
GAME.enemyProjectiles.forEach(p => GAME.scene.remove(p)); | |
GAME.explosions.forEach(e => GAME.scene.remove(e)); | |
GAME.powerUps.forEach(p => GAME.scene.remove(p)); | |
if (GAME.capturedShip && GAME.capturedShip.mesh.parent) GAME.scene.remove(GAME.capturedShip.mesh); | |
GAME.enemies = []; | |
GAME.playerProjectiles = []; | |
GAME.enemyProjectiles = []; | |
GAME.explosions = []; | |
GAME.powerUps = []; | |
} | |
function createIndividualShipMesh(playerIdentifier) { | |
const primaryColor = playerIdentifier === 'p1' ? 0x00ff00 : 0x00aaff; | |
const accentColor = playerIdentifier === 'p1' ? 0x00aa00 : 0x0077bb; | |
const cockpitColor = 0xffffff; | |
const ship = new THREE.Group(); | |
const bodyGeo = new THREE.ConeGeometry(1, 2.5, 8); | |
const bodyMat = new THREE.MeshPhongMaterial({ color: primaryColor }); | |
const body = new THREE.Mesh(bodyGeo, bodyMat); | |
body.rotation.x = Math.PI / 2; | |
ship.add(body); | |
const wingGeo = new THREE.BoxGeometry(3.5, 0.2, 1.2); | |
const wingMat = new THREE.MeshPhongMaterial({ color: accentColor }); | |
const wing = new THREE.Mesh(wingGeo, wingMat); | |
wing.position.set(0, 0, 0.5); | |
ship.add(wing); | |
const cockpitGeo = new THREE.SphereGeometry(0.5, 16, 8); | |
const cockpitMat = new THREE.MeshPhongMaterial({ color: cockpitColor, emissive: 0x5555ff, shininess: 100 }); | |
const cockpit = new THREE.Mesh(cockpitGeo, cockpitMat); | |
cockpit.position.set(0, 0.3, -0.8); | |
ship.add(cockpit); | |
const thrusterGeo = new THREE.CylinderGeometry(0.3, 0.5, 1, 8); | |
const thrusterMat = new THREE.MeshPhongMaterial({ color: 0xff8800, emissive: 0xff8800 }); | |
const leftThruster = new THREE.Mesh(thrusterGeo, thrusterMat); | |
leftThruster.position.set(-1.2, -0.1, 1.5); | |
ship.add(leftThruster); | |
const rightThruster = new THREE.Mesh(thrusterGeo, thrusterMat); | |
rightThruster.position.set(1.2, -0.1, 1.5); | |
ship.add(rightThruster); | |
ship.scale.set(0.7,0.7,0.7); | |
return ship; | |
} | |
function createPlayerShipGroup() { | |
if (GAME.playerShipGroup) { | |
GAME.scene.remove(GAME.playerShipGroup); | |
GAME.playerShipGroup = null; | |
} | |
GAME.playerShipGroup = new THREE.Group(); | |
GAME.playerFleet = []; | |
const fighterCount = GAME.players[GAME.currentPlayer].fighterCount; | |
const spacing = 3; | |
for (let i = 0; i < fighterCount; i++) { | |
const individualShip = createIndividualShipMesh(GAME.currentPlayer); | |
individualShip.position.x = (i - (fighterCount - 1) / 2) * spacing; | |
GAME.playerFleet.push(individualShip); | |
GAME.playerShipGroup.add(individualShip); | |
} | |
GAME.playerShipGroup.position.set(0, 0, 15); | |
GAME.playerShipGroup.velocity = new THREE.Vector3(); | |
GAME.scene.add(GAME.playerShipGroup); | |
} | |
function createGrunt(pos) { | |
const mat = new THREE.MeshPhongMaterial({ color: 0xff0000 }); | |
const mesh = new THREE.Group(); | |
mesh.add(new THREE.Mesh(new THREE.TetrahedronGeometry(1), mat)); | |
const wing1 = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.2, 1.5), mat); | |
wing1.position.set(0.8, 0, 0); | |
wing1.rotation.y = Math.PI / 4; | |
mesh.add(wing1); | |
const wing2 = wing1.clone(); | |
wing2.position.set(-0.8, 0, 0); | |
wing2.rotation.y = -Math.PI / 4; | |
mesh.add(wing2); | |
mesh.scale.set(0.8, 0.8, 0.8); | |
return createEnemyObject(mesh, pos, 100, 'grunt'); | |
} | |
function createCharger(pos) { | |
const mat = new THREE.MeshPhongMaterial({ color: 0xffff00 }); | |
const mesh = new THREE.Group(); | |
mesh.add(new THREE.Mesh(new THREE.OctahedronGeometry(1), mat)); | |
const cannon = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 1, 8), new THREE.MeshPhongMaterial({ color: 0x888800 })); | |
cannon.position.set(0, 0, -0.8); | |
mesh.add(cannon); | |
mesh.scale.set(0.9, 0.9, 0.9); | |
return createEnemyObject(mesh, pos, 150, 'charger'); | |
} | |
function createBoss(pos) { | |
const mat = new THREE.MeshPhongMaterial({ color: 0x00ffff }); | |
const mesh = new THREE.Group(); | |
mesh.add(new THREE.Mesh(new THREE.IcosahedronGeometry(1.2), mat)); | |
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.3, 16, 8), new THREE.MeshPhongMaterial({ color: 0xff0000, emissive: 0xff0000 })); | |
eye.position.set(0, 0.5, -0.5); | |
mesh.add(eye); | |
const antenna1 = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 1, 8), new THREE.MeshPhongMaterial({ color: 0xaaaaaa })); | |
antenna1.position.set(-0.7, 0.7, 0); | |
antenna1.rotation.x = Math.PI / 4; | |
mesh.add(antenna1); | |
const antenna2 = antenna1.clone(); | |
antenna2.position.set(0.7, 0.7, 0); | |
antenna2.rotation.x = -Math.PI / 4; | |
mesh.add(antenna2); | |
return createEnemyObject(mesh, pos, 300, 'boss'); | |
} | |
function createEnemyObject(mesh, pos, score, type) { | |
const enemy = { | |
mesh: mesh, | |
state: 'FORMING', | |
scoreValue: score, | |
type: type, | |
formationPos: new THREE.Vector3().copy(pos), | |
diveTimer: Math.random() * 3 + 2, | |
tractorBeam: null, | |
isCapturing: false, | |
initialSpawnPosition: new THREE.Vector3() | |
}; | |
const spawnX = (Math.random() - 0.5) * 80; | |
const spawnZ = -50 - Math.random() * 20; | |
enemy.mesh.position.set(spawnX, 0, spawnZ); | |
enemy.initialSpawnPosition.copy(enemy.mesh.position); | |
GAME.enemies.push(enemy); | |
GAME.scene.add(enemy.mesh); | |
return enemy; | |
} | |
function createStarfield() { | |
const numLayers = 3; | |
const layerColors = [0xbbbbbb, 0xaaaaaa, 0x888888]; | |
const layerSizes = [0.4, 0.7, 1.0]; | |
const layerSpeeds = [10, 20, 30]; | |
for (let i = 0; i < numLayers; i++) { | |
const starGeometry = new THREE.BufferGeometry(); | |
const starVertices = []; | |
for (let j = 0; j < 5000; j++) { | |
const x = THREE.MathUtils.randFloatSpread(1000); | |
const y = THREE.MathUtils.randFloatSpread(1000); | |
const z = THREE.MathUtils.randFloatSpread(1000); | |
starVertices.push(x, y, z); | |
} | |
starGeometry.setAttribute('position', new THREE.Float32BufferAttribute(starVertices, 3)); | |
const starMaterial = new THREE.PointsMaterial({ color: layerColors[i], size: layerSizes[i] }); | |
const stars = new THREE.Points(starGeometry, starMaterial); | |
stars.userData.speed = layerSpeeds[i]; | |
GAME.stars.push(stars); | |
GAME.scene.add(stars); | |
} | |
} | |
function startWave() { | |
GAME.currentWaveEnemiesSpawned = 0; | |
const waveInfo = GAME.waveData[(GAME.wave - 1) % GAME.waveData.length]; | |
GAME.currentWaveTotalEnemies = waveInfo.grunts + waveInfo.chargers + waveInfo.bosses; | |
if (waveInfo.type === 'armada') { | |
createArmada(waveInfo.grunts); | |
} else { | |
let enemyIndex = 0; | |
function createEnemyInFormation(creatorFn) { | |
const row = Math.floor(enemyIndex / 8); | |
const col = enemyIndex % 8; | |
const pos = new THREE.Vector3((col - 3.5) * 4, 0, -15 - row * 4); | |
creatorFn(pos); | |
enemyIndex++; | |
} | |
for (let i = 0; i < waveInfo.grunts; i++) createEnemyInFormation(createGrunt); | |
for (let i = 0; i < waveInfo.chargers; i++) createEnemyInFormation(createCharger); | |
for (let i = 0; i < waveInfo.bosses; i++) createEnemyInFormation(createBoss); | |
} | |
} | |
function createArmada(count) { | |
const rows = Math.ceil(Math.sqrt(count)); | |
const cols = Math.ceil(count / rows); | |
const spacing = 3; | |
let enemyCount = 0; | |
for (let r = 0; r < rows; r++) { | |
for (let c = 0; c < cols; c++) { | |
if (enemyCount >= count) break; | |
const posX = (c - cols / 2 + 0.5) * spacing; | |
const posZ = -30 - (r * spacing); | |
createGrunt(new THREE.Vector3(posX, 0, posZ)); | |
enemyCount++; | |
} | |
if (enemyCount >= count) break; | |
} | |
} | |
function firePlayerProjectile() { | |
if (!GAME.canShoot || GAME.state !== 'PLAYING' || !GAME.playerShipGroup) return; | |
// Use Tone.now() to ensure distinct trigger times for rapidly fired sounds | |
if(GAME.audioContextReady && GAME.sfx.playerShoot) GAME.sfx.playerShoot.triggerAttackRelease("C4", "32n", Tone.now() + Math.random() * 0.0001); | |
GAME.canShoot = false; | |
setTimeout(() => { GAME.canShoot = true; }, GAME.shootCooldown); | |
const currentPlayerData = GAME.players[GAME.currentPlayer]; | |
const numShotsPerFighter = currentPlayerData.missileCount; | |
GAME.playerFleet.forEach(fighter => { | |
for (let i = 0; i < numShotsPerFighter; i++) { | |
const geo = new THREE.CylinderGeometry(0.1, 0.1, 1, 8); | |
const mat = new THREE.MeshBasicMaterial({ color: 0x00ffff }); | |
const p = new THREE.Mesh(geo, mat); | |
const shotOffset = (i - (numShotsPerFighter - 1) / 2) * 0.5; | |
p.position.copy(fighter.getWorldPosition(new THREE.Vector3())).add(new THREE.Vector3(shotOffset, 0, -1)); | |
p.rotation.x = Math.PI / 2; | |
GAME.playerProjectiles.push({ mesh: p, velocity: new THREE.Vector3(0, 0, -1.5) }); | |
GAME.scene.add(p); | |
} | |
}); | |
} | |
function fireEnemyProjectile(enemy) { | |
if(GAME.audioContextReady && GAME.sfx.enemyShoot) GAME.sfx.enemyShoot.triggerAttackRelease("A3", "32n", Tone.now() + Math.random() * 0.0001); | |
const geo = new THREE.SphereGeometry(0.3, 8, 8); | |
const mat = new THREE.MeshBasicMaterial({ color: 0xffa500 }); | |
const p = new THREE.Mesh(geo, mat); | |
p.position.copy(enemy.mesh.position); | |
p.velocity = new THREE.Vector3().subVectors(GAME.playerShipGroup.position, enemy.mesh.position).normalize().multiplyScalar(0.3); | |
GAME.enemyProjectiles.push(p); | |
GAME.scene.add(p); | |
} | |
function createExplosion(position) { | |
if(GAME.audioContextReady && GAME.sfx.explosion) GAME.sfx.explosion.triggerAttackRelease("8n", Tone.now() + Math.random() * 0.0001); | |
const geo = new THREE.IcosahedronGeometry(1, 1); | |
const mat = new THREE.MeshBasicMaterial({ color: 0xffff00, transparent: true }); | |
const explosion = new THREE.Mesh(geo, mat); | |
explosion.position.copy(position); | |
explosion.scale.set(0.1, 0.1, 0.1); | |
explosion.life = 0.5; | |
GAME.explosions.push(explosion); | |
GAME.scene.add(explosion); | |
} | |
function createPowerUp(position) { | |
if(GAME.audioContextReady && GAME.sfx.powerUp) GAME.sfx.powerUp.triggerAttackRelease("C6", Tone.now() + Math.random() * 0.0001); | |
const geo = new THREE.CylinderGeometry(0.5, 0.5, 1, 12); | |
const mat = new THREE.MeshPhongMaterial({ color: 0x0000ff }); | |
const powerUp = new THREE.Mesh(geo, mat); | |
powerUp.position.copy(position); | |
powerUp.rotation.x = Math.PI / 2; | |
powerUp.velocity = new THREE.Vector3(0, 0, 0.05); | |
GAME.powerUps.push(powerUp); | |
GAME.scene.add(powerUp); | |
} | |
function updatePlayer(delta) { | |
const acceleration = 0.05; | |
const damping = 0.9; | |
if (GAME.input.left) GAME.playerShipGroup.velocity.x -= acceleration; | |
if (GAME.input.right) GAME.playerShipGroup.velocity.x += acceleration; | |
GAME.playerShipGroup.position.x += GAME.playerShipGroup.velocity.x; | |
GAME.playerShipGroup.velocity.x *= damping; | |
GAME.playerShipGroup.position.x = Math.max(-GAME.playerBoundaryX, Math.min(GAME.playerBoundaryX, GAME.playerShipGroup.position.x)); | |
GAME.playerShipGroup.rotation.z = -GAME.playerShipGroup.velocity.x * 2; | |
if (GAME.invincible) { | |
GAME.playerShipGroup.visible = (Math.floor(GAME.clock.elapsedTime * 10) % 2 === 0); | |
} else { | |
GAME.playerShipGroup.visible = true; | |
} | |
} | |
function updateEnemies(delta) { | |
for (let i = GAME.enemies.length - 1; i >= 0; i--) { | |
const enemy = GAME.enemies[i]; | |
if (enemy.state === 'FORMING') { | |
enemy.mesh.position.lerp(enemy.formationPos, delta * 2); | |
if (enemy.mesh.position.distanceTo(enemy.formationPos) < 0.1) { | |
enemy.state = 'IN_FORMATION'; | |
} | |
} else if (enemy.state === 'IN_FORMATION') { | |
enemy.diveTimer -= delta; | |
if (enemy.diveTimer <= 0) { | |
enemy.state = 'DIVING'; | |
enemy.diveTarget = new THREE.Vector3(GAME.playerShipGroup.position.x + (Math.random()-0.5)*10, 0, 25); | |
} | |
if (enemy.type === 'boss' && !enemy.isCapturing && GAME.players[GAME.currentPlayer].fighterCount < 2 && Math.random() < 0.005) { | |
startTractorBeam(enemy); | |
} | |
if(Math.random() < 0.0005) { | |
fireEnemyProjectile(enemy); | |
} | |
} else if (enemy.state === 'DIVING') { | |
enemy.mesh.position.lerp(enemy.diveTarget, delta * 1.5); | |
if (enemy.mesh.position.z > 20) { | |
enemy.mesh.position.z = -30; | |
enemy.state = 'FORMING'; | |
enemy.diveTimer = Math.random() * 5 + 5; | |
} | |
} | |
if(enemy.isCapturing) updateTractorBeam(enemy, delta); | |
} | |
} | |
function startTractorBeam(boss) { | |
if(GAME.capturedShip || GAME.players[GAME.currentPlayer].fighterCount > 1) return; | |
if(GAME.audioContextReady && GAME.sfx.captureBeam) GAME.sfx.captureBeam.triggerAttackRelease("G2", "2s", Tone.now()); | |
boss.isCapturing = true; | |
boss.captureTimer = 1.5; | |
const beamGeo = new THREE.ConeGeometry(2, 20, 16, 1, true); | |
const beamMat = new THREE.MeshBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.5, side: THREE.DoubleSide }); | |
boss.tractorBeam = new THREE.Mesh(beamGeo, beamMat); | |
boss.tractorBeam.position.set(0, -10, 0); | |
boss.mesh.add(boss.tractorBeam); | |
} | |
function updateTractorBeam(boss, delta) { | |
boss.captureTimer -= delta; | |
const beamWorldPos = new THREE.Vector3(); | |
boss.tractorBeam.getWorldPosition(beamWorldPos); | |
const playerShipWorldPos = new THREE.Vector3(); | |
GAME.playerShipGroup.getWorldPosition(playerShipWorldPos); | |
if (playerShipWorldPos.distanceTo(beamWorldPos) < 5 && Math.abs(playerShipWorldPos.x - boss.mesh.position.x) < 3 && !GAME.invincible) { | |
GAME.state = 'CAPTURED'; | |
GAME.capturedShip = { | |
mesh: GAME.playerShipGroup, | |
boss: boss, | |
isFree: false | |
}; | |
GAME.playerShipGroup.velocity.set(0,0,0); | |
GAME.scene.remove(GAME.playerShipGroup); | |
boss.mesh.add(GAME.playerShipGroup); | |
GAME.playerShipGroup.position.set(0, 0, 3); | |
} | |
if (boss.captureTimer <= 0 || GAME.state === 'CAPTURED') { | |
boss.isCapturing = false; | |
if (boss.tractorBeam && boss.mesh) { | |
boss.mesh.remove(boss.tractorBeam); | |
} | |
boss.tractorBeam = null; | |
if(GAME.state === 'CAPTURED') { | |
if (!GAME.capturedShip.isFree) { | |
playerLostLife(true); | |
} | |
} | |
} | |
} | |
function updateProjectiles(delta) { | |
GAME.playerProjectiles.forEach((pObj, i) => { | |
const p = pObj.mesh; | |
const pVel = pObj.velocity; | |
if (GAME.enemies.length > 0) { | |
let closestEnemy = null; | |
let minDist = Infinity; | |
GAME.enemies.forEach(enemy => { | |
const dist = p.position.distanceTo(enemy.mesh.position); | |
if (dist < minDist) { | |
minDist = dist; | |
closestEnemy = enemy; | |
} | |
}); | |
if (closestEnemy) { | |
const targetDirection = new THREE.Vector3().subVectors(closestEnemy.mesh.position, p.position).normalize(); | |
pVel.lerp(targetDirection.multiplyScalar(1.5), 0.05); | |
} | |
} | |
p.position.add(pVel); | |
if (p.position.z < -40) { | |
GAME.scene.remove(p); | |
GAME.playerProjectiles.splice(i, 1); | |
} | |
}); | |
GAME.enemyProjectiles.forEach((p, i) => { | |
p.position.add(p.velocity); | |
if (p.position.z > 40) { | |
GAME.scene.remove(p); | |
GAME.enemyProjectiles.splice(i, 1); | |
} | |
}); | |
} | |
function updateExplosions(delta) { | |
GAME.explosions.forEach((ex, i) => { | |
ex.life -= delta; | |
ex.scale.multiplyScalar(1 + delta * 5); | |
ex.material.opacity = ex.life * 2; | |
if(ex.life <= 0) { | |
GAME.scene.remove(ex); | |
GAME.explosions.splice(i,1); | |
} | |
}); | |
} | |
function updatePowerUps(delta) { | |
GAME.powerUps.forEach((p, i) => { | |
p.position.z += p.velocity.z; | |
if (p.position.z > 20) { | |
GAME.scene.remove(p); | |
GAME.powerUps.splice(i, 1); | |
} | |
}); | |
} | |
function checkCollisions() { | |
if (!GAME.playerShipGroup || GAME.state !== 'PLAYING') return; | |
const playerBox = new THREE.Box3().setFromObject(GAME.playerShipGroup); | |
for (let i = GAME.playerProjectiles.length - 1; i >= 0; i--) { | |
const pObj = GAME.playerProjectiles[i]; | |
const p = pObj.mesh; | |
const pBox = new THREE.Box3().setFromObject(p); | |
for (let j = GAME.enemies.length - 1; j >= 0; j--) { | |
const enemy = GAME.enemies[j]; | |
const eBox = new THREE.Box3().setFromObject(enemy.mesh); | |
if (pBox.intersectsBox(eBox)) { | |
GAME.scene.remove(p); | |
GAME.playerProjectiles.splice(i, 1); | |
hitEnemy(enemy, j); | |
break; | |
} | |
} | |
} | |
if (!GAME.invincible) { | |
for (let i = GAME.enemies.length - 1; i >= 0; i--) { | |
const enemy = GAME.enemies[i]; | |
const eBox = new THREE.Box3().setFromObject(enemy.mesh); | |
if (playerBox.intersectsBox(eBox)) { | |
hitEnemy(enemy, i); | |
playerLostLife(); | |
break; | |
} | |
} | |
} | |
if (!GAME.invincible) { | |
for (let i = GAME.enemyProjectiles.length - 1; i >= 0; i--) { | |
const p = GAME.enemyProjectiles[i]; | |
const pBox = new THREE.Box3().setFromObject(p); | |
if(playerBox.intersectsBox(pBox)) { | |
GAME.scene.remove(p); | |
GAME.enemyProjectiles.splice(i,1); | |
playerLostLife(); | |
break; | |
} | |
} | |
} | |
if(GAME.capturedShip && GAME.capturedShip.isFree) { | |
const cBox = new THREE.Box3().setFromObject(GAME.capturedShip.mesh); | |
if(playerBox.intersectsBox(cBox)) { | |
GAME.players[GAME.currentPlayer].fighterCount++; | |
GAME.scene.remove(GAME.playerShipGroup); | |
createPlayerShipGroup(); | |
GAME.scene.remove(GAME.capturedShip.mesh); | |
GAME.capturedShip = null; | |
startInvincibility(); | |
} | |
} | |
for (let i = GAME.powerUps.length - 1; i >= 0; i--) { | |
const p = GAME.powerUps[i]; | |
const pBox = new THREE.Box3().setFromObject(p); | |
if (playerBox.intersectsBox(pBox)) { | |
GAME.players[GAME.currentPlayer].missileCount++; | |
GAME.scene.remove(p); | |
GAME.powerUps.splice(i, 1); | |
break; | |
} | |
} | |
} | |
function hitEnemy(enemy, index) { | |
createExplosion(enemy.mesh.position); | |
GAME.players[GAME.currentPlayer].score += enemy.scoreValue; | |
if (Math.random() < GAME.powerUpDropChance) { | |
createPowerUp(enemy.mesh.position); | |
} | |
if (GAME.capturedShip && GAME.capturedShip.boss === enemy) { | |
const captured = GAME.capturedShip; | |
captured.isFree = true; | |
if (captured.boss.mesh && captured.mesh && captured.mesh.parent === captured.boss.mesh) { | |
captured.boss.mesh.remove(captured.mesh); | |
} | |
GAME.scene.add(captured.mesh); | |
captured.mesh.position.copy(enemy.mesh.position); | |
captured.velocity = new THREE.Vector3(0, 0, 0.2); | |
GAME.state = 'PLAYING'; | |
} | |
if (enemy.mesh.parent) { | |
GAME.scene.remove(enemy.mesh); | |
} | |
GAME.enemies.splice(index, 1); | |
if(GAME.enemies.length === 0) { | |
GAME.wave++; | |
setTimeout(startWave, 2000); | |
} | |
} | |
function playerLostLife(isCapturedLoss = false) { | |
if(GAME.state === 'GAME_OVER') return; | |
if(GAME.audioContextReady && GAME.sfx.playerHit) GAME.sfx.playerHit.triggerAttackRelease("C2", Tone.now() + Math.random() * 0.0001); | |
const currentPlayerData = GAME.players[GAME.currentPlayer]; | |
if (isCapturedLoss && GAME.capturedShip) { | |
if (GAME.capturedShip.mesh.parent) { | |
GAME.scene.remove(GAME.capturedShip.mesh); | |
} | |
GAME.capturedShip = null; | |
} | |
if(currentPlayerData.fighterCount > 1) { | |
currentPlayerData.fighterCount--; | |
createExplosion(GAME.playerShipGroup.position); | |
GAME.scene.remove(GAME.playerShipGroup); | |
createPlayerShipGroup(); | |
startInvincibility(); | |
return; | |
} | |
currentPlayerData.lives--; | |
createExplosion(GAME.playerShipGroup.position); | |
GAME.scene.remove(GAME.playerShipGroup); | |
if (currentPlayerData.lives < 0) { | |
let allPlayersOut = true; | |
if (GAME.numPlayers === 2) { | |
const otherPlayer = GAME.currentPlayer === 'p1' ? 'p2' : 'p1'; | |
if (GAME.players[otherPlayer].lives >= 0) { | |
GAME.currentPlayer = otherPlayer; | |
allPlayersOut = false; | |
document.getElementById('current-player-display').textContent = `${otherPlayer.toUpperCase()} TURN`; | |
GAME.players[GAME.currentPlayer].fighterCount = 1; | |
GAME.players[GAME.currentPlayer].missileCount = 1; | |
createPlayerShipGroup(); | |
startInvincibility(); | |
GAME.state = 'PLAYING'; | |
updateHUD(); | |
return; | |
} | |
} | |
if (allPlayersOut) { | |
gameOver(); | |
} | |
} else { | |
setTimeout(() => { | |
if (GAME.state !== 'GAME_OVER') { | |
createPlayerShipGroup(); | |
startInvincibility(); | |
} | |
}, 1000); | |
} | |
} | |
function startInvincibility() { | |
GAME.invincible = true; | |
setTimeout(() => { | |
GAME.invincible = false; | |
if (GAME.playerShipGroup) GAME.playerShipGroup.visible = true; | |
}, GAME.invincibilityDuration); | |
} | |
function gameOver() { | |
if(GAME.audioContextReady) { | |
stopAllMusic(); | |
if(GAME.sfx.gameOver) GAME.sfx.gameOver.triggerAttackRelease("C2", "2s", Tone.now()); | |
} | |
GAME.state = 'GAME_OVER'; | |
const finalScore = GAME.players.p1.score + (GAME.numPlayers === 2 ? GAME.players.p2.score : 0); | |
document.getElementById('final-score').textContent = `TOTAL SCORE: ${finalScore}`; | |
document.getElementById('game-over-overlay').style.display = 'flex'; | |
const lowestHighScore = GAME.highScores.length > 0 ? GAME.highScores[GAME.highScores.length - 1].score : 0; | |
if (finalScore > lowestHighScore || GAME.highScores.length < 10) { | |
document.getElementById('high-score-form').classList.remove('hidden'); | |
} else { | |
document.getElementById('high-score-form').classList.add('hidden'); | |
} | |
} | |
function handleHighScoreSubmit(e) { | |
e.preventDefault(); | |
const initials = document.getElementById('initials-input').value.toUpperCase(); | |
const finalScore = GAME.players.p1.score + (GAME.numPlayers === 2 ? GAME.players.p2.score : 0); | |
GAME.highScores.push({ initials: initials, score: finalScore }); | |
GAME.highScores.sort((a, b) => b.score - a.score); | |
GAME.highScores = GAME.highScores.slice(0, 10); | |
localStorage.setItem('galagaHighScores', JSON.stringify(GAME.highScores)); | |
document.getElementById('high-score-form').classList.add('hidden'); | |
document.getElementById('initials-input').value = ''; | |
showHighScores(); | |
} | |
function showHighScores() { | |
stopAllMusic(); | |
if(GAME.audioContextReady && GAME.music.highScore) GAME.music.highScore.start(Tone.now()); | |
document.getElementById('menu-overlay').style.display = 'none'; | |
document.getElementById('game-over-overlay').style.display = 'none'; | |
document.getElementById('high-scores-overlay').style.display = 'flex'; | |
const list = document.getElementById('high-scores-list'); | |
list.innerHTML = ''; | |
if (GAME.highScores.length === 0) { | |
list.innerHTML = '<li>No high scores yet!</li>'; | |
} else { | |
GAME.highScores.forEach((entry, index) => { | |
const li = document.createElement('li'); | |
li.innerHTML = `<span>${index + 1}. ${entry.initials}</span> <span>${entry.score}</span>`; | |
list.appendChild(li); | |
}); | |
} | |
GAME.state = 'HIGH_SCORES'; | |
} | |
function togglePause() { | |
if (GAME.state === 'PLAYING') { | |
GAME.state = 'PAUSED'; | |
stopAllMusic(); | |
document.getElementById('pause-overlay').style.display = 'flex'; | |
} else if (GAME.state === 'PAUSED') { | |
GAME.state = 'PLAYING'; | |
document.getElementById('pause-overlay').style.display = 'none'; | |
} | |
} | |
function updateHUD() { | |
document.getElementById('score-p1-display').textContent = `P1 SCORE: ${GAME.players.p1.score}`; | |
document.getElementById('score-p2-display').textContent = `P2 SCORE: ${GAME.players.p2.score}`; | |
document.getElementById('wave-display').textContent = `WAVE: ${GAME.wave}`; | |
document.getElementById('high-score-current-display').textContent = `HIGH SCORE: ${GAME.highScores.length > 0 ? GAME.highScores[0].score : 0}`; | |
document.getElementById('current-player-display').textContent = `${GAME.currentPlayer.toUpperCase()} TURN`; | |
const livesContainer = document.getElementById('lives-display'); | |
livesContainer.innerHTML = `LIVES: `; | |
const lifeIcon = '<span>🛸</span>'; | |
for (let i = 0; i < GAME.players[GAME.currentPlayer].lives; i++) { | |
livesContainer.innerHTML += lifeIcon; | |
} | |
} | |
function onWindowResize() { | |
GAME.camera.aspect = window.innerWidth / window.innerHeight; | |
GAME.camera.updateProjectionMatrix(); | |
GAME.renderer.setSize(window.innerWidth, window.innerHeight); | |
} | |
function animate() { | |
requestAnimationFrame(animate); | |
const delta = GAME.clock.getDelta(); | |
GAME.stars.forEach(starLayer => { | |
starLayer.position.z += delta * starLayer.userData.speed; | |
if (starLayer.position.z > 500) starLayer.position.z -= 1000; | |
}); | |
if (GAME.state === 'DEMO') { | |
GAME.demoTimer += delta; | |
GAME.musicAlternationTimer += delta; | |
if (GAME.musicAlternationTimer >= GAME.musicAlternationInterval) { | |
if (GAME.music.demo && GAME.music.demo.state === 'started') { | |
if (GAME.music.demo) GAME.music.demo.stop(); | |
if(GAME.highScores.length > 0 && GAME.music.highScore) { | |
GAME.music.highScore.start(Tone.now()); // Start high score music | |
} | |
} else if (GAME.highScores.length > 0 && GAME.music.highScore && GAME.music.highScore.state === 'started') { | |
if (GAME.music.highScore) GAME.music.highScore.stop(); | |
if(GAME.music.demo) GAME.music.demo.start(Tone.now()); // Start demo music | |
} else if (GAME.highScores.length === 0 && GAME.music.demo && GAME.music.demo.state !== 'started') { | |
if(GAME.music.demo) GAME.music.demo.start(Tone.now()); // Only demo music if no high scores | |
} | |
GAME.musicAlternationTimer = 0; | |
} | |
GAME.enemies.forEach(enemy => { | |
enemy.mesh.position.z += delta * 0.5; | |
enemy.mesh.rotation.y += delta * 0.5; | |
if (enemy.mesh.position.z > 10) { | |
enemy.mesh.position.z = -50; | |
} | |
}); | |
updateEnemyProjectilesInDemo(delta); | |
updateExplosions(delta); | |
} else if (GAME.state === 'PLAYING') { | |
updatePlayer(delta); | |
updateEnemies(delta); | |
updateProjectiles(delta); | |
updatePowerUps(delta); | |
checkCollisions(); | |
updateHUD(); | |
if (GAME.capturedShip && GAME.capturedShip.isFree) { | |
GAME.capturedShip.mesh.position.add(GAME.capturedShip.velocity); | |
if (GAME.capturedShip.mesh.position.z > 25) { | |
GAME.scene.remove(GAME.capturedShip.mesh); | |
GAME.capturedShip = null; | |
} | |
} | |
} | |
updateExplosions(delta); | |
GAME.renderer.render(GAME.scene, GAME.camera); | |
} | |
function updateEnemyProjectilesInDemo(delta) { | |
GAME.enemyProjectiles.forEach((p, i) => { | |
p.position.add(p.velocity); | |
if (p.position.z > 40) { | |
GAME.scene.remove(p); | |
GAME.enemyProjectiles.splice(i, 1); | |
} | |
}); | |
} | |
window.onload = init; | |
</script> | |
</body> | |
</html> | |