Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- <!DOCTYPE html>
- <html>
- <head>
- <title>Enhanced Endless Top-Down Shooter</title>
- <meta charset="utf-8"/>
- <script src="https://cdnjs.cloudflare.com/ajax/libs/noisejs/2.1.0/noisejs.min.js"></script>
- <style>
- body {
- margin: 0;
- overflow: hidden;
- background-color: #111;
- display: flex;
- flex-direction: column; /* Stack vertically */
- justify-content: center;
- align-items: center;
- height: 100vh;
- font-family: sans-serif;
- color: white;
- }
- canvas {
- border: 1px solid white;
- background-color: #1a1a1a;
- }
- #ui {
- position: absolute;
- top: 10px;
- left: 10px;
- background: rgba(0, 0, 0, 0.6);
- padding: 10px;
- border-radius: 5px;
- font-size: 0.9em;
- }
- #ui div {
- margin-bottom: 3px;
- }
- #level-up-options {
- position: absolute;
- bottom: 20px;
- left: 50%;
- transform: translateX(-50%);
- display: none; /* Initially hidden */
- gap: 15px;
- z-index: 10;
- }
- .level-up-button {
- padding: 15px 25px;
- background-color: #4CAF50;
- color: white;
- border: none;
- border-radius: 8px;
- cursor: pointer;
- font-size: 1em;
- transition: background-color 0.3s;
- }
- .level-up-button:hover {
- background-color: #45a049;
- }
- #color-selection {
- margin-bottom: 15px;
- display: block; /* Initially visible */
- }
- #color-selection button {
- padding: 8px 15px;
- margin: 0 5px;
- cursor: pointer;
- border: 1px solid white;
- background: none;
- color: white;
- border-radius: 4px;
- }
- #color-selection button.selected {
- background-color: white;
- color: black;
- }
- #game-container {
- position: relative; /* Needed for absolute positioning of UI */
- }
- /* Style for power-up timer display */
- .power-up-timer {
- color: yellow;
- font-weight: bold;
- }
- </style>
- </head>
- <body>
- <div id="color-selection">
- <span>Choose Player Color:</span>
- <button data-color="blue" class="selected">Blue</button>
- <button data-color="cyan">Cyan</button>
- <button data-color="#ff8800">Orange</button>
- <button data-color="magenta">Magenta</button>
- </div>
- <div id="game-container">
- <div id="ui">
- <div>Level: <span id="level">1</span></div>
- <div>XP: <span id="xp">0</span> / <span id="xp-needed">100</span></div>
- <div>Health: <span id="health">100</span> / <span id="max-health">100</span></div>
- <div>Wave: <span id="wave">1</span></div>
- <div>Enemies Left: <span id="enemies-left">0</span></div>
- <div>Weapon: <span id="current-weapon">Pistol</span></div>
- <div>Ammo: <span id="ammo">--</span></div>
- <div id="power-up-status"></div>
- </div>
- <div id="level-up-options"></div>
- <canvas id="gameCanvas"></canvas>
- </div>
- <script>
- // --- DOM Elements ---
- const canvas = document.getElementById('gameCanvas');
- const ctx = canvas.getContext('2d');
- const uiLevel = document.getElementById('level');
- const uiXp = document.getElementById('xp');
- const uiXpNeeded = document.getElementById('xp-needed');
- const uiHealth = document.getElementById('health');
- const uiMaxHealth = document.getElementById('max-health');
- const uiWave = document.getElementById('wave');
- const uiEnemiesLeft = document.getElementById('enemies-left');
- const uiCurrentWeapon = document.getElementById('current-weapon');
- const uiAmmo = document.getElementById('ammo');
- const uiPowerUpStatus = document.getElementById('power-up-status');
- const levelUpOptionsDiv = document.getElementById('level-up-options');
- const colorSelectionDiv = document.getElementById('color-selection');
- const gameContainerDiv = document.getElementById('game-container');
- // --- Game Settings ---
- let PLAYER_COLOR = 'blue'; // Default, will be set by selection
- const PLAYER_SIZE = 15;
- const PLAYER_BASE_SPEED = 250; // Pixels per second
- const ENEMY_START_SIZE = 20;
- const ENEMY_START_HEALTH = 50;
- const ENEMY_START_SPEED = 100;
- const ENEMY_BASE_DAMAGE = 10;
- const PROJECTILE_BASE_SPEED = 500;
- const PROJECTILE_BASE_SIZE = 5;
- const PROJECTILE_COLOR = 'yellow';
- const XP_ORB_SIZE = 5;
- const XP_ORB_COLOR = 'lime';
- const AMMO_PICKUP_SIZE = 6;
- const AMMO_PICKUP_COLOR = 'lightblue';
- const POWERUP_SIZE = 10;
- const POWERUP_SPAWN_CHANCE = 0.05; // Chance per enemy death
- const XP_PER_KILL = 25;
- const BASE_XP_TO_LEVEL = 100;
- const XP_LEVEL_MULTIPLIER = 1.2;
- const HEALTH_BAR_HEIGHT = 5;
- const HEALTH_BAR_WIDTH_FACTOR = 1.5;
- const PARTICLE_LIFETIME = 0.5; // seconds
- const MAX_PARTICLES = 300; // Increased max particles
- const GRID_SIZE = 50; // For background grid
- const BACKGROUND_NOISE_SCALE = 250; // Controls 'zoom' level of background noise
- const CAMERA_SHAKE_DECAY = 0.85;
- const AMMO_DROP_CHANCE = 0.3; // 30% chance for an enemy to drop ammo
- // --- Game State ---
- let player;
- let enemies = [];
- let projectiles = [];
- let xpOrbs = [];
- let ammoPickups = [];
- let powerUps = [];
- let particles = [];
- let keys = {};
- let mouse = {x: 0, y: 0, down: false};
- let camera = {x: 0, y: 0, shakeIntensity: 0, shakeDuration: 0};
- let currentWave = 1;
- let enemiesToSpawn = 0;
- let enemiesAlive = 0;
- let spawningDone = false;
- let lastTime = 0;
- let timeToNextSpawn = 0;
- let gameTime = 0;
- let choosingLevelUp = false;
- let isGameOver = false;
- let noise; // For procedural background
- let audioCtx; // For Web Audio API
- // --- Audio Placeholders ---
- function initAudio() {
- try {
- audioCtx = new (window.AudioContext || window.webkitAudioContext)();
- console.log("AudioContext initialized.");
- } catch (e) {
- console.error("Web Audio API is not supported in this browser", e);
- }
- }
- function playSound(type) {
- if (!audioCtx) return; // Do nothing if audio is not available
- console.log("Play sound:", type); // Placeholder log
- // === Basic Sound Example (Simple Beep) ===
- if (type === 'shoot_pistol') {
- const oscillator = audioCtx.createOscillator();
- const gainNode = audioCtx.createGain();
- oscillator.connect(gainNode);
- gainNode.connect(audioCtx.destination);
- oscillator.type = 'triangle'; // triangle, sine, square, sawtooth
- oscillator.frequency.setValueAtTime(880, audioCtx.currentTime); // A5 note
- gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime); // Start volume
- // Quick fade out
- gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.1);
- oscillator.start(audioCtx.currentTime);
- oscillator.stop(audioCtx.currentTime + 0.1);
- }
- // --- Add more sound types here ---
- // e.g., 'hit_player', 'hit_enemy', 'enemy_death', 'level_up', 'pickup_xp', 'pickup_ammo', 'pickup_powerup', 'melee_swing', 'shoot_shotgun' etc.
- // Implement using OscillatorNode, GainNode (for envelopes), BiquadFilterNode (for tone shaping), BufferSourceNode (for noise/samples)
- }
- // --- Utility Functions ---
- function getRandomColor() {
- const letters = '0123456789ABCDEF';
- let color = '#';
- for (let i = 0; i < 6; i++) {
- color += letters[Math.floor(Math.random() * 16)];
- }
- return color;
- }
- function distance(x1, y1, x2, y2) {
- return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
- }
- function normalize(x, y) {
- const len = Math.sqrt(x * x + y * y);
- if (len === 0) return {x: 0, y: 0};
- return {x: x / len, y: y / len};
- }
- function worldToScreen(x, y) {
- // Apply camera shake
- const screenX = x - camera.x + canvas.width / 2 + (Math.random() - 0.5) * camera.shakeIntensity;
- const screenY = y - camera.y + canvas.height / 2 + (Math.random() - 0.5) * camera.shakeIntensity;
- return {x: screenX, y: screenY};
- }
- function screenToWorld(x, y) {
- return {
- x: x + camera.x - canvas.width / 2,
- y: y + camera.y - canvas.height / 2
- };
- }
- function applyCameraShake(intensity, duration) {
- camera.shakeIntensity = Math.max(camera.shakeIntensity, intensity);
- camera.shakeDuration = Math.max(camera.shakeDuration, duration);
- }
- function randomInRange(min, max) {
- return Math.random() * (max - min) + min;
- }
- // --- Classes ---
- class Entity { /* ... (keep existing, including drawHealthBar, takeDamage) ... */
- constructor(x, y, size, color, health) {
- this.x = x;
- this.y = y;
- this.size = size;
- this.color = color;
- this.maxHealth = health;
- this.health = health;
- this.vx = 0; // velocity x
- this.vy = 0; // velocity y
- }
- draw(ctx) {
- // Abstract method - should be implemented by subclasses
- }
- update(dt) {
- this.x += this.vx * dt;
- this.y += this.vy * dt;
- }
- drawHealthBar(ctx) {
- if (this.health < this.maxHealth && this.health > 0) { // Don't draw if dead
- const screenPos = worldToScreen(this.x, this.y);
- const barWidth = this.size * HEALTH_BAR_WIDTH_FACTOR;
- const barX = screenPos.x - barWidth / 2;
- const barY = screenPos.y - this.size - 8; // Position ABOVE the entity
- // Background of health bar
- ctx.fillStyle = 'grey';
- ctx.fillRect(barX, barY, barWidth, HEALTH_BAR_HEIGHT);
- // Current health
- const healthPercentage = this.health / this.maxHealth;
- const healthColor = healthPercentage > 0.5 ? 'green' : (healthPercentage > 0.2 ? 'orange' : 'red');
- ctx.fillStyle = healthColor;
- ctx.fillRect(barX, barY, barWidth * healthPercentage, HEALTH_BAR_HEIGHT);
- }
- }
- takeDamage(amount) {
- if (this.health <= 0) return false; // Already dead
- this.health -= amount;
- createParticles(this.x, this.y, 5, 'red', 50); // Generic hit particle
- if (this.health <= 0) {
- this.health = 0;
- return true; // Died
- }
- return false; // Survived
- }
- }
- class Player extends Entity {
- constructor(x, y, color) {
- super(x, y, PLAYER_SIZE, color, 100);
- this.baseSpeed = PLAYER_BASE_SPEED;
- this.speed = this.baseSpeed;
- this.xp = 0;
- this.level = 1;
- this.xpToNextLevel = BASE_XP_TO_LEVEL;
- // --- Weapon Definitions ---
- this.weapons = [ // Use an array for easy switching by index
- { // 0: Bat (Melee)
- name: "Bat", type: 'melee', damage: 25, rateOfFire: 2, // swings per second
- range: 40, swingArc: Math.PI / 2, // 90 degree arc
- lastSwingTime: 0, color: '#aaa', knockback: 150
- },
- { // 1: Knife (Melee) - Faster, less damage/range
- name: "Knife", type: 'melee', damage: 15, rateOfFire: 4,
- range: 30, swingArc: Math.PI / 3, // 60 degree arc
- lastSwingTime: 0, color: '#ccc', knockback: 50
- },
- { // 2: Pistol (Ranged)
- name: "Pistol", type: 'ranged', damage: 18, rateOfFire: 5,
- projectileSpeed: PROJECTILE_BASE_SPEED, projectileSize: PROJECTILE_BASE_SIZE,
- ammoType: 'pistol', ammoPerShot: 1, maxAmmo: 100,
- lastShotTime: 0, color: PROJECTILE_COLOR, recoil: 50,
- accuracy: 0.05, projectileCount: 1, spreadAngle: 0
- },
- { // 3: SMG (Ranged) - High fire rate, less damage, less accurate
- name: "SMG", type: 'ranged', damage: 12, rateOfFire: 12,
- projectileSpeed: PROJECTILE_BASE_SPEED * 0.9, projectileSize: PROJECTILE_BASE_SIZE * 0.8,
- ammoType: 'smg', ammoPerShot: 1, maxAmmo: 200,
- lastShotTime: 0, color: 'orange', recoil: 70,
- accuracy: 0.15, projectileCount: 1, spreadAngle: 0
- },
- { // 4: Shotgun (Ranged) - Multi-projectile, spread
- name: "Shotgun", type: 'ranged', damage: 10, rateOfFire: 1.5, // Damage per pellet
- projectileSpeed: PROJECTILE_BASE_SPEED * 0.8, projectileSize: PROJECTILE_BASE_SIZE * 0.7,
- ammoType: 'shotgun', ammoPerShot: 1, maxAmmo: 50, // Uses 1 shell, fires multiple pellets
- lastShotTime: 0, color: 'red', recoil: 250,
- accuracy: 0.1, projectileCount: 6, spreadAngle: 0.5 // Radians (approx 30 deg)
- }
- // Add more: Flamethrower (continuous particles?), Grenade Launcher, etc.
- ];
- this.currentWeaponIndex = 2; // Start with Pistol
- this.currentWeapon = this.weapons[this.currentWeaponIndex];
- // --- Ammo ---
- this.ammo = {
- pistol: 50,
- smg: 0,
- shotgun: 10,
- // Add other ammo types if needed
- };
- // --- Power-up Effects ---
- this.powerUps = {
- invincibility: 0, // duration remaining
- doubleDamage: 0,
- speedBoost: 0,
- rapidFire: 0, // Multiplier for fire rate
- xpMultiplier: 0, // Multiplier for XP gain
- magnetRadius: 0 // Increased XP orb magnet radius
- };
- this.baseMagnetRadius = XP_ORB_MAGNET_RADIUS; // Need to define this constant
- this.aimAngle = 0; // For drawing aim line/weapon
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- let drawColor = this.color;
- // Visual feedback for invincibility
- if (this.powerUps.invincibility > 0) {
- // Pulsating alpha or different color
- ctx.globalAlpha = 0.5 + Math.sin(gameTime * 10) * 0.2;
- ctx.fillStyle = 'white'; // Aura color
- ctx.beginPath();
- ctx.arc(screenPos.x, screenPos.y, this.size + 5, 0, Math.PI * 2);
- ctx.fill();
- ctx.globalAlpha = 1.0;
- }
- ctx.fillStyle = drawColor;
- ctx.beginPath();
- ctx.arc(screenPos.x, screenPos.y, this.size, 0, Math.PI * 2);
- ctx.fill();
- // Draw aiming direction / weapon representation
- const angle = this.aimAngle;
- ctx.strokeStyle = 'white';
- ctx.lineWidth = 3;
- ctx.beginPath();
- ctx.moveTo(screenPos.x, screenPos.y);
- const weaponLength = this.size + 8; // Simple line representing weapon barrel/swing
- ctx.lineTo(screenPos.x + Math.cos(angle) * weaponLength, screenPos.y + Math.sin(angle) * weaponLength);
- ctx.stroke();
- ctx.lineWidth = 1; // Reset
- this.drawHealthBar(ctx);
- }
- update(dt) {
- if (isGameOver) return;
- // Update aim angle
- this.aimAngle = Math.atan2(mouse.y - canvas.height / 2, mouse.x - canvas.width / 2);
- // Update Power-up timers and effects
- this.updatePowerUps(dt);
- // Movement
- this.vx = 0;
- this.vy = 0;
- const currentSpeed = this.speed * (this.powerUps.speedBoost > 0 ? 1.5 : 1); // Apply speed boost
- if (keys['w'] || keys['ArrowUp']) this.vy = -currentSpeed;
- if (keys['s'] || keys['ArrowDown']) this.vy = currentSpeed;
- if (keys['a'] || keys['ArrowLeft']) this.vx = -currentSpeed;
- if (keys['d'] || keys['ArrowRight']) this.vx = currentSpeed;
- if (this.vx !== 0 && this.vy !== 0) {
- const factor = 1 / Math.sqrt(2);
- this.vx *= factor;
- this.vy *= factor;
- }
- super.update(dt); // Apply velocity
- // Apply friction to recoil impulse (simple decay)
- // This part might need tuning or a different approach
- this.vx *= 0.9; // Gradually reduce recoil velocity
- this.vy *= 0.9;
- // Weapon Switching (Number keys 1 through 0)
- for (let i = 1; i <= 9; i++) {
- if (keys[i.toString()] && this.weapons.length >= i) {
- this.switchWeapon(i - 1);
- keys[i.toString()] = false; // Consume the key press
- }
- }
- if (keys['0'] && this.weapons.length >= 10) { // Handle '0' key for 10th weapon
- this.switchWeapon(9);
- keys['0'] = false;
- }
- // Shooting / Attacking
- if (mouse.down) {
- this.tryAttack();
- }
- }
- switchWeapon(index) {
- if (index >= 0 && index < this.weapons.length && index !== this.currentWeaponIndex) {
- this.currentWeaponIndex = index;
- this.currentWeapon = this.weapons[index];
- console.log(`Switched to weapon: ${this.currentWeapon.name}`);
- updateUI(); // Update UI for weapon/ammo
- playSound('weapon_switch'); // Placeholder sound
- }
- }
- tryAttack() {
- const weapon = this.currentWeapon;
- const fireRateMultiplier = (this.powerUps.rapidFire > 0) ? 1.5 : 1;
- const cooldown = 1 / (weapon.rateOfFire * fireRateMultiplier);
- const lastAttackTime = weapon.type === 'melee' ? weapon.lastSwingTime : weapon.lastShotTime;
- if ((gameTime - lastAttackTime) > cooldown) {
- if (weapon.type === 'melee') {
- this.performMeleeAttack(weapon);
- weapon.lastSwingTime = gameTime;
- } else if (weapon.type === 'ranged') {
- // Check Ammo
- const ammoType = weapon.ammoType;
- if (ammoType && this.ammo[ammoType] >= weapon.ammoPerShot) {
- this.ammo[ammoType] -= weapon.ammoPerShot;
- this.shootRanged(weapon);
- weapon.lastShotTime = gameTime;
- updateUI(); // Update ammo display
- } else if (!ammoType) { // Weapon doesn't use ammo
- this.shootRanged(weapon);
- weapon.lastShotTime = gameTime;
- } else {
- // Out of ammo sound?
- playSound('out_of_ammo');
- }
- }
- }
- }
- performMeleeAttack(weapon) {
- console.log(`Swinging ${weapon.name}`);
- playSound('melee_swing'); // Placeholder
- const attackAngle = this.aimAngle;
- const halfArc = weapon.swingArc / 2;
- let enemiesHit = 0;
- enemies.forEach(enemy => {
- const dist = distance(this.x, this.y, enemy.x, enemy.y);
- if (dist < weapon.range + enemy.size / 2) { // Check range
- const angleToEnemy = Math.atan2(enemy.y - this.y, enemy.x - this.x);
- let angleDiff = attackAngle - angleToEnemy;
- // Normalize angle difference to be between -PI and PI
- while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
- while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
- if (Math.abs(angleDiff) < halfArc) { // Check if within swing arc
- const damageMultiplier = (this.powerUps.doubleDamage > 0) ? 2 : 1;
- const died = enemy.takeDamage(weapon.damage * damageMultiplier);
- enemiesHit++;
- // Apply knockback
- const pushDir = normalize(enemy.x - this.x, enemy.y - this.y);
- enemy.vx += pushDir.x * weapon.knockback;
- enemy.vy += pushDir.y * weapon.knockback;
- if (died) {
- enemy.onDeath();
- // Remove enemy (handled in main loop cleanup or dedicated function)
- }
- }
- }
- });
- if (enemiesHit > 0) {
- playSound('melee_hit'); // Placeholder
- }
- // Maybe add a visual swing arc particle effect
- createParticles(this.x, this.y, 5, weapon.color, 100, attackAngle, weapon.swingArc);
- }
- shootRanged(weapon) {
- const damageMultiplier = (this.powerUps.doubleDamage > 0) ? 2 : 1;
- const baseAngle = this.aimAngle;
- // Muzzle flash particles
- const muzzleX = this.x + Math.cos(baseAngle) * (this.size + 5);
- const muzzleY = this.y + Math.sin(baseAngle) * (this.size + 5);
- createParticles(muzzleX, muzzleY, 10, 'orange', 150, baseAngle, Math.PI / 6);
- playSound(`shoot_${weapon.name.toLowerCase()}`.replace(' ', '_')); // e.g., playSound('shoot_pistol')
- for (let i = 0; i < weapon.projectileCount; i++) {
- // Accuracy: Random deviation from the aim angle
- const accuracyOffset = (Math.random() - 0.5) * weapon.accuracy;
- // Spread: Angle offset for multi-projectile weapons
- let spreadOffset = 0;
- if (weapon.projectileCount > 1) {
- // Distribute projectiles evenly within the spreadAngle
- spreadOffset = (i / (weapon.projectileCount - 1) - 0.5) * weapon.spreadAngle;
- }
- const currentAngle = baseAngle + accuracyOffset + spreadOffset;
- const dx = Math.cos(currentAngle);
- const dy = Math.sin(currentAngle);
- // Start projectile slightly outside the player
- const projX = this.x + dx * (this.size + weapon.projectileSize);
- const projY = this.y + dy * (this.size + weapon.projectileSize);
- projectiles.push(new Projectile(
- projX, projY,
- dx * weapon.projectileSpeed, dy * weapon.projectileSpeed,
- weapon.damage * damageMultiplier,
- weapon.projectileSize, weapon.color, this
- ));
- }
- // Apply Recoil
- const recoilStrength = weapon.recoil || 0;
- this.vx -= Math.cos(baseAngle) * recoilStrength * 0.1; // Apply a fraction of recoil as impulse
- this.vy -= Math.sin(baseAngle) * recoilStrength * 0.1;
- // Apply Camera Shake
- applyCameraShake(recoilStrength / 20, 0.1); // Shake proportional to recoil
- }
- addXp(amount) {
- if (choosingLevelUp) return;
- const xpMultiplier = (this.powerUps.xpMultiplier > 0) ? 1.5 : 1;
- this.xp += amount * xpMultiplier;
- if (this.xp >= this.xpToNextLevel) {
- this.levelUp();
- }
- updateUI();
- }
- levelUp() {
- this.level++;
- this.xp -= this.xpToNextLevel; // Carry over excess XP
- this.xpToNextLevel = Math.floor(BASE_XP_TO_LEVEL * (XP_LEVEL_MULTIPLIER ** (this.level - 1))); // Recalculate based on base and level
- this.health = this.maxHealth; // Heal on level up
- console.log(`Level Up! Reached level ${this.level}`);
- playSound('level_up');
- createParticles(this.x, this.y, 50, 'gold', 150); // Level up particle burst
- updateUI();
- presentLevelUpOptions();
- }
- updatePowerUps(dt) {
- let statusChanged = false;
- for (const type in this.powerUps) {
- if (this.powerUps[type] > 0) {
- this.powerUps[type] -= dt;
- if (this.powerUps[type] <= 0) {
- this.powerUps[type] = 0;
- console.log(`Power-up expired: ${type}`);
- statusChanged = true;
- // Reset effects specific to expiration if needed
- if (type === 'speedBoost') this.speed = this.baseSpeed;
- if (type === 'magnetRadius') XP_ORB_MAGNET_RADIUS = this.baseMagnetRadius; // Reset magnet radius
- }
- }
- }
- // Apply continuous effects
- this.speed = this.baseSpeed * (this.powerUps.speedBoost > 0 ? 1.5 : 1);
- // Apply magnet radius boost
- XP_ORB_MAGNET_RADIUS = this.baseMagnetRadius * (this.powerUps.magnetRadius > 0 ? 2.0 : 1); // Double radius example
- if (statusChanged) {
- updateUI(); // Update the UI power-up display
- }
- }
- activatePowerUp(type, duration) {
- console.log(`Activated power-up: ${type} for ${duration}s`);
- this.powerUps[type] = (this.powerUps[type] || 0) + duration; // Add duration, stacking
- // Immediate effect application if necessary
- if (type === 'speedBoost') this.speed = this.baseSpeed * 1.5;
- if (type === 'magnetRadius') XP_ORB_MAGNET_RADIUS = this.baseMagnetRadius * 2.0;
- playSound('pickup_powerup');
- updateUI();
- }
- takeDamage(amount) {
- if (this.powerUps.invincibility > 0 || isGameOver) {
- // Flash effect even if invincible?
- createParticles(this.x, this.y, 3, 'grey', 50);
- return false; // Invincible or game over
- }
- const died = super.takeDamage(amount);
- playSound('hit_player'); // Placeholder
- applyCameraShake(10, 0.15); // Shake on taking damage
- updateUI(); // Update health display
- if (died && !isGameOver) {
- gameOver();
- return true;
- }
- return died;
- }
- }
- // --- Enemy Base Class and Variants ---
- class Enemy extends Entity {
- constructor(x, y, wave, options = {}) {
- const sizeMultiplier = options.sizeMult || (1 + (wave - 1) * 0.05);
- const healthMultiplier = options.healthMult || (1 + (wave - 1) * 0.1);
- const speedMultiplier = options.speedMult || (1 + (wave - 1) * 0.02);
- const size = (options.baseSize || ENEMY_START_SIZE) * sizeMultiplier;
- const health = (options.baseHealth || ENEMY_START_HEALTH) * healthMultiplier;
- const speed = (options.baseSpeed || ENEMY_START_SPEED) * speedMultiplier;
- const color = options.color || `hsl(${randomInRange(0, 50)}, 70%, 50%)`; // Red/Orange hues
- super(x, y, size, color, health);
- this.speed = speed;
- this.baseDamage = options.baseDamage || ENEMY_BASE_DAMAGE;
- this.damage = this.baseDamage * (1 + (wave - 1) * 0.08); // Damage scales
- this.xpValue = (options.xpValue || XP_PER_KILL) * (1 + (wave - 1) * 0.05);
- this.enemyType = options.type || 'standard'; // For identification
- }
- draw(ctx) { // Standard square enemy draw
- const screenPos = worldToScreen(this.x, this.y);
- ctx.fillStyle = this.color;
- ctx.fillRect(screenPos.x - this.size / 2, screenPos.y - this.size / 2, this.size, this.size);
- this.drawHealthBar(ctx);
- }
- update(dt, target) { // Standard chase behavior
- // Guard clause (keep the improved one)
- if (!target || typeof target.x === 'undefined' || typeof target.y === 'undefined' || this.health <= 0) {
- this.vx = 0;
- this.vy = 0;
- return;
- }
- // --- Add Logging ---
- const dx = target.x - this.x;
- const dy = target.y - this.y;
- // console.log(`Enemy update: dx=${dx}, dy=${dy}`); // Optional: Log inputs to normalize
- const moveDir = normalize(dx, dy);
- // --- Log the result of normalize AND add a defensive check ---
- // console.log('Result of normalize:', moveDir); // LOG THE RESULT
- if (!moveDir || typeof moveDir.x === 'undefined') {
- console.error(`CRITICAL: normalize returned invalid value! moveDir:`, moveDir, ` Inputs: dx=${dx}, dy=${dy}`);
- // Provide a default value to prevent crash, although this indicates a deeper issue
- this.vx = 0;
- this.vy = 0;
- // Optionally uncomment the line below to pause execution if debugger is open
- // debugger;
- } else {
- // This block should now only execute if moveDir is a valid object
- this.vx = moveDir.x * this.speed; // Line 718 (or around there)
- this.vy = moveDir.y * this.speed;
- }
- // --- End Logging & Check ---
- super.update(dt); // Apply velocity changes
- }
- onDeath() {
- if (!player) return; // Avoid errors if player is gone
- // Drop XP orb
- xpOrbs.push(new XpOrb(this.x, this.y, this.xpValue));
- // Chance to drop Ammo
- if (Math.random() < AMMO_DROP_CHANCE) {
- // Drop ammo type based on player's needs or random? Let's do random for now.
- const ammoTypes = ['pistol', 'smg', 'shotgun'];
- const typeToDrop = ammoTypes[Math.floor(Math.random() * ammoTypes.length)];
- const amount = typeToDrop === 'shotgun' ? randomInRange(1, 3) : randomInRange(5, 15); // Adjust amounts
- ammoPickups.push(new AmmoPickup(this.x + randomInRange(-5, 5), this.y + randomInRange(-5, 5), typeToDrop, Math.floor(amount)));
- }
- // Chance to drop PowerUp
- if (Math.random() < POWERUP_SPAWN_CHANCE) {
- powerUps.push(PowerUp.createRandom(this.x, this.y));
- }
- // Death particles
- createParticles(this.x, this.y, 20, this.color, 100, null, Math.PI * 2, {friction: 0.96}); // More explosive particles
- playSound('enemy_death'); // Placeholder
- }
- }
- class FastEnemy extends Enemy {
- constructor(x, y, wave) {
- super(x, y, wave, {
- type: 'fast',
- baseSpeed: ENEMY_START_SPEED * 1.5,
- baseHealth: ENEMY_START_HEALTH * 0.7,
- baseSize: ENEMY_START_SIZE * 0.8,
- color: `hsl(${randomInRange(200, 240)}, 80%, 60%)`, // Blue/Cyan hues
- xpValue: XP_PER_KILL * 0.8
- });
- }
- // Override draw for different shape (e.g., triangle)
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- const s = this.size;
- ctx.fillStyle = this.color;
- ctx.beginPath();
- // Simple triangle pointing towards movement direction (or player)
- const angle = Math.atan2(this.vy, this.vx); // Angle of velocity
- ctx.moveTo(screenPos.x + Math.cos(angle) * s * 0.8, screenPos.y + Math.sin(angle) * s * 0.8);
- ctx.lineTo(screenPos.x + Math.cos(angle + Math.PI * 0.8) * s * 0.6, screenPos.y + Math.sin(angle + Math.PI * 0.8) * s * 0.6);
- ctx.lineTo(screenPos.x + Math.cos(angle - Math.PI * 0.8) * s * 0.6, screenPos.y + Math.sin(angle - Math.PI * 0.8) * s * 0.6);
- ctx.closePath();
- ctx.fill();
- this.drawHealthBar(ctx);
- }
- }
- class TankEnemy extends Enemy {
- constructor(x, y, wave) {
- super(x, y, wave, {
- type: 'tank',
- baseSpeed: ENEMY_START_SPEED * 0.6,
- baseHealth: ENEMY_START_HEALTH * 2.5,
- baseSize: ENEMY_START_SIZE * 1.3,
- color: `hsl(${randomInRange(30, 60)}, 60%, 40%)`, // Dark Orange/Brown hues
- xpValue: XP_PER_KILL * 1.5,
- baseDamage: ENEMY_BASE_DAMAGE * 1.2
- });
- }
- // Override draw for different shape (e.g., hexagon)
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- const s = this.size / 2;
- ctx.fillStyle = this.color;
- ctx.beginPath();
- for (let i = 0; i < 6; i++) {
- ctx.lineTo(screenPos.x + s * Math.cos(Math.PI / 3 * i), screenPos.y + s * Math.sin(Math.PI / 3 * i));
- }
- ctx.closePath();
- ctx.fill();
- this.drawHealthBar(ctx);
- }
- }
- // --- Add RangedEnemy, ZigZagEnemy etc. later ---
- class Projectile { /* ... (keep existing, maybe add owner check later) ... */
- constructor(x, y, vx, vy, damage, size, color, owner) {
- this.x = x;
- this.y = y;
- this.vx = vx;
- this.vy = vy;
- this.damage = damage;
- this.size = size;
- this.color = color;
- this.owner = owner; // Could be Player or an Enemy type
- this.life = 3; // Time to live in seconds
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- ctx.fillStyle = this.color;
- ctx.beginPath();
- ctx.arc(screenPos.x, screenPos.y, this.size, 0, Math.PI * 2);
- ctx.fill();
- }
- update(dt) {
- this.x += this.vx * dt;
- this.y += this.vy * dt;
- this.life -= dt;
- }
- }
- // --- Pickups ---
- let XP_ORB_MAGNET_RADIUS = 100; // Can be modified by power-ups
- class XpOrb {
- constructor(x, y, value) { /* ... (keep most of existing) ... */
- this.x = x;
- this.y = y;
- this.value = value;
- this.size = XP_ORB_SIZE;
- this.color = XP_ORB_COLOR;
- this.magnetSpeed = 350; // Speed towards player when close
- // Magnet radius is now a global variable potentially modified by powerups
- }
- draw(ctx) { /* ... (keep existing pulsating draw) ... */
- const screenPos = worldToScreen(this.x, this.y);
- ctx.fillStyle = this.color;
- ctx.beginPath();
- // Pulsating effect
- ctx.arc(screenPos.x, screenPos.y, this.size + Math.sin(gameTime * 6) * 1.5, 0, Math.PI * 2);
- ctx.fill();
- }
- update(dt, target) {
- if (!target) return;
- // Move towards player if close enough
- const magnetRadius = XP_ORB_MAGNET_RADIUS; // Use the potentially modified radius
- const dist = distance(this.x, this.y, target.x, target.y);
- if (dist < magnetRadius && dist > 1) { // Avoid division by zero if right on top
- const moveDir = normalize(target.x - this.x, target.y - this.y);
- // Move faster when closer, up to magnetSpeed
- const speedFactor = Math.min(1, (magnetRadius - dist) / magnetRadius) * 1.5; // Non-linear attraction
- this.x += moveDir.x * this.magnetSpeed * speedFactor * dt;
- this.y += moveDir.y * this.magnetSpeed * speedFactor * dt;
- }
- }
- }
- class AmmoPickup {
- constructor(x, y, ammoType, amount) {
- this.x = x;
- this.y = y;
- this.ammoType = ammoType;
- this.amount = amount;
- this.size = AMMO_PICKUP_SIZE;
- this.color = AMMO_PICKUP_COLOR; // Maybe different color per type?
- this.life = 20; // Disappears after some time
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- ctx.fillStyle = this.color;
- ctx.fillRect(screenPos.x - this.size / 2, screenPos.y - this.size / 2, this.size, this.size); // Simple square
- // Could draw text ('P', 'SG', 'SMG') on it
- ctx.fillStyle = 'black';
- ctx.font = 'bold 8px sans-serif';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- let ammoInitial = this.ammoType.substring(0, 1).toUpperCase();
- if (this.ammoType === 'shotgun') ammoInitial = 'SG';
- if (this.ammoType === 'smg') ammoInitial = 'SM';
- ctx.fillText(ammoInitial, screenPos.x, screenPos.y + 1);
- }
- update(dt) {
- this.life -= dt;
- // Simple bobbing motion
- this.y += Math.sin(gameTime * 3 + this.x) * 0.2; // Offset phase by x position
- }
- }
- class PowerUp {
- constructor(x, y, type, duration, color, symbol) {
- this.x = x;
- this.y = y;
- this.type = type; // e.g., 'invincibility', 'doubleDamage'
- this.duration = duration;
- this.size = POWERUP_SIZE;
- this.color = color;
- this.symbol = symbol; // Text symbol like 'S', '2x'
- this.life = 30; // Disappears after some time
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- // Draw background shape (e.g., star or circle)
- ctx.fillStyle = this.color;
- ctx.beginPath();
- // Simple star shape
- const outerRadius = this.size * 1.2;
- const innerRadius = this.size * 0.6;
- ctx.moveTo(screenPos.x, screenPos.y - outerRadius);
- for (let i = 0; i < 5; i++) {
- ctx.lineTo(screenPos.x + Math.cos((18 + i * 72) * Math.PI / 180) * outerRadius, screenPos.y - Math.sin((18 + i * 72) * Math.PI / 180) * outerRadius);
- ctx.lineTo(screenPos.x + Math.cos((54 + i * 72) * Math.PI / 180) * innerRadius, screenPos.y - Math.sin((54 + i * 72) * Math.PI / 180) * innerRadius);
- }
- ctx.closePath();
- ctx.fill();
- // Draw symbol
- ctx.fillStyle = 'white';
- ctx.font = 'bold 10px sans-serif';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.fillText(this.symbol, screenPos.x, screenPos.y + 1);
- }
- update(dt) {
- this.life -= dt;
- // Gentle rotation
- // this.angle = (this.angle || 0) + dt * 1; // Needs angle property and drawing rotation
- // Bobbing motion
- this.y += Math.sin(gameTime * 2.5 + this.x) * 0.3;
- }
- static createRandom(x, y) {
- const types = [
- {type: 'invincibility', duration: 5, color: 'white', symbol: '★'},
- {type: 'doubleDamage', duration: 10, color: 'red', symbol: '2x'},
- {type: 'speedBoost', duration: 10, color: 'lightblue', symbol: '>>'},
- {type: 'rapidFire', duration: 8, color: 'yellow', symbol: 'RF'},
- {type: 'xpMultiplier', duration: 15, color: 'lime', symbol: 'XP'},
- {type: 'magnetRadius', duration: 20, color: 'violet', symbol: 'M'}
- ];
- const choice = types[Math.floor(Math.random() * types.length)];
- return new PowerUp(x, y, choice.type, choice.duration, choice.color, choice.symbol);
- }
- }
- class Particle { /* ... (keep existing, but maybe add friction/gravity options later) ... */
- constructor(x, y, vx, vy, size, color, life, options = {}) {
- this.x = x;
- this.y = y;
- this.vx = vx;
- this.vy = vy;
- this.size = size;
- this.color = color;
- this.life = life;
- this.initialLife = life;
- this.friction = options.friction || 0.98; // Default friction
- this.gravity = options.gravity || 0; // Default no gravity
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- const lifeRatio = Math.max(0, this.life / this.initialLife);
- ctx.globalAlpha = lifeRatio; // Fade out
- ctx.fillStyle = this.color;
- ctx.beginPath();
- // Shrink based on lifeRatio - make shrinking optional or configurable?
- ctx.arc(screenPos.x, screenPos.y, this.size * lifeRatio, 0, Math.PI * 2);
- ctx.fill();
- ctx.globalAlpha = 1.0; // Reset alpha
- }
- update(dt) {
- this.x += this.vx * dt;
- this.y += this.vy * dt;
- this.vy += this.gravity * dt; // Apply gravity
- this.vx *= this.friction; // Apply friction
- this.vy *= this.friction;
- this.life -= dt;
- }
- }
- // --- Game Logic ---
- function init() {
- console.log("Initializing game...");
- isGameOver = false;
- gameContainerDiv.style.display = 'block'; // Show game area
- colorSelectionDiv.style.display = 'none'; // Hide color selection
- // Get selected player color
- const selectedButton = colorSelectionDiv.querySelector('button.selected');
- PLAYER_COLOR = selectedButton ? selectedButton.dataset.color : 'blue';
- canvas.width = window.innerWidth * 0.9;
- canvas.height = window.innerHeight * 0.9;
- if (typeof Noise === 'undefined') {
- console.warn("Noise library not found. Background will be simple grid.");
- noise = null;
- } else {
- noise = new Noise(Math.random()); // Initialize noise generator
- console.log("Noise library initialized for background.");
- }
- initAudio(); // Initialize Web Audio API context
- player = new Player(0, 0, PLAYER_COLOR);
- camera.x = player.x;
- camera.y = player.y;
- camera.shakeIntensity = 0;
- camera.shakeDuration = 0;
- // Reset game state arrays
- enemies = [];
- projectiles = [];
- xpOrbs = [];
- ammoPickups = [];
- powerUps = [];
- particles = [];
- keys = {};
- mouse = {x: 0, y: 0, down: false};
- currentWave = 1;
- gameTime = 0;
- choosingLevelUp = false;
- levelUpOptionsDiv.style.display = 'none';
- // Remove old listeners before adding new ones (important for restart)
- window.removeEventListener('keydown', handleKeyDown);
- window.removeEventListener('keyup', handleKeyUp);
- window.removeEventListener('mousedown', handleMouseDown);
- window.removeEventListener('mouseup', handleMouseUp);
- window.removeEventListener('mousemove', handleMouseMove);
- window.removeEventListener('resize', handleResize);
- // Add Event Listeners
- window.addEventListener('keydown', handleKeyDown);
- window.addEventListener('keyup', handleKeyUp);
- window.addEventListener('mousedown', handleMouseDown);
- window.addEventListener('mouseup', handleMouseUp);
- window.addEventListener('mousemove', handleMouseMove);
- window.addEventListener('resize', handleResize);
- startWave();
- lastTime = performance.now();
- updateUI(); // Initial UI update
- requestAnimationFrame(gameLoop);
- console.log("Game initialized and loop started.");
- }
- // --- Event Handlers --- (Separate functions for easier removal)
- function handleKeyDown(e) {
- keys[e.key.toLowerCase()] = true;
- }
- function handleKeyUp(e) {
- keys[e.key.toLowerCase()] = false;
- }
- function handleMouseDown(e) {
- if (e.button === 0) mouse.down = true;
- }
- function handleMouseUp(e) {
- if (e.button === 0) mouse.down = false;
- }
- function handleMouseMove(e) {
- const rect = canvas.getBoundingClientRect();
- mouse.x = e.clientX - rect.left;
- mouse.y = e.clientY - rect.top;
- }
- function handleResize() {
- canvas.width = window.innerWidth * 0.9;
- canvas.height = window.innerHeight * 0.9;
- // Optional: Adjust camera or UI if needed on resize
- }
- function setupColorSelection() {
- colorSelectionDiv.querySelectorAll('button').forEach(button => {
- button.style.backgroundColor = button.dataset.color; // Show color on button
- button.addEventListener('click', () => {
- colorSelectionDiv.querySelector('button.selected')?.classList.remove('selected');
- button.classList.add('selected');
- });
- });
- // Add a start button
- const startButton = document.createElement('button');
- startButton.textContent = 'Start Game';
- startButton.style.marginTop = '10px';
- startButton.style.padding = '10px 20px';
- startButton.onclick = init; // Call init when start is clicked
- colorSelectionDiv.appendChild(startButton);
- }
- function startWave() {
- enemiesToSpawn = 5 + Math.floor(currentWave * 1.8); // Slightly more enemies per wave
- enemiesAlive = 0; // Reset count of active enemies
- spawningDone = false;
- timeToNextSpawn = 1.0; // Longer initial delay for wave start
- console.log(`Starting Wave ${currentWave} with ${enemiesToSpawn} enemies.`);
- // Maybe wave start announcement/effect?
- updateUI();
- }
- function spawnEnemy() {
- if (enemiesToSpawn <= 0) {
- spawningDone = true;
- return;
- }
- // Spawn enemies off-screen relative to the camera's view
- const spawnDist = Math.max(canvas.width, canvas.height) * 0.7; // Spawn closer than before?
- const angle = Math.random() * Math.PI * 2;
- const spawnX = player.x + Math.cos(angle) * spawnDist;
- const spawnY = player.y + Math.sin(angle) * spawnDist;
- // --- Choose Enemy Type ---
- let enemyTypeChoice = Math.random();
- let newEnemy;
- if (currentWave > 3 && enemyTypeChoice < 0.25) { // Introduce tanks after wave 3
- newEnemy = new TankEnemy(spawnX, spawnY, currentWave);
- } else if (currentWave > 1 && enemyTypeChoice < 0.6) { // Introduce fast enemies after wave 1
- newEnemy = new FastEnemy(spawnX, spawnY, currentWave);
- } else {
- newEnemy = new Enemy(spawnX, spawnY, currentWave); // Standard enemy
- }
- enemies.push(newEnemy);
- enemiesToSpawn--;
- enemiesAlive++; // Increment count of enemies currently alive
- updateUI();
- // Time until next spawn decreases with wave number, but has a minimum
- timeToNextSpawn = Math.max(0.08, 1.0 - currentWave * 0.04);
- }
- function update(dt) {
- if (isGameOver || !player) return; // Stop updates if game over or player missing
- gameTime += dt;
- // Update Camera Shake
- if (camera.shakeDuration > 0) {
- camera.shakeDuration -= dt;
- camera.shakeIntensity *= CAMERA_SHAKE_DECAY; // Dampen shake
- if (camera.shakeDuration <= 0) {
- camera.shakeIntensity = 0;
- camera.shakeDuration = 0;
- }
- }
- // Don't update game logic while choosing level up? (Current setup doesn't pause)
- // if (choosingLevelUp) return;
- player.update(dt);
- // Update Camera to follow player smoothly
- const cameraLag = 0.08; // Smaller value = tighter follow
- camera.x += (player.x - camera.x) * cameraLag;
- camera.y += (player.y - camera.y) * cameraLag;
- // Spawning
- if (!spawningDone) {
- timeToNextSpawn -= dt;
- if (timeToNextSpawn <= 0) {
- spawnEnemy();
- }
- }
- // Update Enemies
- enemies.forEach(enemy => enemy.update(dt, player));
- // Update Projectiles
- projectiles.forEach(p => p.update(dt));
- // Update XP Orbs
- xpOrbs.forEach(orb => orb.update(dt, player));
- // Update Ammo Pickups
- ammoPickups.forEach(ap => ap.update(dt));
- // Update PowerUps
- powerUps.forEach(pu => pu.update(dt));
- // Update Particles
- particles.forEach(p => p.update(dt));
- // --- Cleanup Dead/Expired Entities ---
- projectiles = projectiles.filter(p => p.life > 0);
- enemies = enemies.filter(e => e.health > 0); // Filter out dead enemies AFTER processing hits/death logic
- xpOrbs = xpOrbs.filter(orb => distance(player.x, player.y, orb.x, orb.y) > 1); // Keep orbs not collected yet
- ammoPickups = ammoPickups.filter(ap => ap.life > 0 && distance(player.x, player.y, ap.x, ap.y) > player.size); // Keep if alive and not collected
- powerUps = powerUps.filter(pu => pu.life > 0 && distance(player.x, player.y, pu.x, pu.y) > player.size); // Keep if alive and not collected
- particles = particles.filter(p => p.life > 0);
- // Recalculate enemiesAlive count after filtering
- enemiesAlive = enemies.length;
- // Collisions
- checkCollisions();
- // Check for next wave (only if player exists)
- if (player && spawningDone && enemiesAlive <= 0) {
- currentWave++;
- startWave();
- // Maybe give a small health/ammo bonus between waves?
- player.health = Math.min(player.maxHealth, player.health + player.maxHealth * 0.1); // Heal 10%
- updateUI();
- }
- }
- function checkCollisions() {
- if (!player) return;
- // Projectile vs Enemy
- for (let i = projectiles.length - 1; i >= 0; i--) {
- const p = projectiles[i];
- let projectileRemoved = false;
- for (let j = enemies.length - 1; j >= 0; j--) {
- if (projectileRemoved) break; // Projectile already hit something
- const e = enemies[j];
- if (e.health <= 0) continue; // Skip dead enemies
- // More accurate circle-shape collision check
- const distSq = (p.x - e.x) ** 2 + (p.y - e.y) ** 2;
- const radiiSumSq = (p.size + e.size / 2) ** 2; // Approximate enemy radius
- if (distSq < radiiSumSq) {
- const died = e.takeDamage(p.damage);
- playSound('hit_enemy'); // Placeholder
- // Remove projectile (unless it pierces - add logic later)
- projectiles.splice(i, 1);
- projectileRemoved = true;
- if (died) {
- e.onDeath(); // Handle drops, particles etc.
- // Enemy removal is handled in the main update loop's filter
- updateUI(); // Update enemies left count
- }
- // break; // Projectile hits only one enemy (usually)
- }
- }
- }
- // Player vs Enemy
- enemies.forEach(e => {
- if (e.health <= 0 || !player) return; // Skip dead enemies or if player gone
- const distBetween = distance(player.x, player.y, e.x, e.y);
- const collisionThreshold = player.size + e.size / 2;
- if (distBetween < collisionThreshold) {
- // Apply damage over time based on overlap? Or just per-touch?
- // Let's stick to damage-per-frame-of-contact for now
- player.takeDamage(e.damage * (1 / 60)); // Damage scaled by typical frame time
- // Push apart based on overlap
- const overlap = collisionThreshold - distBetween;
- if (distBetween > 0.1) { // Avoid division by zero if perfectly overlapped
- const moveDir = normalize(player.x - e.x, player.y - e.y);
- const pushForce = overlap * 0.5; // Push strength
- player.x += moveDir.x * pushForce;
- player.y += moveDir.y * pushForce;
- e.x -= moveDir.x * pushForce;
- e.y -= moveDir.y * pushForce;
- } else { // If perfectly overlapped, push in a random direction slightly
- const randAngle = Math.random() * Math.PI * 2;
- player.x += Math.cos(randAngle) * 0.5;
- player.y += Math.sin(randAngle) * 0.5;
- }
- }
- });
- // Player vs XP Orb
- for (let i = xpOrbs.length - 1; i >= 0; i--) {
- const orb = xpOrbs[i];
- if (distance(player.x, player.y, orb.x, orb.y) < player.size + orb.size) {
- player.addXp(orb.value);
- xpOrbs.splice(i, 1); // Remove orb
- playSound('pickup_xp'); // Placeholder
- createParticles(player.x, player.y, 3, XP_ORB_COLOR, 30, null, Math.PI * 2, {life: 0.2}); // Small pickup effect
- }
- }
- // Player vs Ammo Pickup
- for (let i = ammoPickups.length - 1; i >= 0; i--) {
- const pickup = ammoPickups[i];
- if (distance(player.x, player.y, pickup.x, pickup.y) < player.size + pickup.size) {
- const weapon = player.weapons.find(w => w.ammoType === pickup.ammoType);
- if (weapon) {
- const currentAmmo = player.ammo[pickup.ammoType] || 0;
- const maxAmmo = weapon.maxAmmo;
- player.ammo[pickup.ammoType] = Math.min(maxAmmo, currentAmmo + pickup.amount);
- ammoPickups.splice(i, 1);
- console.log(`Picked up ${pickup.amount} ${pickup.ammoType} ammo.`);
- playSound('pickup_ammo'); // Placeholder
- updateUI();
- createParticles(player.x, player.y, 5, AMMO_PICKUP_COLOR, 40, null, Math.PI * 2, {life: 0.3});
- } else {
- // Maybe allow picking up even if no weapon uses it yet?
- // console.log(`No weapon uses ammo type: ${pickup.ammoType}`);
- ammoPickups.splice(i, 1); // Remove anyway to avoid clutter
- }
- }
- }
- // Player vs PowerUp
- for (let i = powerUps.length - 1; i >= 0; i--) {
- const pu = powerUps[i];
- if (distance(player.x, player.y, pu.x, pu.y) < player.size + pu.size) {
- player.activatePowerUp(pu.type, pu.duration);
- powerUps.splice(i, 1);
- // Powerup pickup particle effect
- createParticles(player.x, player.y, 20, pu.color, 150, null, Math.PI * 2, {life: 0.6});
- }
- }
- }
- function render() {
- // Clear canvas
- // Use fillRect for background color instead of relying on body style
- ctx.fillStyle = '#1a1a1a'; // Canvas background color
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- // Draw background (Noise or Grid)
- drawBackground();
- // --- Draw Game Objects --- (Order matters for layering)
- // Draw Pickups first (lowest layer)
- xpOrbs.forEach(orb => orb.draw(ctx));
- ammoPickups.forEach(ap => ap.draw(ctx));
- powerUps.forEach(pu => pu.draw(ctx));
- // Draw Enemies
- enemies.forEach(enemy => enemy.draw(ctx));
- // Draw Player
- if (player) {
- player.draw(ctx);
- }
- // Draw Projectiles
- projectiles.forEach(p => p.draw(ctx));
- // Draw Particles (Highest layer)
- particles.forEach(p => p.draw(ctx));
- }
- function drawBackground() {
- if (noise) {
- // Procedural Noise Background
- const TILE_RENDER_SIZE = GRID_SIZE; // Size to render each noise 'pixel'
- ctx.globalAlpha = 0.4; // Make background less prominent
- // Calculate visible world bounds based on camera and canvas size
- const viewWidth = canvas.width * 1.1; // Render slightly larger than view
- const viewHeight = canvas.height * 1.1;
- const startWorldX = camera.x - viewWidth / 2;
- const startWorldY = camera.y - viewHeight / 2;
- const endWorldX = startWorldX + viewWidth;
- const endWorldY = startWorldY + viewHeight;
- // Determine tile range to draw
- const startTileX = Math.floor(startWorldX / TILE_RENDER_SIZE);
- const startTileY = Math.floor(startWorldY / TILE_RENDER_SIZE);
- const endTileX = Math.ceil(endWorldX / TILE_RENDER_SIZE);
- const endTileY = Math.ceil(endWorldY / TILE_RENDER_SIZE);
- // Color variation based on wave (simple example: shift hue)
- const baseHue = 180 + (currentWave * 5) % 180; // Shifts from cyan towards green/yellow/red over waves
- for (let ty = startTileY; ty < endTileY; ty++) {
- for (let tx = startTileX; tx < endTileX; tx++) {
- const worldX = tx * TILE_RENDER_SIZE;
- const worldY = ty * TILE_RENDER_SIZE;
- // Get noise value for this tile's world position
- // Divide by noise scale to control the 'zoom' level of the noise pattern
- const noiseValue = noise.simplex2(worldX / BACKGROUND_NOISE_SCALE, worldY / BACKGROUND_NOISE_SCALE); // Value between -1 and 1
- // Map noise value to color (e.g., grayscale or color variations)
- // Example: Map to lightness/saturation variations of the base hue
- const lightness = Math.floor(5 + (noiseValue + 1) / 2 * 20); // Map -1..1 to 5..25
- const saturation = Math.floor(30 + (noiseValue + 1) / 2 * 30); // Map -1..1 to 30..60
- ctx.fillStyle = `hsl(${baseHue}, ${saturation}%, ${lightness}%)`;
- // Convert tile world coords to screen coords for drawing
- const screenPos = worldToScreen(worldX, worldY);
- ctx.fillRect(screenPos.x - TILE_RENDER_SIZE / 2, screenPos.y - TILE_RENDER_SIZE / 2, TILE_RENDER_SIZE, TILE_RENDER_SIZE); // Draw tile centered? No, top-left
- // Correction: Calculate top-left screen pos directly
- const screenTileX = (worldX - camera.x + canvas.width / 2);
- const screenTileY = (worldY - camera.y + canvas.height / 2);
- ctx.fillRect(screenTileX, screenTileY, TILE_RENDER_SIZE, TILE_RENDER_SIZE);
- }
- }
- ctx.globalAlpha = 1.0; // Reset alpha
- } else {
- // Fallback Grid Background
- ctx.strokeStyle = '#444';
- ctx.lineWidth = 1;
- const startWorldX = camera.x - canvas.width / 2;
- const startWorldY = camera.y - canvas.height / 2;
- const offsetX = startWorldX % GRID_SIZE;
- const offsetY = startWorldY % GRID_SIZE;
- const startScreenX = -offsetX;
- const startScreenY = -offsetY;
- for (let x = startScreenX; x < canvas.width; x += GRID_SIZE) {
- ctx.beginPath();
- ctx.moveTo(x, 0);
- ctx.lineTo(x, canvas.height);
- ctx.stroke();
- }
- for (let y = startScreenY; y < canvas.height; y += GRID_SIZE) {
- ctx.beginPath();
- ctx.moveTo(0, y);
- ctx.lineTo(canvas.width, y);
- ctx.stroke();
- }
- }
- }
- function createParticles(x, y, count, color, speed, baseAngle = null, spread = Math.PI * 2, options = {}) {
- if (particles.length > MAX_PARTICLES - count) {
- // Prune oldest particles if limit reached - shift based on count needed
- particles.splice(0, particles.length + count - MAX_PARTICLES);
- }
- for (let i = 0; i < count; i++) {
- const angle = (baseAngle !== null ? baseAngle : Math.random() * Math.PI * 2) + (Math.random() - 0.5) * spread;
- const currentSpeed = speed * (0.5 + Math.random()); // Vary speed more (0.5x to 1.5x)
- const vx = Math.cos(angle) * currentSpeed;
- const vy = Math.sin(angle) * currentSpeed;
- const size = options.size || (Math.random() * 2 + 1);
- const life = (options.life || PARTICLE_LIFETIME) * (0.7 + Math.random() * 0.6); // Vary life more
- // Allow color variations slightly
- let particleColor = color;
- if (options.colorVariance) {
- // Example: Adjust lightness slightly
- // This requires parsing hsl, modifying, and converting back, or using a library.
- // Simple approach: Assume color is hex, slightly change value (crude)
- // For HSL colors, easier: `hsl(${hue}, ${sat}%, ${light + random(-5,5)}%)`
- }
- particles.push(new Particle(x, y, vx, vy, size, particleColor, life, options)); // Pass options
- }
- }
- function updateUI() {
- if (!player) return; // Don't update if player doesn't exist
- uiLevel.textContent = player.level;
- uiXp.textContent = Math.floor(player.xp);
- uiXpNeeded.textContent = player.xpToNextLevel;
- uiHealth.textContent = Math.ceil(player.health);
- uiMaxHealth.textContent = player.maxHealth;
- uiWave.textContent = currentWave;
- uiEnemiesLeft.textContent = enemiesAlive + enemiesToSpawn; // Total remaining for the wave
- // Weapon and Ammo
- uiCurrentWeapon.textContent = player.currentWeapon.name;
- const ammoType = player.currentWeapon.ammoType;
- if (ammoType) {
- const currentAmmo = player.ammo[ammoType] || 0;
- uiAmmo.textContent = `${currentAmmo} / ${player.currentWeapon.maxAmmo}`;
- } else if (player.currentWeapon.type === 'melee') {
- uiAmmo.textContent = `Swing: ${player.currentWeapon.rateOfFire}/s`;
- } else {
- uiAmmo.textContent = "Inf"; // Infinite ammo or N/A
- }
- // Power-up Status
- let powerUpHtml = '';
- for (const type in player.powerUps) {
- if (player.powerUps[type] > 0) {
- powerUpHtml += `<div class="power-up-timer">${type}: ${player.powerUps[type].toFixed(1)}s</div>`;
- }
- }
- uiPowerUpStatus.innerHTML = powerUpHtml;
- }
- function presentLevelUpOptions() {
- if (!player) return;
- choosingLevelUp = true;
- levelUpOptionsDiv.innerHTML = ''; // Clear previous options
- levelUpOptionsDiv.style.display = 'flex';
- const options = generateUpgradeOptions(3); // Get 3 options
- options.forEach(option => {
- const button = document.createElement('button');
- button.classList.add('level-up-button');
- button.textContent = option.description;
- button.onclick = () => {
- applyUpgrade(option);
- levelUpOptionsDiv.style.display = 'none';
- choosingLevelUp = false;
- createParticles(player.x, player.y, 30, 'gold', 100); // Particle burst on choice
- playSound('upgrade_select'); // Placeholder
- };
- levelUpOptionsDiv.appendChild(button);
- });
- }
- function generateUpgradeOptions(count) {
- if (!player) return [];
- const possibleUpgrades = [];
- // --- Player Stat Upgrades ---
- possibleUpgrades.push({type: 'player_stat', stat: 'maxHealth', value: 20, description: 'Max Health +20'});
- possibleUpgrades.push({type: 'player_stat', stat: 'speed', value: 15, description: 'Move Speed +15'});
- possibleUpgrades.push({type: 'player_stat', stat: 'magnet', value: 25, description: 'XP Magnet Radius +25%'}); // Value as percentage increase
- // --- Weapon Specific Upgrades ---
- player.weapons.forEach((weapon, index) => {
- if (weapon.type === 'ranged') {
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'damage',
- value: Math.ceil(weapon.damage * 0.15),
- description: `${weapon.name} Damage +${Math.ceil(weapon.damage * 0.15)}`
- }); // Scale increase by base damage
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'rateOfFire',
- value: weapon.rateOfFire * 0.1,
- description: `${weapon.name} Fire Rate +10%`
- }); // Percentage based increase
- if (weapon.maxAmmo) {
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'maxAmmo',
- value: Math.ceil(weapon.maxAmmo * 0.2),
- description: `${weapon.name} Max Ammo +20%`
- });
- }
- if (weapon.projectileCount > 1) { // Shotgun spread reduce?
- // possibleUpgrades.push({ type: 'weapon_stat', index: index, stat: 'spreadAngle', value: -0.05, description: `${weapon.name} Spread -0.05rad` });
- }
- if (weapon.recoil > 0) {
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'recoil',
- value: -weapon.recoil * 0.15,
- description: `${weapon.name} Recoil -15%`
- });
- }
- // Could add projectile speed, accuracy etc.
- } else if (weapon.type === 'melee') {
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'damage',
- value: Math.ceil(weapon.damage * 0.2),
- description: `${weapon.name} Damage +${Math.ceil(weapon.damage * 0.2)}`
- });
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'rateOfFire',
- value: weapon.rateOfFire * 0.15,
- description: `${weapon.name} Swing Speed +15%`
- });
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'range',
- value: 5,
- description: `${weapon.name} Range +5`
- });
- // Could add swing arc?
- }
- });
- // Add finding a new weapon? (More complex logic needed)
- // Shuffle and pick 'count' unique options
- const shuffled = possibleUpgrades.sort(() => 0.5 - Math.random());
- // Ensure variety? (e.g., avoid offering 3 upgrades for the same weapon) - simple slice for now
- return shuffled.slice(0, count);
- }
- function applyUpgrade(option) {
- if (!player) return;
- console.log("Applying upgrade:", option.description);
- if (option.type === 'weapon_stat') {
- const weapon = player.weapons[option.index];
- if (weapon) {
- weapon[option.stat] += option.value;
- // Ensure some stats don't go below reasonable minimums
- if (option.stat === 'rateOfFire') weapon[option.stat] = Math.max(0.1, weapon[option.stat]);
- if (option.stat === 'spreadAngle') weapon[option.stat] = Math.max(0, weapon[option.stat]);
- if (option.stat === 'recoil') weapon[option.stat] = Math.max(0, weapon[option.stat]);
- console.log(`New ${weapon.name} ${option.stat}: ${weapon[option.stat].toFixed(2)}`);
- // If current weapon was upgraded, update UI immediately
- if (player.weapons[player.currentWeaponIndex] === weapon) {
- updateUI();
- }
- } else {
- console.warn(`Tried to upgrade non-existent weapon at index: ${option.index}`);
- }
- } else if (option.type === 'player_stat') {
- if (option.stat === 'maxHealth') {
- player.maxHealth += option.value;
- player.health += option.value; // Also heal by the amount increased
- } else if (option.stat === 'speed') {
- player.baseSpeed += option.value; // Upgrade the base speed
- player.speed = player.baseSpeed * (player.powerUps.speedBoost > 0 ? 1.5 : 1); // Reapply boost if active
- } else if (option.stat === 'magnet') {
- player.baseMagnetRadius *= (1 + option.value / 100); // Increase base radius by percentage
- XP_ORB_MAGNET_RADIUS = player.baseMagnetRadius * (player.powerUps.magnetRadius > 0 ? 2.0 : 1); // Reapply boost
- }
- console.log(`New player ${option.stat}: ${player[option.stat] !== undefined ? player[option.stat] : player.baseMagnetRadius}`);
- }
- updateUI(); // Update UI for player stats or potentially weapon stats if current
- }
- function gameOver() {
- if (isGameOver) return; // Prevent multiple calls
- isGameOver = true;
- console.log("Game Over!");
- playSound('game_over'); // Placeholder
- // More dramatic effect? Stop sounds, screen fade?
- applyCameraShake(30, 0.5); // Final shake
- // Use timeout to allow effects to play before alert/reset
- setTimeout(() => {
- alert(`Game Over! You reached Wave ${currentWave} and Level ${player.level}.`);
- // Clean reset
- player = null; // Stop rendering/updating player specifically
- enemies = [];
- projectiles = [];
- xpOrbs = [];
- ammoPickups = [];
- powerUps = [];
- particles = [];
- keys = {};
- mouse = {x: 0, y: 0, down: false};
- choosingLevelUp = false;
- levelUpOptionsDiv.style.display = 'none';
- gameContainerDiv.style.display = 'none'; // Hide game area
- colorSelectionDiv.style.display = 'block'; // Show color selection again
- // Remove listeners to prevent duplicates on restart
- window.removeEventListener('keydown', handleKeyDown);
- window.removeEventListener('keyup', handleKeyUp);
- window.removeEventListener('mousedown', handleMouseDown);
- window.removeEventListener('mouseup', handleMouseUp);
- window.removeEventListener('mousemove', handleMouseMove);
- window.removeEventListener('resize', handleResize);
- // Don't automatically restart, wait for player to click start again
- console.log("Game state reset. Ready for new game.");
- }, 500); // Delay before alert/reset
- }
- let animationFrameId = null; // To potentially cancel the loop
- function gameLoop(currentTime) {
- if (isGameOver && player === null) { // Ensure loop stops cleanly after reset
- console.log("Game loop stopped.");
- cancelAnimationFrame(animationFrameId);
- return;
- }
- const dt = Math.min(0.05, (currentTime - lastTime) / 1000); // Delta time in seconds, with frame cap
- lastTime = currentTime;
- if (!choosingLevelUp) { // Only update game state if not in level up screen (optional pause)
- update(dt);
- } else {
- // Optionally update *some* things while paused, like particles or UI animations
- gameTime += dt; // Keep game time running for animations?
- particles.forEach(p => p.update(dt));
- particles = particles.filter(p => p.life > 0);
- // Update Camera Shake even during level up pause
- if (camera.shakeDuration > 0) {
- camera.shakeDuration -= dt;
- camera.shakeIntensity *= CAMERA_SHAKE_DECAY;
- if (camera.shakeDuration <= 0) {
- camera.shakeIntensity = 0;
- camera.shakeDuration = 0;
- }
- }
- }
- render();
- updateUI(); // Update UI constantly, even during level up
- animationFrameId = requestAnimationFrame(gameLoop);
- }
- // --- Start Game ---
- // Set up the color selection UI first, which then calls init()
- window.onload = setupColorSelection;
- </script>
- </body>
- </html>
Advertisement
Add Comment
Please, Sign In to add comment