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.7); /* Slightly more opaque */
- padding: 10px;
- border-radius: 5px;
- font-size: 0.9em;
- z-index: 5; /* Ensure UI is above background/game */
- min-width: 200px; /* Ensure some minimum width */
- }
- #ui div {
- margin-bottom: 5px; /* Increased spacing slightly */
- white-space: nowrap; /* Prevent wrapping */
- }
- #stamina-bar-container {
- width: 100px;
- height: 8px;
- background-color: #555;
- border: 1px solid #888;
- border-radius: 2px;
- overflow: hidden;
- display: inline-block; /* Align with text */
- vertical-align: middle; /* Align with text */
- margin-left: 5px;
- }
- #stamina-bar {
- width: 100%;
- height: 100%;
- background-color: #4CAF50; /* Green */
- transition: width 0.1s linear;
- }
- #level-up-indicator {
- position: absolute;
- top: 10px;
- right: 10px; /* Position top-right */
- background: rgba(255, 215, 0, 0.8); /* Gold background */
- color: black;
- padding: 8px 15px;
- border-radius: 5px;
- font-weight: bold;
- font-size: 1.1em;
- display: none; /* Initially hidden */
- cursor: pointer; /* Indicate it's clickable */
- z-index: 15; /* Above other UI */
- border: 2px solid white;
- box-shadow: 0 0 10px gold;
- transition: transform 0.1s ease-out;
- }
- /* Container for floating damage text */
- #damage-text-container {
- position: absolute;
- /* --- ADD THESE --- */
- top: 0;
- left: 0;
- /* --- END ADD --- */
- /* width/height are set by JS */
- pointer-events: none;
- overflow: hidden; /* Optional: prevent text spilling if calculations are slightly off */
- z-index: 20; /* Ensure it's above canvas, below UI popups if needed */
- }
- #level-up-indicator:hover {
- transform: scale(1.05);
- }
- #level-up-options {
- position: absolute;
- top: 50px; /* Below the indicator */
- right: 10px;
- display: none; /* Initially hidden */
- flex-direction: column; /* Stack options vertically */
- gap: 10px;
- z-index: 10; /* Below indicator but above game */
- background: rgba(0, 0, 0, 0.85); /* Darker background */
- padding: 15px;
- border-radius: 8px;
- border: 1px solid #666;
- min-width: 250px; /* Ensure buttons fit */
- }
- .level-up-button {
- padding: 12px 20px;
- background-color: #4CAF50;
- color: white;
- border: none;
- border-radius: 6px; /* Slightly less rounded */
- cursor: pointer;
- font-size: 0.95em; /* Slightly smaller font */
- transition: background-color 0.3s, transform 0.1s;
- text-align: left; /* Align text left */
- }
- .level-up-button:hover {
- background-color: #45a049;
- transform: translateX(-3px); /* Slight move on hover */
- }
- #color-selection {
- margin-bottom: 15px;
- display: block; /* Initially visible */
- text-align: center; /* Center the selection */
- }
- #color-selection span {
- display: block;
- margin-bottom: 10px;
- }
- #color-selection button {
- padding: 8px 15px;
- margin: 0 5px 5px 5px; /* Added bottom margin */
- cursor: pointer;
- border: 1px solid white;
- background: none;
- color: white;
- border-radius: 4px;
- min-width: 60px; /* Ensure color is visible */
- }
- #color-selection button.selected {
- background-color: white !important; /* Ensure selected style overrides */
- color: black;
- }
- #game-container {
- position: relative; /* Needed for absolute positioning of UI */
- display: none; /* Hidden until game starts */
- }
- /* Style for power-up timer display */
- .power-up-timer {
- color: yellow;
- font-weight: bold;
- }
- /* Style for Damage Text */
- .damage-text {
- position: absolute;
- font-weight: bold;
- /* font-size is set by JS */
- text-shadow: 1px 1px 2px black;
- pointer-events: none;
- white-space: nowrap;
- z-index: 20;
- /* Define transition properties */
- transition: opacity 0.5s linear, transform 0.5s ease-out;
- /* Initial state set mostly by JS now, but can define defaults */
- opacity: 0; /* Start invisible before JS sets it */
- transform: translateX(-50%); /* Default horizontal centering */
- }
- .damage-text.burn {
- color: orange !important; /* Override default color */
- /* font-size adjusted dynamically like normal damage */
- }
- /* Reloading indicator style */
- #reload-indicator {
- position: absolute;
- bottom: 10px; /* Position near bottom center */
- left: 50%;
- transform: translateX(-50%);
- background: rgba(255, 0, 0, 0.7);
- color: white;
- padding: 5px 10px;
- border-radius: 4px;
- font-weight: bold;
- display: none; /* Hidden initially */
- z-index: 6;
- }
- /* Shop Button Style */
- #shop-button {
- position: absolute;
- bottom: 10px;
- left: 10px;
- padding: 10px 15px;
- background-color: #007bff;
- color: white;
- border: none;
- border-radius: 5px;
- cursor: pointer;
- font-size: 1em;
- font-weight: bold;
- z-index: 10;
- transition: background-color 0.3s;
- }
- #shop-button:hover {
- background-color: #0056b3;
- }
- /* Shop Screen Style */
- #shop-screen {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 80%;
- max-width: 500px;
- height: 70%;
- max-height: 450px;
- background: rgba(30, 30, 30, 0.95);
- border: 2px solid #888;
- border-radius: 10px;
- padding: 20px;
- display: none; /* Hidden by default */
- flex-direction: column;
- z-index: 50; /* Above everything else */
- overflow-y: auto; /* Allow scrolling */
- color: white;
- box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
- }
- #shop-screen h2 {
- text-align: center;
- margin-top: 0;
- margin-bottom: 20px;
- color: #FFD700; /* Gold color for title */
- }
- #shop-screen .shop-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 10px;
- margin-bottom: 10px;
- background: rgba(50, 50, 50, 0.8);
- border-radius: 5px;
- }
- #shop-screen .shop-item-info {
- flex-grow: 1;
- margin-right: 15px;
- }
- #shop-screen .shop-item-name {
- font-weight: bold;
- font-size: 1.1em;
- }
- #shop-screen .shop-item-desc {
- font-size: 0.9em;
- color: #ccc;
- }
- #shop-screen .shop-buy-button {
- padding: 8px 12px;
- background-color: #28a745;
- color: white;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- font-weight: bold;
- white-space: nowrap;
- transition: background-color 0.2s;
- }
- #shop-screen .shop-buy-button:hover:not(:disabled) {
- background-color: #218838;
- }
- #shop-screen .shop-buy-button:disabled {
- background-color: #6c757d; /* Grey out disabled */
- cursor: not-allowed;
- }
- #shop-close-button {
- position: absolute;
- top: 10px;
- right: 10px;
- background: #dc3545;
- color: white;
- border: none;
- border-radius: 50%;
- width: 25px;
- height: 25px;
- font-weight: bold;
- cursor: pointer;
- line-height: 25px; /* Center 'X' vertically */
- text-align: center;
- }
- #shop-close-button:hover {
- background: #c82333;
- }
- </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>
- <button data-color="#33ff33">Green</button> <!-- Added green -->
- </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>Stamina:
- <div id="stamina-bar-container">
- <div id="stamina-bar"></div>
- </div>
- </div>
- <div>Money: $<span id="money">0</span></div> <!-- Money Display -->
- <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-indicator">LEVEL UP! (<span id="pending-levelups">0</span>)</div>
- <div id="level-up-options"></div>
- <div id="reload-indicator">RELOADING...</div>
- <button id="shop-button">Shop (B)</button> <!-- Shop Button -->
- <canvas id="gameCanvas"></canvas>
- <!-- Container for floating damage text -->
- <div id="damage-text-container"></div>
- <!-- Shop Screen -->
- <div id="shop-screen">
- <button id="shop-close-button">X</button>
- <h2>Ye Olde Upgrade Shoppe</h2>
- <div id="shop-items-container">
- <!-- Shop items will be populated by script -->
- </div>
- </div>
- </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 uiStaminaBar = document.getElementById('stamina-bar'); // Stamina UI
- const uiMoney = document.getElementById('money'); // Money UI
- 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 levelUpIndicator = document.getElementById('level-up-indicator');
- const pendingLevelUpsSpan = document.getElementById('pending-levelups');
- const reloadIndicatorDiv = document.getElementById('reload-indicator');
- const colorSelectionDiv = document.getElementById('color-selection');
- const gameContainerDiv = document.getElementById('game-container');
- const damageTextContainer = document.getElementById('damage-text-container');
- const shopButton = document.getElementById('shop-button');
- const shopScreen = document.getElementById('shop-screen');
- const shopCloseButton = document.getElementById('shop-close-button');
- const shopItemsContainer = document.getElementById('shop-items-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 PLAYER_BASE_MAX_STAMINA = 100;
- const PLAYER_BASE_STAMINA_REGEN = 25; // Stamina points per second
- const PLAYER_STAMINA_CONSUMPTION = 40; // Stamina points per second while sprinting
- const PLAYER_SPRINT_MULTIPLIER = 1.6; // Speed multiplier when sprinting
- const ENEMY_START_SIZE = 20;
- const ENEMY_START_HEALTH = 50;
- const ENEMY_START_SPEED = 125; // Increased base speed
- 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 MONEY_PICKUP_SIZE_MIN = 4;
- const MONEY_PICKUP_SIZE_MAX = 8;
- const MONEY_PICKUP_COLOR = '#FFD700'; // Gold
- let MONEY_PICKUP_MAGNET_RADIUS = 90; // Similar to XP orbs
- const MONEY_PICKUP_MAGNET_SPEED = 450;
- const MONEY_PICKUP_FRICTION = 0.9;
- const MONEY_PICKUP_LIFE = 30; // Seconds before disappearing
- const BASE_MONEY_DROP_MIN = 1;
- const BASE_MONEY_DROP_MAX = 250;
- const MONEY_DROP_CHANCE = 0.95; // High chance to drop at least *some* money
- const AMMO_PICKUP_SIZE = 6;
- const AMMO_PICKUP_COLOR = 'lightblue';
- const POWERUP_SIZE = 10;
- const POWERUP_SPAWN_CHANCE = 0.09; // Slightly increased chance for powerups
- const XP_PER_KILL = 25;
- const BASE_XP_TO_LEVEL = 100;
- const XP_LEVEL_MULTIPLIER = 1.05;
- const HEALTH_BAR_HEIGHT = 5;
- const HEALTH_BAR_WIDTH_FACTOR = 1.5;
- const PARTICLE_LIFETIME = 0.5; // seconds
- const MAX_PARTICLES = 5000; // Increased max particles
- const GRID_SIZE = 50;
- const BACKGROUND_NOISE_SCALE = 250;
- const CAMERA_SHAKE_DECAY = 0.85;
- const AMMO_DROP_CHANCE = 0.55;
- const DAMAGE_TEXT_DURATION = 0.7; // seconds
- const DAMAGE_TEXT_SPEED = 50; // pixels per second upward drift
- const RELOAD_KEY = 'r';
- const SPRINT_KEY = 'shift'; // Sprint key
- const SHOP_KEY = 'b'; // Shop toggle key
- const PLAYER_HIT_SOUND_COOLDOWN = 0.01;
- const OFFSCREEN_INDICATOR_SIZE = 10;
- const OFFSCREEN_INDICATOR_MARGIN = 20; // Distance from edge
- const OFFSCREEN_INDICATOR_MAX_DIST_OPACITY = 1500; // Distance where indicator is fully opaque
- const OFFSCREEN_INDICATOR_MIN_DIST_OPACITY = 400; // Distance where indicator starts fading in
- const SHIELD_ORB_COUNT = 3;
- const SHIELD_ORB_RADIUS = 50;
- const SHIELD_ORB_ROTATION_SPEED = 2.5; // Radians per second
- const SHIELD_ORB_DAMAGE = 30; // Damage per hit
- const SHIELD_ORB_HIT_COOLDOWN = 0.3; // Seconds between hits per enemy per orb
- const SHIELD_ORB_PARTICLE_RATE = 2; // Particles per orb per second
- const TURRET_COST = 500;
- const TURRET_SIZE = 12;
- const TURRET_HEALTH = 150;
- const TURRET_FIRE_RATE = 2;
- const TURRET_DAMAGE = 15;
- const TURRET_RANGE = 350;
- const TURRET_PROJECTILE_SPEED = 400;
- const TURRET_FOLLOW_DISTANCE = 50;
- const TURRET_FOLLOW_LAG = 0.05;
- const FLAMETHROWER_PARTICLE_SPEED = 280;
- const FLAMETHROWER_PARTICLE_LIFE = 0.3;
- const FLAMETHROWER_PARTICLE_SIZE = 8;
- const FLAMETHROWER_RANGE = 180; // Effective range for collision check
- const FLAMETHROWER_CONE_ANGLE = 0.4; // Radians (approx 23 degrees)
- const FLAMETHROWER_BURN_DAMAGE_PER_TICK = 5;
- const FLAMETHROWER_BURN_DURATION = 3.0; // Seconds burn lasts after being hit
- const ENEMY_BURN_TICK_RATE = 0.5; // Seconds between burn damage ticks
- const BAZOOKA_PROJECTILE_SPEED = 250;
- const BAZOOKA_PROJECTILE_SIZE = 12;
- const BAZOOKA_EXPLOSION_RADIUS = 100; // Pixel radius of explosion
- const BAZOOKA_MAX_DAMAGE = 120; // Damage at center of explosion
- // --- Game State ---
- let player;
- let enemies = [];
- let projectiles = [];
- let xpOrbs = [];
- let moneyPickups = []; // Money pickups array
- let ammoPickups = [];
- let powerUps = [];
- let particles = [];
- let turrets = []; // Array for turrets
- let damageTexts = []; // For floating damage text elements
- 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 isGameOver = false;
- let noise; // For procedural background
- let audioCtx; // For Web Audio API
- let masterGain; // Master volume control
- let lastPlayerHitSoundTime = 0;
- let pendingLevelUps = 0; // Queue for level ups
- let isShowingLevelUpOptions = false; // To prevent overlap issues
- let isShopOpen = false; // Track shop state
- let animationFrameId = null; // Store animation frame request ID
- let lightEffects = [];
- // --- Audio Context & Sound Generation ---
- function initAudio() {
- try {
- audioCtx = new (window.AudioContext || window.webkitAudioContext)();
- masterGain = audioCtx.createGain();
- masterGain.gain.value = 0.5; // Set master volume (0 to 1)
- masterGain.connect(audioCtx.destination);
- console.log("AudioContext initialized.");
- } catch (e) {
- console.error("Web Audio API is not supported in this browser", e);
- }
- }
- // More sophisticated sound function
- function playSound(type, options = {}) {
- if (!audioCtx || !masterGain) return;
- const time = audioCtx.currentTime;
- const volume = options.volume !== undefined ? options.volume : 0.3; // Default volume
- switch (type) {
- // --- Existing Sounds (keep all of them) ---
- case 'shoot_pistol': {
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'sawtooth';
- osc.frequency.setValueAtTime(900, time);
- osc.frequency.exponentialRampToValueAtTime(400, time + 0.08);
- gain.gain.setValueAtTime(volume * 0.8, time);
- gain.gain.linearRampToValueAtTime(0, time + 0.1);
- osc.start(time);
- osc.stop(time + 0.1);
- break;
- }
- case 'shoot_smg': { // Faster, brighter sound
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'square';
- osc.frequency.setValueAtTime(1200, time);
- osc.frequency.exponentialRampToValueAtTime(800, time + 0.05);
- gain.gain.setValueAtTime(volume * 0.6, time);
- gain.gain.linearRampToValueAtTime(0, time + 0.07);
- osc.start(time);
- osc.stop(time + 0.07);
- break;
- }
- case 'shoot_shotgun': { // Low "thump" with noise burst
- // Thump
- const osc = audioCtx.createOscillator();
- const oscGain = audioCtx.createGain();
- osc.connect(oscGain).connect(masterGain);
- osc.type = 'sine';
- osc.frequency.setValueAtTime(150, time);
- osc.frequency.exponentialRampToValueAtTime(50, time + 0.15);
- oscGain.gain.setValueAtTime(volume * 1.2, time);
- oscGain.gain.linearRampToValueAtTime(0, time + 0.2);
- osc.start(time);
- osc.stop(time + 0.2);
- // Noise burst
- const bufferSize = audioCtx.sampleRate * 0.15; // 0.15 sec noise
- const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < bufferSize; i++) {
- data[i] = Math.random() * 2 - 1;
- }
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- noise.buffer = buffer;
- noise.connect(filter).connect(noiseGain).connect(masterGain);
- filter.type = 'bandpass';
- filter.frequency.value = 1500;
- filter.Q.value = 0.5;
- noiseGain.gain.setValueAtTime(volume * 0.5, time);
- noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
- noise.start(time);
- noise.stop(time + 0.15);
- break;
- }
- case 'shoot_minigun': { // Very fast, slightly metallic sound
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- osc.connect(gain).connect(filter).connect(masterGain);
- osc.type = 'sawtooth';
- filter.type = 'highpass';
- filter.frequency.value = 1500;
- osc.frequency.setValueAtTime(1400 + Math.random() * 100, time);
- osc.frequency.linearRampToValueAtTime(1200, time + 0.04);
- gain.gain.setValueAtTime(volume * 0.4, time);
- gain.gain.linearRampToValueAtTime(0, time + 0.06);
- osc.start(time);
- osc.stop(time + 0.06);
- break;
- }
- case 'shoot_flamethrower': { // Short burst of rushing noise
- const bufferSize = audioCtx.sampleRate * 0.15; // Longer duration for effect
- const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < bufferSize; i++) {
- data[i] = (Math.random() * 2 - 1);
- }
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- noise.buffer = buffer;
- noise.connect(filter).connect(noiseGain).connect(masterGain);
- filter.type = 'bandpass';
- filter.frequency.setValueAtTime(1500, time);
- filter.frequency.linearRampToValueAtTime(2500, time + 0.1);
- filter.Q.value = 1;
- noiseGain.gain.setValueAtTime(volume * 0.5, time);
- noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
- noise.start(time);
- noise.stop(time + 0.15);
- break;
- }
- case 'hit_fire': { // Sizzling sound for fire damage tick or hit
- const bufferSize = audioCtx.sampleRate * 0.08;
- const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1);
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- noise.buffer = buffer;
- noise.connect(filter).connect(noiseGain).connect(masterGain);
- filter.type = 'highpass';
- filter.frequency.value = 3000 + Math.random() * 1000;
- filter.Q.value = 1;
- noiseGain.gain.setValueAtTime(volume * 0.3, time); // Quieter than normal hit
- noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.08);
- noise.start(time);
- noise.stop(time + 0.08);
- break;
- }
- case 'shoot_bazooka': { // Low "Thoomp" and a high whistle/whoosh
- // Thoomp
- const osc = audioCtx.createOscillator();
- const oscGain = audioCtx.createGain();
- osc.connect(oscGain).connect(masterGain);
- osc.type = 'sine';
- osc.frequency.setValueAtTime(100, time);
- osc.frequency.exponentialRampToValueAtTime(40, time + 0.3);
- oscGain.gain.setValueAtTime(volume * 1.0, time);
- oscGain.gain.linearRampToValueAtTime(0, time + 0.3);
- osc.start(time);
- osc.stop(time + 0.3);
- // Whoosh (Noise)
- const bufferSize = audioCtx.sampleRate * 0.25;
- const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1);
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- noise.buffer = buffer;
- noise.connect(filter).connect(noiseGain).connect(masterGain);
- filter.type = 'bandpass';
- filter.frequency.setValueAtTime(800, time + 0.05); // Delayed start
- filter.frequency.linearRampToValueAtTime(3000, time + 0.05 + 0.2);
- filter.Q.value = 0.8;
- noiseGain.gain.setValueAtTime(volume * 0.6, time + 0.05);
- noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.05 + 0.25);
- noise.start(time + 0.05);
- noise.stop(time + 0.05 + 0.25);
- break;
- }
- case 'explosion': { // Deep boom with crackling noise
- // Boom
- const osc = audioCtx.createOscillator();
- const oscGain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- osc.connect(oscGain).connect(filter).connect(masterGain);
- osc.type = 'sawtooth';
- filter.type = 'lowpass';
- filter.frequency.value = 600;
- osc.frequency.setValueAtTime(80, time);
- osc.frequency.exponentialRampToValueAtTime(30, time + 0.8);
- oscGain.gain.setValueAtTime(volume * 1.5, time); // Loud
- oscGain.gain.exponentialRampToValueAtTime(0.001, time + 0.8);
- osc.start(time);
- osc.stop(time + 0.8);
- // Crackle (Noise)
- const bufferSize = audioCtx.sampleRate * 0.6;
- const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1);
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const noiseFilter = audioCtx.createBiquadFilter();
- noise.buffer = buffer;
- noise.connect(noiseFilter).connect(noiseGain).connect(masterGain);
- noiseFilter.type = 'highpass';
- noiseFilter.frequency.value = 1000;
- noiseGain.gain.setValueAtTime(volume * 0.5, time + 0.05);
- noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.6);
- noise.start(time + 0.05);
- noise.stop(time + 0.6);
- break;
- }
- case 'shoot_turret': { // Lighter, faster pew sound
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'triangle';
- osc.frequency.setValueAtTime(1100, time);
- osc.frequency.exponentialRampToValueAtTime(700, time + 0.06);
- gain.gain.setValueAtTime(volume * 0.5, time); // Quieter than player weapons
- gain.gain.linearRampToValueAtTime(0, time + 0.08);
- osc.start(time);
- osc.stop(time + 0.08);
- break;
- }
- case 'melee_swing': { // Quick "whoosh"
- const bufferSize = audioCtx.sampleRate * 0.1;
- const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- noise.buffer = buffer;
- noise.connect(filter).connect(noiseGain).connect(masterGain);
- filter.type = 'highpass';
- filter.frequency.setValueAtTime(1000, time);
- filter.frequency.linearRampToValueAtTime(8000, time + 0.1);
- noiseGain.gain.setValueAtTime(volume * 0.4, time);
- noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.1);
- noise.start(time);
- noise.stop(time + 0.1);
- break;
- }
- case 'melee_hit': { // Short, sharp impact
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'square';
- osc.frequency.setValueAtTime(400, time);
- gain.gain.setValueAtTime(volume * 0.9, time);
- gain.gain.exponentialRampToValueAtTime(0.001, time + 0.08);
- osc.start(time);
- osc.stop(time + 0.08);
- // Add noise click
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const buffer = audioCtx.createBuffer(1, 44100 * 0.03, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
- noise.buffer = buffer;
- noise.connect(noiseGain).connect(masterGain);
- noiseGain.gain.setValueAtTime(volume * 0.3, time);
- noiseGain.gain.linearRampToValueAtTime(0, time + 0.03);
- noise.start(time);
- noise.stop(time + 0.03);
- break;
- }
- case 'hit_enemy': { // Short click/thump
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'triangle';
- osc.frequency.setValueAtTime(300 + Math.random() * 100, time); // Vary pitch slightly
- gain.gain.setValueAtTime(volume * 0.6, time);
- gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
- osc.start(time);
- osc.stop(time + 0.05);
- break;
- }
- case 'shield_hit_enemy': { // Slightly different hit sound for shield orbs
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'sine'; // Softer sine wave
- osc.frequency.setValueAtTime(500 + Math.random() * 150, time);
- gain.gain.setValueAtTime(volume * 0.5, time);
- gain.gain.exponentialRampToValueAtTime(0.001, time + 0.07);
- osc.start(time);
- osc.stop(time + 0.07);
- break;
- }
- case 'enemy_death': { // Low crunch/squish
- const osc = audioCtx.createOscillator();
- const oscGain = audioCtx.createGain();
- osc.connect(oscGain).connect(masterGain);
- osc.type = 'sawtooth';
- osc.frequency.setValueAtTime(100, time);
- osc.frequency.exponentialRampToValueAtTime(30, time + 0.3);
- oscGain.gain.setValueAtTime(volume * 0.7, time);
- oscGain.gain.linearRampToValueAtTime(0, time + 0.3);
- osc.start(time);
- osc.stop(time + 0.3);
- // Noise
- const buffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.2, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- noise.buffer = buffer;
- noise.connect(filter).connect(noiseGain).connect(masterGain);
- filter.type = 'lowpass';
- filter.frequency.value = 800;
- noiseGain.gain.setValueAtTime(volume * 0.4, time);
- noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.2);
- noise.start(time);
- noise.stop(time + 0.2);
- break;
- }
- case 'hit_player': { // Lower pitch, slightly longer hurt sound
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'square';
- osc.frequency.setValueAtTime(150, time);
- gain.gain.setValueAtTime(volume * 0.2, time);
- gain.gain.linearRampToValueAtTime(0, time + 0.15);
- osc.start(time);
- osc.stop(time + 0.15);
- break;
- }
- case 'hit_turret': { // Metallic ping/clank for turret hit
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- osc.connect(gain).connect(filter).connect(masterGain);
- osc.type = 'square';
- osc.frequency.setValueAtTime(600, time);
- osc.frequency.exponentialRampToValueAtTime(300, time + 0.1);
- gain.gain.setValueAtTime(volume * 0.6, time);
- gain.gain.linearRampToValueAtTime(0, time + 0.1);
- filter.type = 'highpass'; // Make it sound metallic
- filter.frequency.value = 1500;
- filter.Q.value = 2;
- osc.start(time);
- osc.stop(time + 0.1);
- break;
- }
- case 'turret_death': { // Small explosion/break sound
- // Low thump
- const osc = audioCtx.createOscillator();
- const oscGain = audioCtx.createGain();
- osc.connect(oscGain).connect(masterGain);
- osc.type = 'triangle';
- osc.frequency.setValueAtTime(120, time);
- osc.frequency.exponentialRampToValueAtTime(40, time + 0.3);
- oscGain.gain.setValueAtTime(volume * 0.8, time);
- oscGain.gain.linearRampToValueAtTime(0, time + 0.3);
- osc.start(time);
- osc.stop(time + 0.3);
- // Noise burst
- const buffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.25, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- noise.buffer = buffer;
- noise.connect(filter).connect(noiseGain).connect(masterGain);
- filter.type = 'bandpass';
- filter.frequency.value = 1000;
- noiseGain.gain.setValueAtTime(volume * 0.6, time);
- noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.25);
- noise.start(time);
- noise.stop(time + 0.25);
- break;
- }
- case 'level_up': { // Ascending chime sequence
- const freqs = [440, 554, 659, 880]; // A4, C#5, E5, A5
- const attack = 0.01;
- const decay = 0.15;
- const step = 0.08;
- freqs.forEach((freq, i) => {
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'triangle';
- osc.frequency.setValueAtTime(freq, time + i * step);
- gain.gain.setValueAtTime(0, time + i * step);
- gain.gain.linearRampToValueAtTime(volume * 0.8, time + i * step + attack);
- gain.gain.exponentialRampToValueAtTime(0.001, time + i * step + attack + decay);
- osc.start(time + i * step);
- osc.stop(time + i * step + attack + decay);
- });
- break;
- }
- case 'upgrade_select': { // Confirming "blip"
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'sine';
- osc.frequency.setValueAtTime(880, time); // A5
- gain.gain.setValueAtTime(volume * 0.6, time);
- gain.gain.exponentialRampToValueAtTime(0.001, time + 0.1);
- osc.start(time);
- osc.stop(time + 0.1);
- break;
- }
- case 'buy_item': { // "Cha-ching" or positive confirmation sound
- const freq1 = 1046.50; // C6
- const freq2 = 1318.51; // E6
- const osc1 = audioCtx.createOscillator();
- const gain1 = audioCtx.createGain();
- osc1.connect(gain1).connect(masterGain);
- osc1.type = 'sine';
- osc1.frequency.value = freq1;
- gain1.gain.setValueAtTime(volume * 0.7, time);
- gain1.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
- osc1.start(time);
- osc1.stop(time + 0.15);
- const osc2 = audioCtx.createOscillator();
- const gain2 = audioCtx.createGain();
- osc2.connect(gain2).connect(masterGain);
- osc2.type = 'sine';
- osc2.frequency.value = freq2;
- gain2.gain.setValueAtTime(volume * 0.7, time + 0.08); // Slightly delayed
- gain2.gain.exponentialRampToValueAtTime(0.001, time + 0.08 + 0.15);
- osc2.start(time + 0.08);
- osc2.stop(time + 0.08 + 0.15);
- break;
- }
- case 'cant_afford': { // Negative/buzz sound
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'sawtooth';
- osc.frequency.setValueAtTime(100, time);
- gain.gain.setValueAtTime(volume * 0.5, time);
- gain.gain.exponentialRampToValueAtTime(0.001, time + 0.2);
- osc.start(time);
- osc.stop(time + 0.2);
- break;
- }
- case 'pickup_xp': { // Quick, high pitched "ping"
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'sine';
- osc.frequency.setValueAtTime(1200 + Math.random() * 200, time); // Slightly varied pitch
- gain.gain.setValueAtTime(volume * 0.3, time);
- gain.gain.exponentialRampToValueAtTime(0.001, time + 0.08);
- osc.start(time);
- osc.stop(time + 0.08);
- break;
- }
- case 'pickup_money': { // Coin clink/jingle sound
- const freq1 = 1567.98; // G6
- const freq2 = 1975.53; // B6
- const osc1 = audioCtx.createOscillator();
- const gain1 = audioCtx.createGain();
- osc1.connect(gain1).connect(masterGain);
- osc1.type = 'square'; // Brighter sound
- osc1.frequency.value = freq1 * (1 + Math.random() * 0.05 - 0.025);
- gain1.gain.setValueAtTime(volume * 0.4, time);
- gain1.gain.exponentialRampToValueAtTime(0.001, time + 0.07);
- osc1.start(time);
- osc1.stop(time + 0.07);
- const osc2 = audioCtx.createOscillator();
- const gain2 = audioCtx.createGain();
- osc2.connect(gain2).connect(masterGain);
- osc2.type = 'square';
- osc2.frequency.value = freq2 * (1 + Math.random() * 0.05 - 0.025);
- gain2.gain.setValueAtTime(volume * 0.4, time + 0.04); // Slightly delayed
- gain2.gain.exponentialRampToValueAtTime(0.001, time + 0.04 + 0.07);
- osc2.start(time + 0.04);
- osc2.stop(time + 0.04 + 0.07);
- break;
- }
- case 'pickup_ammo': { // Mechanical "click"
- const bufferSize = audioCtx.sampleRate * 0.05;
- const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- noise.buffer = buffer;
- noise.connect(filter).connect(noiseGain).connect(masterGain);
- filter.type = 'bandpass';
- filter.frequency.value = 4000;
- filter.Q.value = 1;
- noiseGain.gain.setValueAtTime(volume * 0.7, time);
- noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
- noise.start(time);
- noise.stop(time + 0.05);
- break;
- }
- case 'pickup_powerup': { // Magical shimmer
- const baseFreq = 600;
- for (let i = 0; i < 3; ++i) {
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'sine';
- const freq = baseFreq * (1 + Math.random() * 0.1 - 0.05); // Slight freq variation
- const detune = (i - 1) * 500; // Cents detune for chorus effect
- osc.frequency.value = freq;
- osc.detune.value = detune;
- const startTime = time + i * 0.04;
- gain.gain.setValueAtTime(0, startTime);
- gain.gain.linearRampToValueAtTime(volume * 0.4, startTime + 0.1);
- gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.4);
- osc.start(startTime);
- osc.stop(startTime + 0.4);
- }
- break;
- }
- case 'powerup_shield_activate': { // Activation sound for shield
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- osc.connect(gain).connect(filter).connect(masterGain);
- osc.type = 'sawtooth';
- osc.frequency.setValueAtTime(200, time);
- osc.frequency.exponentialRampToValueAtTime(800, time + 0.3);
- gain.gain.setValueAtTime(volume * 0.6, time);
- gain.gain.exponentialRampToValueAtTime(0.001, time + 0.5);
- filter.type = 'lowpass';
- filter.frequency.setValueAtTime(500, time);
- filter.frequency.exponentialRampToValueAtTime(2000, time + 0.3);
- osc.start(time);
- osc.stop(time + 0.5);
- break;
- }
- case 'powerup_shield_expire': { // Deactivation sound for shield
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- osc.connect(gain).connect(filter).connect(masterGain);
- osc.type = 'sawtooth';
- osc.frequency.setValueAtTime(800, time);
- osc.frequency.exponentialRampToValueAtTime(200, time + 0.4);
- gain.gain.setValueAtTime(volume * 0.5, time);
- gain.gain.exponentialRampToValueAtTime(0.001, time + 0.5);
- filter.type = 'lowpass';
- filter.frequency.setValueAtTime(2000, time);
- filter.frequency.exponentialRampToValueAtTime(500, time + 0.4);
- osc.start(time);
- osc.stop(time + 0.5);
- break;
- }
- case 'reload_start': { // Click sound
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const buffer = audioCtx.createBuffer(1, 44100 * 0.06, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
- noise.buffer = buffer;
- noise.connect(noiseGain).connect(masterGain);
- noiseGain.gain.setValueAtTime(volume * 0.5, time);
- noiseGain.gain.linearRampToValueAtTime(0, time + 0.06);
- noise.start(time);
- noise.stop(time + 0.06);
- break;
- }
- case 'reload_finish': { // Chunkier click/slide
- // Click
- const noise1 = audioCtx.createBufferSource();
- const noiseGain1 = audioCtx.createGain();
- const buffer1 = audioCtx.createBuffer(1, 44100 * 0.05, audioCtx.sampleRate);
- const data1 = buffer1.getChannelData(0);
- for (let i = 0; i < data1.length; i++) data1[i] = Math.random() * 2 - 1;
- noise1.buffer = buffer1;
- noise1.connect(noiseGain1).connect(masterGain);
- noiseGain1.gain.setValueAtTime(volume * 0.6, time);
- noiseGain1.gain.linearRampToValueAtTime(0, time + 0.05);
- noise1.start(time);
- noise1.stop(time + 0.05);
- // Chunk/slide (slightly delayed)
- const noise2 = audioCtx.createBufferSource();
- const noiseGain2 = audioCtx.createGain();
- const filter2 = audioCtx.createBiquadFilter();
- const buffer2 = audioCtx.createBuffer(1, 44100 * 0.1, audioCtx.sampleRate);
- const data2 = buffer2.getChannelData(0);
- for (let i = 0; i < data2.length; i++) data2[i] = Math.random() * 2 - 1;
- noise2.buffer = buffer2;
- noise2.connect(filter2).connect(noiseGain2).connect(masterGain);
- filter2.type = 'bandpass';
- filter2.frequency.value = 1500;
- filter2.Q.value = 0.8;
- noiseGain2.gain.setValueAtTime(volume * 0.4, time + 0.03);
- noiseGain2.gain.exponentialRampToValueAtTime(0.001, time + 0.13);
- noise2.start(time + 0.03);
- noise2.stop(time + 0.13);
- break;
- }
- case 'out_of_ammo': { // Empty "click" sound
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const buffer = audioCtx.createBuffer(1, 44100 * 0.04, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
- noise.buffer = buffer;
- noise.connect(noiseGain).connect(masterGain);
- noiseGain.gain.setValueAtTime(volume * 0.3, time); // Quieter click
- noiseGain.gain.linearRampToValueAtTime(0, time + 0.04);
- noise.start(time);
- noise.stop(time + 0.04);
- break;
- }
- case 'stamina_depleted': { // Low energy/breath sound
- const bufferSize = audioCtx.sampleRate * 0.3;
- const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize); // Noise fading out
- const noise = audioCtx.createBufferSource();
- const noiseGain = audioCtx.createGain();
- const filter = audioCtx.createBiquadFilter();
- noise.buffer = buffer;
- noise.connect(filter).connect(noiseGain).connect(masterGain);
- filter.type = 'bandpass';
- filter.frequency.setValueAtTime(800, time);
- filter.frequency.linearRampToValueAtTime(400, time + 0.3);
- filter.Q.value = 0.7;
- noiseGain.gain.setValueAtTime(volume * 0.3, time);
- noiseGain.gain.linearRampToValueAtTime(0.001, time + 0.3);
- noise.start(time);
- noise.stop(time + 0.3);
- break;
- }
- case 'game_over': { // Descending sad trombone-ish sound
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'sawtooth';
- osc.frequency.setValueAtTime(300, time);
- osc.frequency.exponentialRampToValueAtTime(100, time + 1.5);
- gain.gain.setValueAtTime(volume * 0.8, time);
- gain.gain.linearRampToValueAtTime(0, time + 1.5);
- osc.start(time);
- osc.stop(time + 1.5);
- break;
- }
- case 'wave_start': { // Alert sound / short rising tone
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'square';
- osc.frequency.setValueAtTime(500, time);
- osc.frequency.exponentialRampToValueAtTime(1000, time + 0.3);
- gain.gain.setValueAtTime(volume * 0.7, time);
- gain.gain.linearRampToValueAtTime(0, time + 0.5);
- osc.start(time);
- osc.stop(time + 0.5);
- break;
- }
- case 'weapon_switch': { // Short clicking sound for switching
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.connect(gain).connect(masterGain);
- osc.type = 'square';
- osc.frequency.setValueAtTime(1500, time);
- gain.gain.setValueAtTime(volume * 0.5, time);
- gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
- osc.start(time);
- osc.stop(time + 0.05);
- break;
- }
- default:
- break;
- }
- }
- // --- 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 distanceSq(x1, y1, x2, y2) { // Added squared distance utility
- return (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}; // Handle zero length vector
- 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;
- }
- // Clamps a screen coordinate to the canvas edge with margin
- function clampToScreenEdge(x, y, margin) {
- return {
- x: Math.max(margin, Math.min(canvas.width - margin, x)),
- y: Math.max(margin, Math.min(canvas.height - margin, y))
- };
- }
- function addLightEffect(x, y, radius, color, duration) {
- lightEffects.push({x, y, radius, color, duration, startTime: gameTime});
- }
- // Revised Line-Rectangle Intersection using Slab Method (Axis-Aligned)
- function intersectLineRectangle(x1, y1, x2, y2, rectX, rectY, rectW, rectH) {
- const dx = x2 - x1;
- const dy = y2 - y1;
- let tNear = 0; // Start parameter of line segment p1->p2
- let tFar = 1; // End parameter of line segment p1->p2
- // --- Slab tests for X and Y ---
- const p = [-dx, dx, -dy, dy];
- const q = [x1 - rectX, (rectX + rectW) - x1, y1 - rectY, (rectY + rectH) - y1];
- for (let i = 0; i < 4; i++) {
- if (p[i] === 0) {
- // Line is parallel to the slab edge i
- if (q[i] < 0) {
- return null; // Parallel and outside the box boundary
- }
- // Parallel and inside or on edge - continue checks
- } else {
- const r = q[i] / p[i];
- if (p[i] < 0) { // Line enters slab i
- if (r > tFar) return null; // Enters after leaving the valid segment [tNear, tFar]
- if (r > tNear) tNear = r; // Potential new near intersection time
- } else { // Line leaves slab i
- if (r < tNear) return null; // Leaves before entering the valid segment [tNear, tFar]
- if (r < tFar) tFar = r; // Potential new far intersection time
- }
- }
- }
- // If tNear > tFar, the intersection interval is empty (or invalid)
- // Also check if tNear is within the original segment [0, 1]
- if (tNear <= tFar && tNear >= 0 && tNear <= 1) {
- // Calculate the intersection point using tNear
- let intersectX = x1 + dx * tNear;
- let intersectY = y1 + dy * tNear;
- // Snap the intersection point precisely onto the boundary edge to avoid floating point errors
- // This assumes tNear correctly identifies the first boundary hit from inside->outside
- const tolerance = 0.01; // Small tolerance for boundary checks
- if (Math.abs(intersectX - rectX) < tolerance) intersectX = rectX;
- else if (Math.abs(intersectX - (rectX + rectW)) < tolerance) intersectX = rectX + rectW;
- if (Math.abs(intersectY - rectY) < tolerance) intersectY = rectY;
- else if (Math.abs(intersectY - (rectY + rectH)) < tolerance) intersectY = rectY + rectH;
- // Final clamp just in case snapping wasn't perfect
- intersectX = Math.max(rectX, Math.min(rectX + rectW, intersectX));
- intersectY = Math.max(rectY, Math.min(rectY + rectH, intersectY));
- return {x: intersectX, y: intersectY};
- }
- // No valid intersection found within the line segment [p1, p2] that hits the rectangle boundary
- return null;
- }
- // --- Classes ---
- class Entity {
- 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
- this.id = Math.random().toString(36).substring(2, 15); // Unique ID for tracking hits etc.
- }
- 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 or full
- 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 - 10; // Position ABOVE the entity slightly higher
- // Background of health bar
- ctx.fillStyle = 'rgba(100, 100, 100, 0.7)'; // Slightly transparent grey
- ctx.fillRect(barX, barY, barWidth, HEALTH_BAR_HEIGHT);
- // Current health
- const healthPercentage = this.health / this.maxHealth;
- const healthColor = healthPercentage > 0.5 ? 'rgba(0, 200, 0, 0.9)' : (healthPercentage > 0.2 ? 'rgba(255, 165, 0, 0.9)' : 'rgba(220, 0, 0, 0.9)'); // More vibrant, slightly transparent colors
- ctx.fillStyle = healthColor;
- ctx.fillRect(barX, barY, barWidth * healthPercentage, HEALTH_BAR_HEIGHT);
- // Add a thin border to the health bar for definition
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
- ctx.lineWidth = 0.5;
- ctx.strokeRect(barX, barY, barWidth, HEALTH_BAR_HEIGHT);
- ctx.lineWidth = 1; // Reset line width
- }
- }
- takeDamage(amount, source = null, burnDamage = 0, burnDuration = 0) { // Added burn params
- if (this.health <= 0) return false; // Already dead
- const actualDamage = amount * (1 - (this.damageResistance || 0)); // Apply resistance if exists
- if (actualDamage <= 0) return false; // No damage dealt
- this.health -= actualDamage;
- // --- Create Damage Text ---
- let damageColor = 'white';
- let textClass = '';
- let damageSourceType = typeof source === 'string' ? source : source?.constructor?.name; // Get source type
- if (damageSourceType === 'fire' || source === 'fire') { // Check if source is 'fire' (for burn ticks)
- damageColor = 'orange';
- textClass = 'burn'; // Add class for CSS styling
- playSound('hit_fire', {volume: 0.3}); // Play fire hit sound
- } else if (amount > 0) {
- playSound('hit_enemy', {volume: 0.4}); // Play normal hit sound only if direct damage > 0
- }
- if (amount > 0) { // Only show text for initial hit or burn ticks, not just status application
- createDamageText(this.x, this.y, actualDamage.toFixed(0), damageColor, this.size, textClass);
- }
- // --- NEW --- Handle Burn Effect Application (only if source isn't 'fire' itself)
- if (burnDamage > 0 && burnDuration > 0 && source !== 'fire') {
- this.applyBurn(burnDamage, burnDuration);
- }
- // --- END NEW ---
- if (this.health <= 0) {
- this.health = 0;
- return true; // Died
- }
- return false; // Survived
- }
- applyBurn(damagePerTick, duration) {
- // Apply resistance to burn damage? Maybe not for fire...
- this.burnDamage = Math.max(this.burnDamage, damagePerTick); // Take the highest burn damage if multiple sources? Or stack? Let's take highest for now.
- this.burnDuration = Math.max(this.burnDuration, duration); // Refresh/extend duration
- this.isOnFire = true; // Set flag immediately
- this.lastBurnTickTime = gameTime; // Start ticking soon
- console.log(`Enemy ${this.id} is burning! Damage: ${this.burnDamage}, Duration: ${this.burnDuration}`);
- }
- }
- 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;
- this.healthRegen = 0; // Initialize health regen
- this.money = 0; // Initialize money
- // Sprinting
- this.maxStamina = PLAYER_BASE_MAX_STAMINA;
- this.currentStamina = this.maxStamina;
- this.staminaRegenRate = PLAYER_BASE_STAMINA_REGEN;
- this.isSprinting = false;
- this.canSprint = true; // Can start sprinting
- this.staminaDepletedCooldown = 0; // Cooldown after depletion
- // --- 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: 80, swingArc: Math.PI / 2, // 90 degree arc, INCREASED RANGE
- lastSwingTime: 0, color: '#aaa', knockback: 150 // Added default knockback
- },
- { // 1: Knife (Melee) - Faster, less damage/range
- name: "Knife", type: 'melee', damage: 15, rateOfFire: 4,
- range: 60, swingArc: Math.PI / 3, // 60 degree arc, INCREASED RANGE
- lastSwingTime: 0, color: '#ccc', knockback: 50 // Added default knockback
- },
- { // 2: Pistol (Ranged)
- name: "Pistol", type: 'ranged', damage: 18, rateOfFire: 5,
- projectileSpeed: PROJECTILE_BASE_SPEED, projectileSize: PROJECTILE_BASE_SIZE,
- ammoType: 'pistol', ammoPerShot: 1, clipSize: 12, maxAmmo: 100, // Max reserve ammo
- lastShotTime: 0, color: PROJECTILE_COLOR, recoil: 50,
- accuracy: 0.05, projectileCount: 1, spreadAngle: 0,
- ammoInClip: 12, reloadTime: 1.5 // Seconds
- },
- { // 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, clipSize: 30, maxAmmo: 200,
- lastShotTime: 0, color: 'orange', recoil: 70,
- accuracy: 0.15, projectileCount: 1, spreadAngle: 0,
- ammoInClip: 30, reloadTime: 2.2
- },
- { // 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, clipSize: 6, 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)
- ammoInClip: 6, reloadTime: 3.0 // Long reload
- },
- { // 5: Minigun (Ranged)
- name: "Minigun", type: 'ranged', damage: 8, rateOfFire: 25, // High RoF, low damage
- projectileSpeed: PROJECTILE_BASE_SPEED * 1.1, projectileSize: PROJECTILE_BASE_SIZE * 0.7,
- ammoType: 'minigun', ammoPerShot: 1, clipSize: 500, maxAmmo: 2000, // Large clip, large reserve
- lastShotTime: 0, color: '#ADD8E6', recoil: 40, // Less recoil per shot, but fires fast
- accuracy: 0.1, projectileCount: 1, spreadAngle: 0, // Initial accuracy okay
- accuracyBloomPerShot: 0.015, // --- NEW: Spread increases when firing
- maxAccuracyPenalty: 0.3, // --- NEW: Max additional spread
- currentAccuracyPenalty: 0, // --- NEW: Current penalty state
- ammoInClip: 100, reloadTime: 4.5, muzzleFlashSize: 10 // Long reload
- },
- { // 6: Flamethrower (Ranged - Special Particle Stream)
- name: "Flamethrower", type: 'ranged', isFlamethrower: true, // Flag for special handling
- damage: 2, // Low direct damage per "shot" (particle burst)
- rateOfFire: 15, // Controls particle burst rate
- ammoType: 'fuel', ammoPerShot: 1, clipSize: 50, maxAmmo: 150,
- lastShotTime: 0, color: 'orange', // Used for particles
- recoil: 20, accuracy: 0.2, // Affects particle spread
- ammoInClip: 50, reloadTime: 3.0, muzzleFlashSize: 20
- // Burn damage/duration defined in constants
- },
- { // 7: Bazooka (Ranged - Explosive)
- name: "Bazooka", type: 'ranged', damage: 30, // Direct hit damage (less important)
- rateOfFire: 0.7, // Very slow
- projectileSpeed: BAZOOKA_PROJECTILE_SPEED, projectileSize: BAZOOKA_PROJECTILE_SIZE,
- ammoType: 'rocket', ammoPerShot: 1, clipSize: 1, maxAmmo: 10, // Single shot, low reserve
- lastShotTime: 0, color: '#FFA500', recoil: 400, // High recoil
- accuracy: 0.08, projectileCount: 1, spreadAngle: 0,
- ammoInClip: 1, reloadTime: 4.0, muzzleFlashSize: 35,
- explosionRadius: BAZOOKA_EXPLOSION_RADIUS, // --- NEW: AoE radius
- explosionDamage: BAZOOKA_MAX_DAMAGE, // --- NEW: Max AoE damage
- isRocket: true // --- NEW: Flag for projectile type
- }
- ];
- this.currentWeaponIndex = 2; // Start with Pistol
- this.currentWeapon = this.weapons[this.currentWeaponIndex];
- // --- Ammo (Reserve) ---
- this.ammo = {
- pistol: 50,
- smg: 0,
- shotgun: 10,
- minigun: 0, // New
- fuel: 0, // New
- rocket: 2 // New
- };
- // --- 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
- shieldOrbs: 0, // Duration for shield orbs
- // Add future power-up types here
- };
- this.baseMagnetRadius = XP_ORB_MAGNET_RADIUS; // Store base value
- // Shield Orb State
- this.shieldOrbAngle = 0;
- this.shieldOrbHitCooldowns = {}; // Tracks cooldowns per enemy ID for orbs
- this.aimAngle = 0; // For drawing aim line/weapon
- // --- Reloading State ---
- this.isReloading = false;
- this.reloadTimer = 0;
- // --- Melee Animation State ---
- this.meleeAnimationProgress = 1; // 0 = start, 1 = end
- this.meleeAnimationDuration = 0;
- this.minigunAccuracyDecayTimer = 0;
- this.minigunAccuracyDecayRate = 0.1; // How much penalty decays per second when not firing
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- let drawColor = this.color;
- // Draw Shield Orbs if active
- if (this.powerUps.shieldOrbs > 0) {
- const orbBaseRadius = SHIELD_ORB_RADIUS;
- const pulseFactor = Math.sin(gameTime * 8) * 3; // Gentle pulsing size
- const currentOrbRadius = orbBaseRadius + pulseFactor;
- for (let i = 0; i < SHIELD_ORB_COUNT; i++) {
- const angle = this.shieldOrbAngle + (i * Math.PI * 2 / SHIELD_ORB_COUNT);
- const orbX = this.x + Math.cos(angle) * currentOrbRadius;
- const orbY = this.y + Math.sin(angle) * currentOrbRadius;
- const orbScreenPos = worldToScreen(orbX, orbY);
- const orbSize = 8 + pulseFactor * 0.5; // Orb size pulsing slightly
- // Draw Orb
- ctx.fillStyle = `rgba(100, 200, 255, ${0.6 + Math.sin(gameTime * 5 + i) * 0.2})`; // Pulsating blue alpha
- ctx.beginPath();
- ctx.arc(orbScreenPos.x, orbScreenPos.y, orbSize, 0, Math.PI * 2);
- ctx.fill();
- // Add core glow
- ctx.fillStyle = `rgba(220, 240, 255, ${0.7 + Math.sin(gameTime * 5 + i + 1) * 0.2})`;
- ctx.beginPath();
- ctx.arc(orbScreenPos.x, orbScreenPos.y, orbSize * 0.5, 0, Math.PI * 2);
- ctx.fill();
- // Simple Particle trail for orbs
- if (Math.random() < SHIELD_ORB_PARTICLE_RATE * (1 / 60)) { // Rate adjusted for assumed 60fps
- createParticles(orbX, orbY, 1, 'rgba(150, 220, 255, 0.7)', 20, angle + Math.PI, 0.5, {
- life: 0.3,
- size: 2,
- friction: 0.9,
- glowColor: 'rgba(200, 230, 255, 0.5)', // --- NEW --- Subtle glow
- glowSizeFactor: 2.0
- });
- }
- }
- }
- // Visual feedback for invincibility
- if (this.powerUps.invincibility > 0) {
- 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 + Math.sin(gameTime * 10) * 2, 0, Math.PI * 2); // Pulsating size too
- ctx.fill();
- ctx.globalAlpha = 1.0;
- }
- // Sprinting visual effect (subtle trail/afterimage)
- if (this.isSprinting && this.currentStamina > 0) {
- ctx.globalAlpha = 0.3;
- const trailOffsetFactor = 0.1; // How far back the trail is
- const trailX = screenPos.x - this.vx * trailOffsetFactor;
- const trailY = screenPos.y - this.vy * trailOffsetFactor;
- ctx.fillStyle = this.color;
- ctx.beginPath();
- ctx.arc(trailX, trailY, this.size * 0.9, 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 weapon = this.currentWeapon;
- let drawAimLine = true; // Flag to control drawing the default aim line
- // --- MELEE WEAPON DRAWING (Adjusted) ---
- if (weapon.type === 'melee' && this.meleeAnimationProgress < 1) {
- drawAimLine = false; // Don't draw the default line while animating melee
- ctx.save();
- ctx.translate(screenPos.x, screenPos.y); // Move origin to player center
- let weaponWidth, weaponHeight, currentAngle, distOffset;
- const progress = this.meleeAnimationProgress; // 0 to 1
- if (weapon.name === "Bat") {
- weaponWidth = 8;
- weaponHeight = weapon.range * 0.8; // Adjusted: Relate length to range
- const halfArc = weapon.swingArc / 2;
- const swingPhase = Math.sin(progress * Math.PI * 1.5 - Math.PI / 4);
- currentAngle = this.aimAngle + swingPhase * halfArc * 1.2;
- distOffset = this.size * 0.5; // Adjusted: Hold closer to center
- ctx.rotate(currentAngle);
- ctx.fillStyle = weapon.color;
- ctx.fillRect(distOffset, -weaponWidth / 2, weaponHeight, weaponWidth);
- } else if (weapon.name === "Knife") {
- weaponWidth = 4;
- weaponHeight = this.size * 1.5; // Adjusted: Keep relatively small
- const stabPhase = Math.sin(progress * Math.PI); // 0 -> 1 -> 0
- currentAngle = this.aimAngle;
- distOffset = this.size * 0.5 + stabPhase * (weapon.range); // Adjusted: Start close, extend to full range
- ctx.rotate(currentAngle);
- ctx.fillStyle = weapon.color;
- ctx.fillRect(distOffset, -weaponWidth / 2, weaponHeight, weaponWidth);
- }
- ctx.restore(); // Restore context transforms
- }
- // --- END MELEE WEAPON DRAWING ---
- // Draw the default aim line ONLY if not drawing melee animation OR if it's a ranged weapon
- if (drawAimLine && weapon.type === 'ranged') { // Only draw for ranged now
- const angle = this.aimAngle;
- ctx.strokeStyle = this.isReloading ? 'grey' : 'white'; // Grey out while reloading
- ctx.lineWidth = 3;
- ctx.beginPath();
- ctx.moveTo(screenPos.x, screenPos.y);
- const weaponLength = this.size + 8;
- 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);
- // --- Sprinting Logic ---
- // Update depletion cooldown
- if (this.staminaDepletedCooldown > 0) {
- this.staminaDepletedCooldown -= dt;
- this.canSprint = false; // Cannot sprint during cooldown
- } else {
- this.canSprint = true; // Cooldown finished
- }
- // Check if sprint key is held and player can sprint
- this.isSprinting = keys[SPRINT_KEY] && this.canSprint && this.currentStamina > 0;
- // Stamina Consumption & Regeneration
- if (this.isSprinting) {
- this.currentStamina -= PLAYER_STAMINA_CONSUMPTION * dt;
- if (this.currentStamina <= 0) {
- this.currentStamina = 0;
- this.isSprinting = false; // Stop sprinting
- this.canSprint = false; // Can't sprint immediately
- this.staminaDepletedCooldown = 1.0; // 1 second cooldown after depletion
- playSound('stamina_depleted', {volume: 0.4});
- }
- } else if (this.currentStamina < this.maxStamina) {
- this.currentStamina += this.staminaRegenRate * dt;
- this.currentStamina = Math.min(this.currentStamina, this.maxStamina);
- // Allow sprinting again if stamina recovers above a threshold (e.g., 10%) and cooldown is over
- if (!this.canSprint && this.staminaDepletedCooldown <= 0 && this.currentStamina > this.maxStamina * 0.1) {
- this.canSprint = true;
- }
- }
- // Movement
- let targetVX = 0;
- let targetVY = 0;
- // Apply power-up speed boost AND sprint boost
- const speedBoostMultiplier = (this.powerUps.speedBoost > 0 ? 1.5 : 1);
- const sprintMultiplier = (this.isSprinting ? PLAYER_SPRINT_MULTIPLIER : 1);
- const currentSpeed = this.speed * speedBoostMultiplier * sprintMultiplier;
- if (keys['w'] || keys['ArrowUp']) targetVY = -currentSpeed;
- if (keys['s'] || keys['ArrowDown']) targetVY = currentSpeed;
- if (keys['a'] || keys['ArrowLeft']) targetVX = -currentSpeed;
- if (keys['d'] || keys['ArrowRight']) targetVX = currentSpeed;
- // Normalize diagonal movement
- if (targetVX !== 0 && targetVY !== 0) {
- const factor = 1 / Math.sqrt(2);
- targetVX *= factor;
- targetVY *= factor;
- }
- // Smooth movement towards target velocity (prevents jerky stops)
- const moveLerp = 0.2; // How quickly velocity adjusts (0-1)
- this.vx += (targetVX - this.vx) * moveLerp;
- this.vy += (targetVY - this.vy) * moveLerp;
- // Apply velocity update from Entity class AFTER setting targetVX/VY
- super.update(dt);
- // Update Shield Orb rotation
- if (this.powerUps.shieldOrbs > 0) {
- this.shieldOrbAngle += SHIELD_ORB_ROTATION_SPEED * dt;
- // Clean up old enemy IDs from cooldown tracker
- const now = gameTime;
- Object.keys(this.shieldOrbHitCooldowns).forEach(enemyId => {
- if (now > this.shieldOrbHitCooldowns[enemyId]) {
- delete this.shieldOrbHitCooldowns[enemyId];
- }
- });
- }
- // --- Reloading Logic ---
- if (this.isReloading) {
- this.reloadTimer -= dt;
- reloadIndicatorDiv.style.display = 'block'; // Show indicator
- if (this.reloadTimer <= 0) {
- this.finishReload();
- }
- } else {
- reloadIndicatorDiv.style.display = 'none'; // Hide indicator
- // Check for manual reload input
- if (keys[RELOAD_KEY]) {
- this.startReload();
- keys[RELOAD_KEY] = false; // Consume key press
- }
- }
- // Update melee animation progress
- if (this.meleeAnimationProgress < 1 && this.meleeAnimationDuration > 0) {
- this.meleeAnimationProgress += dt / this.meleeAnimationDuration;
- this.meleeAnimationProgress = Math.min(1, this.meleeAnimationProgress); // Clamp to 1
- }
- // 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;
- }
- if (!mouse.down || this.currentWeapon.name !== "Minigun") {
- if (this.currentWeapon.currentAccuracyPenalty > 0) {
- this.minigunAccuracyDecayTimer += dt;
- if (this.minigunAccuracyDecayTimer >= 0.1) { // Check decay every 0.1s
- this.currentWeapon.currentAccuracyPenalty -= this.minigunAccuracyDecayRate * this.minigunAccuracyDecayTimer;
- this.currentWeapon.currentAccuracyPenalty = Math.max(0, this.currentWeapon.currentAccuracyPenalty);
- this.minigunAccuracyDecayTimer = 0; // Reset timer
- }
- }
- } else {
- this.minigunAccuracyDecayTimer = 0; // Reset timer if firing minigun
- }
- // Shooting / Attacking (Keep existing check)
- if (mouse.down && !this.isReloading && !isShopOpen) {
- this.tryAttack();
- }
- }
- switchWeapon(index) {
- if (index >= 0 && index < this.weapons.length && index !== this.currentWeaponIndex) {
- if (this.isReloading) {
- this.cancelReload(); // Cancel reload if switching weapons
- }
- 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', {volume: 0.4}); // Play switch 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 in Clip
- if (weapon.ammoInClip > 0) {
- weapon.ammoInClip -= weapon.ammoPerShot; // Use clip ammo
- if (weapon.name === "Minigun" && weapon.accuracyBloomPerShot > 0) {
- weapon.currentAccuracyPenalty = Math.min(weapon.maxAccuracyPenalty, weapon.currentAccuracyPenalty + weapon.accuracyBloomPerShot);
- }
- if (weapon.isFlamethrower) { // --- NEW --- Special Flamethrower handling
- this.shootFlamethrower(weapon);
- } else { // Normal Ranged
- this.shootRanged(weapon);
- }
- weapon.lastShotTime = gameTime;
- updateUI(); // Update ammo display
- if (weapon.ammoInClip <= 0) {
- playSound('out_of_ammo', {volume: 0.5});
- }
- } else {
- playSound('out_of_ammo', {volume: 0.5});
- this.startReload(); // Attempt to start reload if empty
- }
- }
- }
- }
- startReload() {
- const weapon = this.currentWeapon;
- if (weapon.type !== 'ranged' || this.isReloading || !weapon.clipSize || weapon.ammoInClip >= weapon.clipSize || !this.ammo[weapon.ammoType] || this.ammo[weapon.ammoType] <= 0) {
- return; // Can't reload
- }
- this.isReloading = true;
- const actualReloadTime = weapon.reloadTime * (this.powerUps.fastReload > 0 ? 0.7 : 1); // Example modifier
- this.reloadTimer = actualReloadTime;
- console.log(`Starting reload for ${weapon.name} (${this.reloadTimer.toFixed(1)}s)...`);
- playSound('reload_start');
- updateUI(); // Show reloading status maybe
- }
- finishReload() {
- const weapon = this.currentWeapon;
- if (!this.isReloading || weapon.type !== 'ranged') return; // Safety checks
- const ammoNeeded = weapon.clipSize - weapon.ammoInClip;
- const ammoAvailable = this.ammo[weapon.ammoType] || 0;
- const ammoToLoad = Math.min(ammoNeeded, ammoAvailable);
- if (ammoToLoad > 0) {
- weapon.ammoInClip += ammoToLoad;
- this.ammo[weapon.ammoType] -= ammoToLoad;
- console.log(`Reloaded ${weapon.name} with ${ammoToLoad} rounds.`);
- playSound('reload_finish');
- } else {
- console.log(`Reload failed for ${weapon.name} - no reserve ammo.`);
- playSound('out_of_ammo', {volume: 0.4}); // Empty click if no reserve
- }
- this.isReloading = false;
- this.reloadTimer = 0;
- updateUI(); // Update ammo display
- reloadIndicatorDiv.style.display = 'none';
- }
- cancelReload() {
- if (!this.isReloading) return;
- this.isReloading = false;
- this.reloadTimer = 0;
- console.log("Reload cancelled.");
- reloadIndicatorDiv.style.display = 'none';
- // Optionally play a cancel sound
- updateUI();
- }
- performMeleeAttack(weapon) {
- playSound('melee_swing'); // Play swing sound
- // Start animation
- this.meleeAnimationProgress = 0;
- const fireRateMultiplier = (this.powerUps.rapidFire > 0 ? 1.5 : 1);
- this.meleeAnimationDuration = 1 / (weapon.rateOfFire * fireRateMultiplier);
- this.meleeAnimationDuration = Math.max(0.05, this.meleeAnimationDuration); // Minimum duration
- const attackAngle = this.aimAngle;
- const halfArc = weapon.swingArc / 2;
- let enemiesHit = 0;
- enemies.forEach(enemy => {
- if (enemy.health <= 0) return; // Skip dead enemies
- const dist = distance(this.x, this.y, enemy.x, enemy.y);
- if (dist < weapon.range + enemy.size) {
- const angleToEnemy = Math.atan2(enemy.y - this.y, enemy.x - this.x);
- let angleDiff = attackAngle - angleToEnemy;
- while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
- while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
- if (Math.abs(angleDiff) < halfArc) {
- const damageMultiplier = (this.powerUps.doubleDamage > 0) ? 2 : 1;
- const damageDealt = weapon.damage * damageMultiplier;
- const died = enemy.takeDamage(damageDealt, this); // Pass player as source
- enemiesHit++;
- const knockbackResistance = enemy.knockbackResistance || 0;
- const baseKnockback = weapon.knockback || 0;
- const actualKnockback = baseKnockback * (1 - knockbackResistance);
- if (actualKnockback > 0) {
- const pushDir = normalize(enemy.x - this.x, enemy.y - this.y);
- if (pushDir.x === 0 && pushDir.y === 0) {
- const randomAngle = Math.random() * Math.PI * 2;
- pushDir.x = Math.cos(randomAngle);
- pushDir.y = Math.sin(randomAngle);
- }
- const impulseStrength = actualKnockback * 1.0;
- enemy.vx += pushDir.x * impulseStrength;
- enemy.vy += pushDir.y * impulseStrength;
- }
- if (died) {
- enemy.onDeath();
- updateUI();
- }
- }
- }
- });
- if (enemiesHit > 0) {
- playSound('melee_hit', {volume: 0.5 + Math.min(0.4, enemiesHit * 0.1)});
- applyCameraShake(5 * enemiesHit, 0.1);
- }
- createParticles(this.x, this.y, 7, weapon.color, 150, attackAngle, weapon.swingArc, {
- life: 0.15,
- friction: 0.8
- });
- }
- shootRanged(weapon) {
- const damageMultiplier = (this.powerUps.doubleDamage > 0) ? 2 : 1;
- const baseAngle = this.aimAngle;
- const muzzleX = this.x + Math.cos(baseAngle) * (this.size + 5);
- const muzzleY = this.y + Math.sin(baseAngle) * (this.size + 5);
- createParticles(muzzleX, muzzleY, 8, 'yellow', 250, baseAngle, Math.PI / 8, {life: 0.1, friction: 0.8});
- const soundName = `shoot_${weapon.name.toLowerCase().replace(' ', '_')}`;
- playSound(soundName);
- // Sprinting Accuracy Penalty (Optional)
- const sprintAccuracyPenalty = this.isSprinting ? (weapon.accuracy || 0) * 0.5 : 0; // Add 50% of base inaccuracy when sprinting
- for (let i = 0; i < weapon.projectileCount; i++) {
- const accuracyOffset = (Math.random() - 0.5) * (weapon.accuracy + sprintAccuracyPenalty); // Include penalty
- let spreadOffset = 0;
- if (weapon.projectileCount > 1) {
- const divisor = weapon.projectileCount > 1 ? weapon.projectileCount - 1 : 1;
- const baseSpread = divisor > 0 ? (i / divisor) - 0.5 : 0;
- spreadOffset = baseSpread * weapon.spreadAngle * (Math.random() * 0.4 + 0.8);
- }
- const currentAngle = baseAngle + accuracyOffset + spreadOffset;
- const dx = Math.cos(currentAngle);
- const dy = Math.sin(currentAngle);
- const projX = this.x + dx * (this.size + weapon.projectileSize + 1);
- const projY = this.y + dy * (this.size + weapon.projectileSize + 1);
- projectiles.push(new Projectile(
- projX, projY,
- dx * weapon.projectileSpeed, dy * weapon.projectileSpeed,
- weapon.damage * damageMultiplier,
- weapon.projectileSize, weapon.color, this
- ));
- }
- const recoilStrength = weapon.recoil || 0;
- this.vx -= Math.cos(baseAngle) * recoilStrength * 0.03;
- this.vy -= Math.sin(baseAngle) * recoilStrength * 0.03;
- applyCameraShake(recoilStrength / 25, 0.1);
- }
- shootFlamethrower(weapon) {
- const baseAngle = this.aimAngle;
- const damageMultiplier = (this.powerUps.doubleDamage > 0) ? 2 : 1;
- const muzzleOffset = this.size + 5;
- const muzzleX = this.x + Math.cos(baseAngle) * muzzleOffset;
- const muzzleY = this.y + Math.sin(baseAngle) * muzzleOffset;
- // Muzzle flash (more orange/red)
- const muzzleFlashSize = weapon.muzzleFlashSize || 20;
- createParticles(muzzleX, muzzleY, 15, 'orange', 300, baseAngle, Math.PI / 5, {
- life: 0.1,
- friction: 0.75,
- size: 6,
- colorVariance: true
- });
- createParticles(muzzleX, muzzleY, 8, 'red', 250, baseAngle, Math.PI / 6, {
- life: 0.12,
- friction: 0.8,
- size: 4
- });
- addLightEffect(muzzleX, muzzleY, muzzleFlashSize * 2, 'rgba(255, 165, 0, 0.5)', 0.1);
- playSound('shoot_flamethrower', {volume: 0.35});
- // Create a burst of fire particles
- const particleCount = 5;
- for (let i = 0; i < particleCount; i++) {
- const accuracy = weapon.accuracy || 0.2;
- const angleOffset = (Math.random() - 0.5) * accuracy * 1.5; // Wider spread for flames
- const currentAngle = baseAngle + angleOffset;
- const speedVariation = 0.8 + Math.random() * 0.4;
- const speed = FLAMETHROWER_PARTICLE_SPEED * speedVariation;
- createParticles(
- muzzleX, muzzleY, 1, // Create one particle at a time
- `rgba(255, ${100 + Math.floor(Math.random() * 100)}, 0, ${0.6 + Math.random() * 0.3})`, // Orange/Yellow/Red gradient
- speed, currentAngle, 0, // No extra spread here
- {
- life: FLAMETHROWER_PARTICLE_LIFE * (0.8 + Math.random() * 0.4),
- friction: 0.9,
- size: FLAMETHROWER_PARTICLE_SIZE * (0.7 + Math.random() * 0.6),
- gravity: randomInRange(-50, 50), // Slight random gravity for flutter
- glowColor: 'rgba(255, 150, 0, 0.4)', // Orange glow
- glowSizeFactor: 1.8
- }
- );
- }
- // Check for hits in a cone - MORE EFFICIENT than particle collision
- const checkRangeSq = FLAMETHROWER_RANGE * FLAMETHROWER_RANGE;
- const halfCone = FLAMETHROWER_CONE_ANGLE / 2;
- enemies.forEach(enemy => {
- if (!enemy || enemy.health <= 0) return;
- const dx = enemy.x - this.x;
- const dy = enemy.y - this.y;
- const distSq = dx * dx + dy * dy;
- if (distSq < checkRangeSq) {
- const angleToEnemy = Math.atan2(dy, dx);
- let angleDiff = baseAngle - angleToEnemy;
- // Normalize angle difference to [-PI, PI]
- while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
- while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
- if (Math.abs(angleDiff) < halfCone) {
- // Hit! Apply direct damage AND burn effect
- const directDamage = weapon.damage * damageMultiplier;
- enemy.takeDamage(directDamage, this, FLAMETHROWER_BURN_DAMAGE_PER_TICK, FLAMETHROWER_BURN_DURATION);
- }
- }
- });
- // Recoil (less than bullets)
- const recoilStrength = weapon.recoil || 20;
- this.vx -= Math.cos(baseAngle) * recoilStrength * 0.02;
- this.vy -= Math.sin(baseAngle) * recoilStrength * 0.02;
- applyCameraShake(recoilStrength / 10, 0.08);
- }
- addXp(amount) {
- if (amount <= 0) return;
- const xpMultiplier = (this.powerUps.xpMultiplier > 0) ? 1.5 : 1;
- const actualAmount = Math.max(1, Math.floor(amount * xpMultiplier));
- this.xp += actualAmount;
- console.log(`+${actualAmount} XP (Total: ${this.xp}/${this.xpToNextLevel})`);
- while (this.xp >= this.xpToNextLevel) {
- this.levelUp();
- }
- updateUI();
- }
- addMoney(amount) {
- if (amount <= 0) return;
- this.money += amount;
- console.log(`+$${amount} (Total: $${this.money})`);
- updateUI();
- // Update shop button states if shop is open
- if (isShopOpen) {
- updateShopItemStates();
- }
- }
- 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 fully on level up
- pendingLevelUps++; // Increment pending level ups
- console.log(`Level Up! Reached level ${this.level}. Pending: ${pendingLevelUps}`);
- playSound('level_up');
- createParticles(this.x, this.y, 50, 'gold', 150, null, Math.PI * 2, {friction: 0.95}); // Level up particle burst
- updateUI(); // Update stats display
- levelUpIndicator.style.display = 'block';
- pendingLevelUpsSpan.textContent = pendingLevelUps;
- if (!isShowingLevelUpOptions && pendingLevelUps > 0 && !isShopOpen) { // Don't auto-open if shop is open
- presentLevelUpOptions();
- }
- }
- updatePowerUps(dt) {
- let statusChanged = false;
- let shieldExpired = 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;
- if (type === 'shieldOrbs') {
- shieldExpired = true;
- playSound('powerup_shield_expire');
- }
- }
- }
- }
- // Expire shield orb hit cooldowns that belong to non-existent enemies
- const activeEnemyIds = new Set(enemies.map(e => e.id));
- Object.keys(this.shieldOrbHitCooldowns).forEach(enemyId => {
- if (!activeEnemyIds.has(enemyId)) {
- delete this.shieldOrbHitCooldowns[enemyId];
- }
- });
- this.speed = this.baseSpeed * (this.powerUps.speedBoost > 0 ? 1.5 : 1);
- XP_ORB_MAGNET_RADIUS = this.baseMagnetRadius * (this.powerUps.magnetRadius > 0 ? 2.0 : 1);
- // Also apply magnet radius to money pickups? Yes.
- MONEY_PICKUP_MAGNET_RADIUS = XP_ORB_MAGNET_RADIUS * 1.1; // Slightly larger for money?
- if (statusChanged) {
- updateUI(); // Update the UI power-up display
- }
- }
- activatePowerUp(type, duration) {
- console.log(`Activated power-up: ${type} for ${duration}s`);
- const wasActive = (this.powerUps[type] || 0) > 0;
- this.powerUps[type] = (this.powerUps[type] || 0) + duration; // Add duration, stacking
- this.speed = this.baseSpeed * (this.powerUps.speedBoost > 0 ? 1.5 : 1);
- XP_ORB_MAGNET_RADIUS = this.baseMagnetRadius * (this.powerUps.magnetRadius > 0 ? 2.0 : 1);
- MONEY_PICKUP_MAGNET_RADIUS = XP_ORB_MAGNET_RADIUS * 1.1;
- if (type === 'shieldOrbs' && !wasActive) { // Play sound only on initial activation
- playSound('powerup_shield_activate');
- this.shieldOrbHitCooldowns = {}; // Reset cooldowns when shield activates/reactivates
- } else {
- playSound('pickup_powerup', {volume: 0.6});
- }
- updateUI();
- }
- takeDamage(amount, source = null) { // Accept source
- if (this.powerUps.invincibility > 0 || isGameOver) {
- createParticles(this.x, this.y, 3, 'grey', 50);
- return false; // Invincible or game over
- }
- createParticles(this.x, this.y, 5, '#ff4444', 100, null, Math.PI * 2, {friction: 0.9, life: 0.5});
- // Call parent takeDamage (which now accepts source)
- const died = super.takeDamage(amount, source);
- const now = audioCtx ? audioCtx.currentTime : gameTime;
- if (now - lastPlayerHitSoundTime > PLAYER_HIT_SOUND_COOLDOWN) {
- playSound('hit_player');
- lastPlayerHitSoundTime = now;
- }
- applyCameraShake(12, 0.15); // Shake on taking damage
- updateUI(); // Update health display
- if (died && !isGameOver) {
- gameOver();
- return true;
- }
- return died;
- }
- buyItem(item) {
- if (this.money >= item.cost) {
- this.money -= item.cost;
- playSound('buy_item');
- console.log(`Purchased ${item.name} for $${item.cost}`);
- // Apply item effect
- if (item.effect.type === 'ammo') {
- const ammoType = item.effect.ammoType;
- const amount = item.effect.amount;
- const weaponDef = this.weapons.find(w => w.ammoType === ammoType);
- const maxReserve = weaponDef ? weaponDef.maxAmmo : Infinity; // Use weapon max or assume infinite if no match
- this.ammo[ammoType] = Math.min(maxReserve, (this.ammo[ammoType] || 0) + amount);
- console.log(`Gained ${amount} ${ammoType} ammo. Reserve: ${this.ammo[ammoType]}/${maxReserve === Infinity ? '∞' : maxReserve}`);
- } else if (item.effect.type === 'turret') {
- // Find a non-overlapping spawn position near the player
- let spawnAngle = Math.random() * Math.PI * 2;
- let spawnOk = false;
- let attempt = 0;
- let tx, ty;
- while (!spawnOk && attempt < 10) {
- tx = this.x + Math.cos(spawnAngle) * TURRET_FOLLOW_DISTANCE;
- ty = this.y + Math.sin(spawnAngle) * TURRET_FOLLOW_DISTANCE;
- spawnOk = true;
- // Check collision with existing turrets
- for (const turret of turrets) {
- if (distanceSq(tx, ty, turret.x, turret.y) < (TURRET_SIZE * 2) ** 2) {
- spawnOk = false;
- spawnAngle += Math.PI / 3; // Try different angle
- break;
- }
- }
- attempt++;
- }
- if (!spawnOk) { // Fallback if no good spot found quickly
- tx = this.x + TURRET_FOLLOW_DISTANCE;
- ty = this.y;
- }
- turrets.push(new Turret(tx, ty, this)); // Add new turret owned by player
- item.buttonElement.disabled = true; // Disable button after purchase (one-time buy)
- item.buttonElement.textContent = 'Owned';
- }
- // Add other item types here...
- updateUI();
- updateShopItemStates(); // Update button disabled states
- return true;
- } else {
- playSound('cant_afford');
- console.log(`Cannot afford ${item.name} ($${item.cost}). Need $${item.cost - this.money} more.`);
- // Maybe flash the money UI red?
- uiMoney.parentElement.style.color = 'red';
- setTimeout(() => {
- uiMoney.parentElement.style.color = 'white';
- }, 500);
- return false;
- }
- }
- }
- // --- 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.04); // Increased wave speed scaling slightly
- 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
- this.target = null; // Store the target
- this.knockbackResistance = options.knockbackResistance || 0; // Default no resistance
- this.friction = 0.85; // Add friction to enemies
- // Money Drop Values (scale with health/size/wave)
- const valueMultiplier = healthMultiplier * sizeMultiplier * (1 + wave * 0.02);
- this.moneyDropMin = options.moneyMin || Math.floor(BASE_MONEY_DROP_MIN * valueMultiplier);
- this.moneyDropMax = options.moneyMax || Math.ceil(BASE_MONEY_DROP_MAX * valueMultiplier);
- this.burnDamage = 0;
- this.burnDuration = 0;
- this.lastBurnTickTime = 0;
- this.isOnFire = false;
- this.damageResistance = options.damageResistance || 0; // Optional resistance
- }
- // Override base takeDamage to add blood effect
- // takeDamage(amount, source = null) {
- // if (this.health <= 0) return false; // Already dead
- //
- // const died = super.takeDamage(amount, source); // Call parent for health reduction & damage text
- //
- // createParticles(this.x, this.y, 2 + Math.floor(amount / 15), 'red', 80 + amount * 0.5, null, Math.PI * 2, {
- // friction: 0.92,
- // life: 0.4
- // });
- //
- // return died;
- // }
- 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) {
- this.vx *= (1 - (1 - this.friction) * dt * 60);
- this.vy *= (1 - (1 - this.friction) * dt * 60);
- if (this.health <= 0) {
- super.update(dt);
- return;
- }
- this.target = target;
- if (!this.target || typeof this.target.x === 'undefined') {
- super.update(dt);
- return;
- }
- const dx = this.target.x - this.x;
- const dy = this.target.y - this.y;
- const distToTargetSq = dx * dx + dy * dy;
- // Stop moving if very close and avoid division by zero if dist is 0
- const stoppingDistanceSq = (this.size * 0.5 + (this.target.size || 0) * 0.5 + 5) ** 2; // Add small buffer
- if (distToTargetSq > stoppingDistanceSq && distToTargetSq > 0.1) { // Check dist > small threshold
- const distToTarget = Math.sqrt(distToTargetSq); // Calculate actual distance only when needed
- const moveDirX = dx / distToTarget;
- const moveDirY = dy / distToTarget;
- const targetVX = moveDirX * this.speed;
- const targetVY = moveDirY * this.speed;
- const desiredVX = targetVX - this.vx;
- const desiredVY = targetVY - this.vy;
- const maxAccel = this.speed * 4.0 * dt;
- const desiredSpeed = Math.sqrt(desiredVX * desiredVX + desiredVY * desiredVY);
- let accelX, accelY;
- if (desiredSpeed <= maxAccel || desiredSpeed === 0) {
- accelX = desiredVX;
- accelY = desiredVY;
- } else {
- accelX = (desiredVX / desiredSpeed) * maxAccel;
- accelY = (desiredVY / desiredSpeed) * maxAccel;
- }
- this.vx += accelX;
- this.vy += accelY;
- const currentSpeedSq = this.vx * this.vx + this.vy * this.vy;
- if (currentSpeedSq > this.speed * this.speed * 1.1) {
- const currentSpeed = Math.sqrt(currentSpeedSq);
- this.vx = (this.vx / currentSpeed) * this.speed;
- this.vy = (this.vy / currentSpeed) * this.speed;
- }
- } else {
- // If too close or dist is near zero, just let friction work.
- }
- super.update(dt); // Apply velocity changes
- }
- onDeath() {
- if (!player) return;
- createParticles(this.x, this.y, 15, '#cc0000', 100, null, Math.PI * 2, {friction: 0.9, life: 0.6});
- // Drop XP orb
- xpOrbs.push(new XpOrb(this.x, this.y, this.xpValue));
- // Drop Money
- if (Math.random() < MONEY_DROP_CHANCE) {
- const amount = Math.floor(randomInRange(this.moneyDropMin, this.moneyDropMax + 1)); // +1 because floor
- if (amount > 0) {
- moneyPickups.push(new MoneyPickup(this.x + randomInRange(-5, 5), this.y + randomInRange(-5, 5), amount));
- }
- }
- // Chance to drop Ammo
- if (Math.random() < AMMO_DROP_CHANCE) {
- let typeToDrop = null;
- const currentWeaponAmmoType = player.currentWeapon.ammoType;
- if (currentWeaponAmmoType && Math.random() < 0.6) { // 60% chance to drop ammo for current weapon
- typeToDrop = currentWeaponAmmoType;
- } else {
- const ammoTypes = Object.keys(player.ammo).filter(at => player.weapons.some(w => w.ammoType === at)); // Only drop types player actually has weapons for
- if (ammoTypes.length > 0) {
- typeToDrop = ammoTypes[Math.floor(Math.random() * ammoTypes.length)];
- }
- }
- if (typeToDrop) {
- const weaponDef = player.weapons.find(w => w.ammoType === typeToDrop);
- let amount = 1;
- if (weaponDef && weaponDef.clipSize) {
- amount = Math.ceil(weaponDef.clipSize * randomInRange(0.10, 0.25));
- } else {
- amount = typeToDrop === 'shotgun' ? randomInRange(1, 3) : randomInRange(5, 15);
- }
- amount = Math.max(1, Math.floor(amount));
- ammoPickups.push(new AmmoPickup(this.x + randomInRange(-8, 8), this.y + randomInRange(-8, 8), typeToDrop, amount));
- }
- }
- // Chance to drop PowerUp
- if (Math.random() < POWERUP_SPAWN_CHANCE) {
- powerUps.push(PowerUp.createRandom(this.x + randomInRange(-8, 8), this.y + randomInRange(-8, 8)));
- }
- createParticles(this.x, this.y, 25, this.color, 120, null, Math.PI * 2, {friction: 0.94, life: 0.8});
- createParticles(this.x, this.y, 10, 'white', 80, null, Math.PI * 2, {friction: 0.96, life: 0.5});
- playSound('enemy_death', {volume: 0.5});
- }
- }
- class FastEnemy extends Enemy {
- constructor(x, y, wave) {
- super(x, y, wave, {
- type: 'fast',
- baseSpeed: ENEMY_START_SPEED * 1.6, // Faster
- 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.9, // Slightly less XP
- baseDamage: ENEMY_BASE_DAMAGE * 0.8, // Slightly less damage
- knockbackResistance: 0.1, // Slight resistance
- moneyMin: Math.floor(BASE_MONEY_DROP_MIN * 0.8), // Less money
- moneyMax: Math.ceil(BASE_MONEY_DROP_MAX * 0.9),
- });
- this.friction = 0.9; // Lower friction for faster changes
- }
- draw(ctx) { // Triangle shape pointing in movement direction
- const screenPos = worldToScreen(this.x, this.y);
- const s = this.size * 1.2; // Make triangle appear similar size
- ctx.fillStyle = this.color;
- ctx.beginPath();
- let angle = Math.atan2(this.vy, this.vx);
- const speedSq = this.vx * this.vx + this.vy * this.vy;
- if (speedSq < 1 && this.target && this.health > 0) {
- angle = Math.atan2(this.target.y - this.y, this.target.x - this.x);
- }
- 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.55, // Slower
- baseHealth: ENEMY_START_HEALTH * 2.8, // More health
- baseSize: ENEMY_START_SIZE * 1.4, // Larger
- color: `hsl(${randomInRange(270, 310)}, 60%, 45%)`, // Dark Purple/Magenta hues
- xpValue: XP_PER_KILL * 1.8, // More XP
- baseDamage: ENEMY_BASE_DAMAGE * 1.3, // More damage
- knockbackResistance: 0.6, // Higher resistance
- moneyMin: Math.floor(BASE_MONEY_DROP_MIN * 1.5), // More money
- moneyMax: Math.ceil(BASE_MONEY_DROP_MAX * 2.0),
- });
- this.friction = 0.75; // Higher friction (slower to change direction)
- }
- draw(ctx) { // Hexagon shape
- const screenPos = worldToScreen(this.x, this.y);
- const s = this.size / 2 * 1.1; // Make hexagon slightly larger visually
- 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);
- }
- }
- class Projectile {
- 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, Turret, or an Enemy type
- this.life = 2.5;
- this.creationTime = gameTime;
- this.knockback = damage * 5;
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- // --- Trail Effect ---
- const trailLength = 3;
- const maxTrailOpacity = 0.5;
- for (let i = 1; i <= trailLength; i++) {
- const trailRatio = i / trailLength;
- const prevX = this.x - this.vx * (0.016 * trailRatio);
- const prevY = this.y - this.vy * (0.016 * trailRatio);
- const trailScreenPos = worldToScreen(prevX, prevY);
- ctx.globalAlpha = maxTrailOpacity * (1 - trailRatio);
- ctx.fillStyle = this.color;
- ctx.beginPath();
- ctx.arc(trailScreenPos.x, trailScreenPos.y, this.size * (1 - trailRatio * 0.5), 0, Math.PI * 2);
- ctx.fill();
- }
- ctx.globalAlpha = 1.0;
- // Draw main projectile body
- ctx.fillStyle = this.color;
- ctx.beginPath();
- ctx.arc(screenPos.x, screenPos.y, this.size, 0, Math.PI * 2);
- ctx.fill();
- // Optional: Add a white core for brightness
- ctx.fillStyle = 'white';
- ctx.beginPath();
- ctx.arc(screenPos.x, screenPos.y, this.size * 0.4, 0, Math.PI * 2);
- ctx.fill();
- }
- update(dt) {
- this.x += this.vx * dt;
- this.y += this.vy * dt;
- this.life -= dt;
- }
- }
- // --- Turret ---
- class Turret extends Entity {
- constructor(x, y, owner) {
- super(x, y, TURRET_SIZE, 'grey', TURRET_HEALTH);
- this.owner = owner; // The player who owns it
- this.targetEnemy = null;
- this.fireCooldown = 0;
- this.rangeSq = TURRET_RANGE * TURRET_RANGE; // Store squared range
- this.aimAngle = 0;
- this.barrelColor = '#aaa';
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- // Base
- ctx.fillStyle = this.color;
- ctx.beginPath();
- ctx.arc(screenPos.x, screenPos.y, this.size, 0, Math.PI * 2);
- ctx.fill();
- ctx.strokeStyle = '#555';
- ctx.lineWidth = 2;
- ctx.stroke();
- // Barrel pointing towards aim angle
- ctx.save();
- ctx.translate(screenPos.x, screenPos.y);
- ctx.rotate(this.aimAngle);
- ctx.fillStyle = this.barrelColor;
- ctx.fillRect(0, -2, this.size * 1.2, 4); // Barrel shape
- ctx.restore();
- // Draw health bar slightly differently for turret
- this.drawHealthBar(ctx);
- }
- update(dt) {
- // Smoothly follow the owner (player)
- if (this.owner) {
- const targetX = this.owner.x + Math.cos(this.aimAngle + Math.PI) * TURRET_FOLLOW_DISTANCE; // Try to stay behind player aiming dir
- const targetY = this.owner.y + Math.sin(this.aimAngle + Math.PI) * TURRET_FOLLOW_DISTANCE;
- // Simple lerp towards target position (could be improved with steering)
- const lerpFactor = 1 - Math.pow(1 - TURRET_FOLLOW_LAG, dt * 60);
- this.x += (targetX - this.x) * lerpFactor;
- this.y += (targetY - this.y) * lerpFactor;
- }
- // Find target
- this.findTarget();
- // Aim and shoot
- if (this.targetEnemy) {
- // Aim smoothly towards target
- const angleToTarget = Math.atan2(this.targetEnemy.y - this.y, this.targetEnemy.x - this.x);
- // Smooth angle transition (lerp)
- let angleDiff = angleToTarget - this.aimAngle;
- while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
- while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
- const turnSpeed = Math.PI * 2.0 * dt; // Radians per second turn rate
- this.aimAngle += Math.max(-turnSpeed, Math.min(turnSpeed, angleDiff));
- // Shoot if cooldown ready and aimed roughly correctly
- this.fireCooldown -= dt;
- if (this.fireCooldown <= 0 && Math.abs(angleDiff) < 0.2) { // Aim tolerance
- this.shoot();
- this.fireCooldown = 1 / TURRET_FIRE_RATE;
- }
- } else {
- // If no target, slowly rotate barrel back towards owner's aim direction? Or just stay put.
- // Smoothly rotate towards owner's aim direction if no target
- if (this.owner) {
- let angleDiff = this.owner.aimAngle - this.aimAngle;
- while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
- while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
- const idleTurnSpeed = Math.PI * 0.5 * dt; // Slower idle turn
- this.aimAngle += Math.max(-idleTurnSpeed, Math.min(idleTurnSpeed, angleDiff));
- }
- }
- }
- findTarget() {
- // Check if current target is still valid (alive and in range)
- if (this.targetEnemy && (this.targetEnemy.health <= 0 || distanceSq(this.x, this.y, this.targetEnemy.x, this.targetEnemy.y) > this.rangeSq)) {
- this.targetEnemy = null;
- }
- // If no valid target, find the closest enemy in range
- if (!this.targetEnemy) {
- let closestDistSq = this.rangeSq; // Start with max range squared
- let potentialTarget = null;
- for (const enemy of enemies) {
- if (enemy.health <= 0) continue;
- const distSq = distanceSq(this.x, this.y, enemy.x, enemy.y);
- if (distSq < closestDistSq) {
- closestDistSq = distSq;
- potentialTarget = enemy;
- }
- }
- this.targetEnemy = potentialTarget;
- }
- }
- shoot() {
- if (!this.targetEnemy) return;
- const dx = Math.cos(this.aimAngle);
- const dy = Math.sin(this.aimAngle);
- const projX = this.x + dx * (this.size + 3); // Start slightly ahead
- const projY = this.y + dy * (this.size + 3);
- projectiles.push(new Projectile(
- projX, projY,
- dx * TURRET_PROJECTILE_SPEED, dy * TURRET_PROJECTILE_SPEED,
- TURRET_DAMAGE,
- PROJECTILE_BASE_SIZE * 0.8, '#00FFFF', this // Cyan color, owner is turret itself
- ));
- playSound('shoot_turret', {volume: 0.3});
- // Muzzle flash
- createParticles(projX, projY, 3, 'cyan', 150, this.aimAngle, Math.PI / 12, {life: 0.08, friction: 0.8});
- }
- // Override takeDamage for turrets
- takeDamage(amount, source = null) {
- if (this.health <= 0) return false;
- const died = super.takeDamage(amount, source); // Use parent logic
- // Play turret hit sound
- playSound('hit_turret', {volume: 0.5});
- // Small particle effect on hit
- createParticles(this.x, this.y, 3, '#cccccc', 60, null, Math.PI * 2, {friction: 0.9, life: 0.3});
- if (died) {
- this.onDeath();
- return true;
- }
- return false;
- }
- onDeath() {
- console.log("Turret destroyed!");
- playSound('turret_death');
- // Big explosion effect
- createParticles(this.x, this.y, 40, 'orange', 200, null, Math.PI * 2, {life: 0.8, friction: 0.9});
- createParticles(this.x, this.y, 20, 'grey', 150, null, Math.PI * 2, {life: 1.0, friction: 0.92});
- applyCameraShake(15, 0.2);
- // Find the item in the shop and re-enable its button
- const turretShopItem = shopItems.find(item => item.id === 'turret_1');
- if (turretShopItem && turretShopItem.buttonElement) {
- turretShopItem.buttonElement.disabled = false;
- turretShopItem.buttonElement.textContent = `Buy ($${turretShopItem.cost})`;
- }
- }
- }
- // --- Pickups ---
- let XP_ORB_MAGNET_RADIUS = 90; // Default radius
- class XpOrb {
- constructor(x, y, value) {
- this.x = x;
- this.y = y;
- this.value = value;
- this.size = XP_ORB_SIZE;
- this.color = XP_ORB_COLOR;
- this.magnetSpeed = 400;
- this.vx = randomInRange(-30, 30);
- this.vy = randomInRange(-30, 30);
- this.friction = 0.9;
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- ctx.fillStyle = this.color;
- ctx.globalAlpha = 0.6 + Math.sin(gameTime * 6 + this.x) * 0.3;
- ctx.beginPath();
- ctx.arc(screenPos.x, screenPos.y, this.size + Math.sin(gameTime * 6 + this.x) * 1.5, 0, Math.PI * 2);
- ctx.fill();
- ctx.globalAlpha = 1.0;
- }
- update(dt, target) {
- if (!target) return;
- this.x += this.vx * dt;
- this.y += this.vy * dt;
- this.vx *= (1 - (1 - this.friction) * dt * 60);
- this.vy *= (1 - (1 - this.friction) * dt * 60);
- const magnetRadius = XP_ORB_MAGNET_RADIUS; // Use global magnet radius
- const distSq = distanceSq(this.x, this.y, target.x, target.y); // Use squared distance
- const magnetRadiusSq = magnetRadius * magnetRadius;
- if (distSq < magnetRadiusSq && distSq > 1) { // Check squared distances
- const dist = Math.sqrt(distSq); // Calculate actual distance only if needed
- const moveDirX = (target.x - this.x) / dist;
- const moveDirY = (target.y - this.y) / dist;
- const speedFactor = Math.pow((magnetRadius - dist) / magnetRadius, 0.5) * 2.0;
- const currentMagnetSpeed = this.magnetSpeed * speedFactor;
- this.vx += (moveDirX * currentMagnetSpeed - this.vx) * 0.3;
- this.vy += (moveDirY * currentMagnetSpeed - this.vy) * 0.3;
- }
- }
- }
- // --- Money Pickup ---
- class MoneyPickup {
- constructor(x, y, value) {
- this.x = x;
- this.y = y;
- this.value = value;
- // Size scales slightly with value
- this.size = Math.min(MONEY_PICKUP_SIZE_MAX, MONEY_PICKUP_SIZE_MIN + Math.log10(value + 1));
- this.color = MONEY_PICKUP_COLOR;
- this.life = MONEY_PICKUP_LIFE;
- this.vx = randomInRange(-40, 40); // Initial burst
- this.vy = randomInRange(-40, 40);
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- ctx.fillStyle = this.color;
- ctx.globalAlpha = 0.7 + Math.sin(gameTime * 5 + this.y) * 0.3; // Pulsate alpha
- // Draw shape (e.g., rotating coin '$')
- ctx.save();
- ctx.translate(screenPos.x, screenPos.y);
- ctx.rotate(Math.sin(gameTime * 2.5 + this.x) * 0.3); // Gentle rotation
- // Simple Rect as coin base
- ctx.fillRect(-this.size * 0.6, -this.size * 0.6, this.size * 1.2, this.size * 1.2);
- // Draw '$' sign
- ctx.fillStyle = 'black';
- ctx.font = `bold ${Math.max(6, this.size * 0.8)}px sans-serif`;
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.fillText('$', 0, 1);
- ctx.restore();
- ctx.globalAlpha = 1.0; // Reset alpha
- }
- update(dt, target) {
- this.life -= dt;
- if (!target) return;
- // Apply initial velocity and friction
- this.x += this.vx * dt;
- this.y += this.vy * dt;
- this.vx *= (1 - (1 - MONEY_PICKUP_FRICTION) * dt * 60);
- this.vy *= (1 - (1 - MONEY_PICKUP_FRICTION) * dt * 60);
- // Move towards player if close enough
- const magnetRadius = MONEY_PICKUP_MAGNET_RADIUS; // Use global money magnet radius
- const distSq = distanceSq(this.x, this.y, target.x, target.y);
- const magnetRadiusSq = magnetRadius * magnetRadius;
- if (distSq < magnetRadiusSq && distSq > 1) {
- const dist = Math.sqrt(distSq);
- const moveDirX = (target.x - this.x) / dist;
- const moveDirY = (target.y - this.y) / dist;
- const speedFactor = Math.pow((magnetRadius - dist) / magnetRadius, 0.6) * 2.5; // Slightly stronger pull?
- const currentMagnetSpeed = MONEY_PICKUP_MAGNET_SPEED * speedFactor;
- this.vx += (moveDirX * currentMagnetSpeed - this.vx) * 0.35; // Faster lerp?
- this.vy += (moveDirY * currentMagnetSpeed - this.vy) * 0.35;
- }
- }
- }
- class AmmoPickup {
- constructor(x, y, ammoType, amount) {
- this.x = x;
- this.y = y;
- this.ammoType = ammoType;
- this.amount = amount;
- this.size = AMMO_PICKUP_SIZE + Math.log10(amount + 1) * 2;
- this.color = AMMO_PICKUP_COLOR;
- this.life = 25;
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- ctx.fillStyle = this.color;
- ctx.globalAlpha = 0.7 + Math.sin(gameTime * 4 + this.y) * 0.3;
- ctx.save();
- ctx.translate(screenPos.x, screenPos.y);
- ctx.rotate(Math.sin(gameTime * 2 + this.x) * 0.2);
- ctx.fillRect(-this.size / 2, -this.size / 2, this.size, this.size);
- ctx.restore();
- ctx.fillStyle = 'black';
- ctx.font = `bold ${Math.max(7, this.size * 0.6)}px 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);
- ctx.globalAlpha = 1.0;
- }
- update(dt) {
- this.life -= dt;
- }
- }
- class PowerUp {
- constructor(x, y, type, duration, color, symbol) {
- this.x = x;
- this.y = y;
- this.type = type;
- this.duration = duration;
- this.size = POWERUP_SIZE;
- this.color = color;
- this.symbol = symbol;
- this.life = 30;
- this.angle = Math.random() * Math.PI * 2;
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- ctx.save();
- ctx.translate(screenPos.x, screenPos.y);
- ctx.rotate(this.angle);
- ctx.globalAlpha = 0.7 + Math.sin(gameTime * 5 + this.x) * 0.3;
- ctx.fillStyle = this.color;
- ctx.beginPath();
- const outerRadius = this.size * (1.1 + Math.sin(gameTime * 5 + this.x) * 0.1);
- const innerRadius = outerRadius * 0.5;
- ctx.moveTo(0, -outerRadius);
- for (let i = 0; i < 5; i++) {
- const outerAngle = Math.PI / 5 * (i * 2) - Math.PI / 2;
- const innerAngle = outerAngle + Math.PI / 5;
- ctx.lineTo(Math.cos(outerAngle) * outerRadius, Math.sin(outerAngle) * outerRadius);
- ctx.lineTo(Math.cos(innerAngle) * innerRadius, Math.sin(innerAngle) * innerRadius);
- }
- ctx.closePath();
- ctx.fill();
- ctx.strokeStyle = 'white';
- ctx.lineWidth = 1.5;
- ctx.stroke();
- ctx.fillStyle = 'white';
- ctx.font = `bold ${Math.max(9, this.size)}px sans-serif`;
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.shadowColor = 'black';
- ctx.shadowBlur = 4;
- ctx.fillText(this.symbol, 0, 1);
- ctx.shadowBlur = 0;
- ctx.restore();
- ctx.globalAlpha = 1.0;
- }
- update(dt) {
- this.life -= dt;
- this.angle += dt * 1.5;
- }
- static createRandom(x, y) {
- const types = [
- {type: 'invincibility', duration: 5, color: '#ccccff', symbol: '★'},
- {type: 'doubleDamage', duration: 10, color: '#ff6666', symbol: '2x'},
- {type: 'speedBoost', duration: 10, color: '#87CEEB', symbol: '>>'},
- {type: 'rapidFire', duration: 8, color: '#FFD700', symbol: 'RF'},
- {type: 'xpMultiplier', duration: 15, color: '#90EE90', symbol: 'XP'},
- {type: 'magnetRadius', duration: 20, color: '#DA70D6', symbol: 'M'},
- {type: 'shieldOrbs', duration: 12, color: '#64C8FF', symbol: 'O'} // Added Shield Orbs
- ];
- const choice = types[Math.floor(Math.random() * types.length)];
- return new PowerUp(x, y, choice.type, choice.duration, choice.color, choice.symbol);
- }
- }
- class Particle {
- 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 !== undefined ? options.friction : 0.98;
- this.gravity = options.gravity || 0;
- this.alpha = 1.0;
- this.rotation = Math.random() * Math.PI * 2;
- this.rotationSpeed = (Math.random() - 0.5) * 5;
- }
- draw(ctx) {
- const screenPos = worldToScreen(this.x, this.y);
- const lifeRatio = Math.max(0, this.life / this.initialLife);
- this.alpha = lifeRatio;
- ctx.save();
- ctx.translate(screenPos.x, screenPos.y);
- ctx.rotate(this.rotation);
- ctx.globalAlpha = this.alpha;
- ctx.fillStyle = this.color;
- ctx.beginPath();
- const currentSize = Math.max(0.1, this.size * lifeRatio);
- ctx.arc(0, 0, currentSize, 0, Math.PI * 2);
- ctx.fill();
- ctx.restore();
- ctx.globalAlpha = 1.0;
- }
- update(dt) {
- this.x += this.vx * dt;
- this.y += this.vy * dt;
- this.vy += this.gravity * dt;
- this.vx *= (1 - (1 - this.friction) * dt * 60);
- this.vy *= (1 - (1 - this.friction) * dt * 60);
- this.life -= dt;
- this.rotation += this.rotationSpeed * dt;
- }
- }
- // --- Floating Damage Text ---
- // Added entitySize parameter (with a default fallback)
- function createDamageText(x, y, text, color = 'white', entitySize = 20) {
- const textElement = document.createElement('div');
- textElement.classList.add('damage-text');
- textElement.textContent = text;
- textElement.style.color = color;
- const baseSize = 15;
- const damageScaleFactor = 1.8;
- textElement.style.fontSize = `${baseSize + Math.log10(parseFloat(text) + 1) * damageScaleFactor}px`;
- const screenPos = worldToScreen(x, y);
- // --- ADJUSTED VERTICAL OFFSET ---
- // Position above the entity's estimated top edge + a small gap (15px)
- // Use entitySize * 0.6 as a rough estimate for radius/half-height across different shapes
- const verticalOffset = -(entitySize * 0.6 + 15);
- // --- END ADJUSTMENT ---
- textElement.style.left = `${screenPos.x}px`;
- textElement.style.top = `${screenPos.y + verticalOffset}px`; // Use the calculated offset
- textElement.style.transform = 'translateX(-50%) translateY(0px)'; // Center horizontally, start at calculated top
- textElement.style.opacity = '1';
- damageTextContainer.appendChild(textElement);
- const damageTextObject = {
- element: textElement,
- creationTime: gameTime,
- duration: DAMAGE_TEXT_DURATION
- };
- damageTexts.push(damageTextObject);
- // --- Animation: Use requestAnimationFrame for smoother start ---
- requestAnimationFrame(() => { // Wait for element to be added to DOM
- requestAnimationFrame(() => { // Wait one more frame for styles to apply
- const upwardMovement = DAMAGE_TEXT_SPEED * DAMAGE_TEXT_DURATION;
- textElement.style.opacity = '0';
- // Translate Y moves *upwards* from the initial position
- textElement.style.transform = `translateX(-50%) translateY(-${upwardMovement}px)`;
- });
- });
- // --- End Animation Change ---
- // Cleanup timeout remains the same
- setTimeout(() => {
- if (textElement.parentNode === damageTextContainer) {
- damageTextContainer.removeChild(textElement);
- }
- const index = damageTexts.indexOf(damageTextObject);
- if (index > -1) {
- damageTexts.splice(index, 1);
- }
- }, DAMAGE_TEXT_DURATION * 1000 + 100); // Add small buffer to timeout
- }
- function cleanupDamageTexts() {
- const now = gameTime;
- damageTexts = damageTexts.filter(dt => (now - dt.creationTime) < dt.duration + 0.2);
- }
- // --- Shop Items Definition ---
- let shopItems = []; // Will be populated in setupShop
- function setupShop() {
- shopItemsContainer.innerHTML = ''; // Clear existing items
- shopItems = [
- // Ammo
- {
- id: 'ammo_pistol_small',
- name: 'Pistol Rounds (x20)',
- cost: 25,
- description: 'A small pack of pistol ammo.',
- effect: {type: 'ammo', ammoType: 'pistol', amount: 20}
- },
- {
- id: 'ammo_pistol_large',
- name: 'Pistol Rounds (x50)',
- cost: 50,
- description: 'A box of pistol ammo.',
- effect: {type: 'ammo', ammoType: 'pistol', amount: 50}
- },
- {
- id: 'ammo_smg_small',
- name: 'SMG Mags (x60)',
- cost: 60,
- description: 'A couple of SMG magazines.',
- effect: {type: 'ammo', ammoType: 'smg', amount: 60}
- },
- {
- id: 'ammo_smg_large',
- name: 'SMG Mags (x150)',
- cost: 120,
- description: 'An ammo can of SMG rounds.',
- effect: {type: 'ammo', ammoType: 'smg', amount: 150}
- },
- {
- id: 'ammo_shotgun_small',
- name: 'Shotgun Shells (x10)',
- cost: 40,
- description: 'A bandolier of shells.',
- effect: {type: 'ammo', ammoType: 'shotgun', amount: 10}
- },
- {
- id: 'ammo_shotgun_large',
- name: 'Shotgun Shells (x25)',
- cost: 85,
- description: 'A case of shotgun shells.',
- effect: {type: 'ammo', ammoType: 'shotgun', amount: 25}
- },
- // Upgrades
- {
- id: 'turret_1',
- name: 'Deployable Turret',
- cost: TURRET_COST,
- description: 'Places an automated turret that follows and shoots enemies. (Max 1)',
- effect: {type: 'turret'}
- },
- // Add more unique items: Health pack? Temporary buff? New weapon?
- ];
- shopItems.forEach(item => {
- const itemDiv = document.createElement('div');
- itemDiv.classList.add('shop-item');
- const infoDiv = document.createElement('div');
- infoDiv.classList.add('shop-item-info');
- const nameSpan = document.createElement('span');
- nameSpan.classList.add('shop-item-name');
- nameSpan.textContent = item.name;
- infoDiv.appendChild(nameSpan);
- const descSpan = document.createElement('span');
- descSpan.classList.add('shop-item-desc');
- descSpan.textContent = ` ${item.description}`; // Add space before desc
- infoDiv.appendChild(descSpan);
- const buyButton = document.createElement('button');
- buyButton.classList.add('shop-buy-button');
- buyButton.dataset.itemId = item.id; // Store item ID for lookup
- buyButton.textContent = `Buy ($${item.cost})`;
- // Store reference to button element on the item object for later disabling
- item.buttonElement = buyButton;
- buyButton.addEventListener('click', () => {
- if (player) {
- player.buyItem(item); // Pass the whole item object
- }
- });
- itemDiv.appendChild(infoDiv);
- itemDiv.appendChild(buyButton);
- shopItemsContainer.appendChild(itemDiv);
- });
- updateShopItemStates(); // Set initial button states
- }
- function updateShopItemStates() {
- if (!player) return;
- shopItems.forEach(item => {
- if (!item.buttonElement) return; // Skip if button not found
- let canAfford = player.money >= item.cost;
- let owned = false;
- // Check if turret is already owned
- if (item.id === 'turret_1' && turrets.some(t => t.owner === player)) {
- owned = true;
- canAfford = false; // Can't buy if owned
- }
- // Check if ammo is full for this type
- if (item.effect.type === 'ammo') {
- const ammoType = item.effect.ammoType;
- const weaponDef = player.weapons.find(w => w.ammoType === ammoType);
- const maxReserve = weaponDef ? weaponDef.maxAmmo : Infinity;
- if ((player.ammo[ammoType] || 0) >= maxReserve) {
- canAfford = false; // Can't buy if full
- item.buttonElement.textContent = 'Full';
- } else {
- item.buttonElement.textContent = `Buy ($${item.cost})`; // Reset text if not full
- }
- }
- item.buttonElement.disabled = !canAfford;
- if (owned) {
- item.buttonElement.textContent = 'Owned';
- item.buttonElement.disabled = true;
- } else if (!canAfford && item.effect.type !== 'ammo') { // Keep "Full" text for ammo
- item.buttonElement.textContent = `Buy ($${item.cost})`; // Reset text if just can't afford non-ammo
- }
- });
- }
- function toggleShop() {
- isShopOpen = !isShopOpen;
- shopScreen.style.display = isShopOpen ? 'flex' : 'none';
- if (isShopOpen) {
- updateShopItemStates(); // Update states when opening
- // Don't pause game, maybe give player slight defense buff while shopping? Or just risk it.
- // Close level up options if open
- if (isShowingLevelUpOptions) {
- levelUpOptionsDiv.style.display = 'none';
- isShowingLevelUpOptions = false;
- }
- } else {
- // Focus back on canvas? Not strictly necessary.
- }
- }
- // --- Game Logic ---
- function init() {
- console.log("Initializing game...");
- isGameOver = false;
- gameContainerDiv.style.display = 'block'; // Show game area
- colorSelectionDiv.style.display = 'none'; // Hide color selection
- isShopOpen = false; // Ensure shop starts closed
- shopScreen.style.display = 'none';
- 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;
- damageTextContainer.style.width = `${canvas.width}px`;
- damageTextContainer.style.height = `${canvas.height}px`;
- if (typeof Noise === 'undefined') {
- console.warn("Noise library not found. Background will be simple grid.");
- noise = null;
- } else {
- noise = new Noise(Math.random());
- 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;
- enemies = [];
- projectiles = [];
- xpOrbs = [];
- moneyPickups = [];
- ammoPickups = [];
- powerUps = [];
- particles = [];
- turrets = []; // Reset turrets
- damageTexts = [];
- damageTextContainer.innerHTML = '';
- keys = {};
- mouse = {x: 0, y: 0, down: false};
- currentWave = 1;
- gameTime = 0;
- pendingLevelUps = 0;
- isShowingLevelUpOptions = false;
- levelUpOptionsDiv.style.display = 'none';
- levelUpIndicator.style.display = 'none';
- reloadIndicatorDiv.style.display = 'none';
- // Remove old listeners before adding new ones
- window.removeEventListener('keydown', handleKeyDown);
- window.removeEventListener('keyup', handleKeyUp);
- window.removeEventListener('mousedown', handleMouseDown);
- window.removeEventListener('mouseup', handleMouseUp);
- window.removeEventListener('mousemove', handleMouseMove);
- window.removeEventListener('resize', handleResize);
- levelUpIndicator.removeEventListener('click', toggleLevelUpOptions);
- shopButton.removeEventListener('click', toggleShop);
- shopCloseButton.removeEventListener('click', toggleShop);
- // 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);
- levelUpIndicator.addEventListener('click', toggleLevelUpOptions);
- shopButton.addEventListener('click', toggleShop); // Shop button listener
- shopCloseButton.addEventListener('click', toggleShop); // Shop close button listener
- setupShop(); // Populate shop items
- startWave();
- lastTime = performance.now();
- updateUI(); // Initial UI update
- if (animationFrameId) { // Clear previous loop if any
- cancelAnimationFrame(animationFrameId);
- }
- animationFrameId = requestAnimationFrame(gameLoop); // Start new loop
- console.log("Game initialized and loop started.");
- }
- // --- Event Handlers ---
- function handleKeyDown(e) {
- const key = e.key.toLowerCase();
- keys[key] = true;
- // Prevent browser default actions for game keys
- if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' ', SPRINT_KEY, RELOAD_KEY, SHOP_KEY].includes(key) || (key >= '0' && key <= '9')) {
- e.preventDefault();
- }
- // Toggle shop with keybind (only if not game over)
- if (!isGameOver && key === SHOP_KEY) {
- toggleShop();
- keys[key] = false; // Consume the shop key press immediately
- }
- }
- function handleKeyUp(e) {
- const key = e.key.toLowerCase();
- keys[key] = false;
- // If shift is released, explicitly stop sprinting
- if (key === SPRINT_KEY && player) {
- player.isSprinting = false;
- }
- }
- function handleMouseDown(e) {
- // Only register left clicks on canvas, and only if shop is NOT open
- if (e.target === canvas && e.button === 0 && !isShopOpen) {
- mouse.down = true;
- } else if (isShopOpen && !shopScreen.contains(e.target) && e.target !== shopButton) {
- // If shop is open and click is outside shop (and not on the shop button itself), close shop? Optional behavior.
- // toggleShop();
- }
- }
- function handleMouseUp(e) {
- if (e.button === 0) mouse.down = false; // Release mouse down regardless of target
- }
- function handleMouseMove(e) {
- const rect = canvas.getBoundingClientRect();
- mouse.x = e.clientX - rect.left;
- mouse.y = e.clientY - rect.top;
- }
- function handleResize() {
- const oldWidth = canvas.width;
- const oldHeight = canvas.height;
- canvas.width = window.innerWidth * 0.9;
- canvas.height = window.innerHeight * 0.9;
- damageTextContainer.style.width = `${canvas.width}px`;
- damageTextContainer.style.height = `${canvas.height}px`;
- const canvasRect = canvas.getBoundingClientRect();
- damageTextContainer.style.top = canvasRect.top + 'px';
- damageTextContainer.style.left = canvasRect.left + 'px';
- console.log(`Resized canvas to ${canvas.width}x${canvas.height}`);
- }
- function toggleLevelUpOptions() {
- if (isShopOpen) return; // Don't allow level up interaction if shop is open
- if (isShowingLevelUpOptions) {
- levelUpOptionsDiv.style.display = 'none';
- isShowingLevelUpOptions = false;
- } else if (pendingLevelUps > 0) {
- presentLevelUpOptions(); // Call presentation logic if hidden and pending
- }
- }
- function setupColorSelection() {
- gameContainerDiv.style.display = 'none'; // Ensure game hidden initially
- colorSelectionDiv.style.display = 'block'; // Ensure selection visible
- isShopOpen = false; // Ensure shop closed
- shopScreen.style.display = 'none';
- colorSelectionDiv.querySelectorAll('button[data-color]').forEach(button => {
- button.style.backgroundColor = button.dataset.color;
- const bgColor = button.style.backgroundColor;
- let luminance = 150;
- try {
- // Try to calculate luminance to set text color automatically
- let r, g, b;
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(bgColor); // Check hex
- const resultRGB = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(bgColor); // Check rgb()
- if (result) {
- r = parseInt(result[1], 16);
- g = parseInt(result[2], 16);
- b = parseInt(result[3], 16);
- } else if (resultRGB) {
- r = parseInt(resultRGB[1]);
- g = parseInt(resultRGB[2]);
- b = parseInt(resultRGB[3]);
- } else { // Attempt computed style for color names
- const tempElem = document.createElement('div');
- tempElem.style.color = bgColor;
- document.body.appendChild(tempElem);
- const computedColor = window.getComputedStyle(tempElem).color;
- document.body.removeChild(tempElem);
- const rgbMatch = computedColor.match(/\d+/g);
- if (rgbMatch && rgbMatch.length >= 3) {
- r = parseInt(rgbMatch[0]);
- g = parseInt(rgbMatch[1]);
- b = parseInt(rgbMatch[2]);
- }
- }
- if (typeof r !== 'undefined') {
- luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
- }
- } catch (e) {
- console.warn("Could not parse button background color:", bgColor, e);
- }
- button.style.color = luminance > 128 ? 'black' : 'white'; // Set text color based on brightness
- button.addEventListener('click', () => {
- colorSelectionDiv.querySelector('button.selected')?.classList.remove('selected');
- button.classList.add('selected');
- });
- });
- let startButton = colorSelectionDiv.querySelector('#start-game-button');
- if (!startButton) {
- startButton = document.createElement('button');
- startButton.id = 'start-game-button';
- startButton.textContent = 'Start Game';
- startButton.style.marginTop = '20px';
- startButton.style.padding = '12px 25px';
- startButton.style.fontSize = '1.1em';
- startButton.style.cursor = 'pointer';
- startButton.style.backgroundColor = '#4CAF50';
- startButton.style.color = 'white';
- startButton.style.border = 'none';
- startButton.style.borderRadius = '5px';
- startButton.onclick = init;
- colorSelectionDiv.appendChild(startButton);
- } else {
- startButton.onclick = init;
- }
- }
- function startWave() {
- enemiesToSpawn = 5 + Math.floor(currentWave * 2.2); // Slightly more enemies per wave, steeper increase
- enemiesAlive = enemies.length;
- spawningDone = false;
- timeToNextSpawn = 2.0; // Longer initial delay
- console.log(`Starting Wave ${currentWave} with ${enemiesToSpawn} enemies.`);
- playSound('wave_start');
- updateUI();
- }
- function spawnEnemy() {
- if (enemiesToSpawn <= 0) {
- spawningDone = true;
- return;
- }
- if (!player) return;
- const spawnDist = Math.max(canvas.width, canvas.height) * 0.7;
- const angle = Math.random() * Math.PI * 2;
- const safeRadius = 200;
- let spawnXBase = player.x + Math.cos(angle) * spawnDist;
- let spawnYBase = player.y + Math.sin(angle) * spawnDist;
- const dxFromPlayer = spawnXBase - player.x;
- const dyFromPlayer = spawnYBase - player.y;
- // Ensure currentDist is not zero before dividing
- let currentDist = Math.sqrt(dxFromPlayer * dxFromPlayer + dyFromPlayer * dyFromPlayer);
- if (currentDist < 0.1) currentDist = 0.1; // Prevent division by zero
- const dirX = dxFromPlayer / currentDist;
- const dirY = dyFromPlayer / currentDist;
- const finalDist = Math.max(spawnDist, currentDist + safeRadius);
- const spawnX = player.x + dirX * finalDist;
- const spawnY = player.y + dirY * finalDist;
- let enemyTypeChoice = Math.random();
- let newEnemy;
- const waveFactor = currentWave / 10; // Simple factor for adjusting probability
- if (currentWave >= 4 && enemyTypeChoice < 0.15 + waveFactor * 0.1) {
- newEnemy = new TankEnemy(spawnX, spawnY, currentWave);
- } else if (currentWave >= 2 && enemyTypeChoice < 0.4 + waveFactor * 0.15) {
- newEnemy = new FastEnemy(spawnX, spawnY, currentWave);
- } else {
- newEnemy = new Enemy(spawnX, spawnY, currentWave);
- }
- enemies.push(newEnemy);
- enemiesToSpawn--;
- updateUI();
- timeToNextSpawn = Math.max(0.05, 1.1 - currentWave * 0.04); // Reduced min spawn time slightly
- }
- function update(dt) {
- // Skip most updates if shop is open (except things that should continue like timers)
- // Player input and attacks are blocked elsewhere.
- // Enemies, projectiles, pickups should still update.
- gameTime += dt;
- // Update Camera Shake (always update)
- if (camera.shakeDuration > 0) {
- camera.shakeDuration -= dt;
- const decayFactor = Math.pow(CAMERA_SHAKE_DECAY, dt * 60);
- camera.shakeIntensity *= decayFactor;
- if (camera.shakeDuration <= 0 || camera.shakeIntensity < 0.1) {
- camera.shakeIntensity = 0;
- camera.shakeDuration = 0;
- }
- }
- if (!player || isGameOver) return; // Stop if no player or game over
- // Update Player (Handles movement, sprinting, powerups, aiming, reloading, melee anim)
- player.update(dt);
- // Update Camera to follow player smoothly (always update if player exists)
- const cameraLag = 0.08;
- const lerpFactor = 1 - Math.pow(1 - cameraLag, dt * 60);
- camera.x += (player.x - camera.x) * lerpFactor;
- camera.y += (player.y - camera.y) * lerpFactor;
- // --- Updates that run even if shop is open ---
- enemies.forEach(enemy => enemy.update(dt, player));
- projectiles.forEach(p => p.update(dt));
- xpOrbs.forEach(orb => orb.update(dt, player));
- moneyPickups.forEach(mp => mp.update(dt, player)); // Update money pickups
- ammoPickups.forEach(ap => ap.update(dt));
- powerUps.forEach(pu => pu.update(dt));
- particles.forEach(p => p.update(dt));
- turrets.forEach(t => t.update(dt)); // Update turrets
- // Spawning (runs if not paused by shop)
- if (!spawningDone && !isShopOpen) {
- timeToNextSpawn -= dt;
- if (timeToNextSpawn <= 0) {
- spawnEnemy();
- }
- }
- // --- Cleanup Dead/Expired Entities ---
- projectiles = projectiles.filter(p => p.life > 0);
- enemiesAlive = enemies.filter(e => e.health > 0).length;
- enemies = enemies.filter(e => e.health > 0);
- turrets = turrets.filter(t => t.health > 0); // Remove destroyed turrets
- // Cleanup pickups (XP)
- xpOrbs = xpOrbs.filter(orb => {
- const collectionRadius = player.size + orb.size / 2 + XP_ORB_MAGNET_RADIUS * 0.1;
- const distSq = distanceSq(player.x, player.y, orb.x, orb.y); // Use squared distance
- const collected = distSq < collectionRadius * collectionRadius;
- if (collected) {
- player.addXp(orb.value);
- playSound('pickup_xp', {volume: 0.3});
- createParticles(player.x, player.y, 3, XP_ORB_COLOR, 50);
- return false;
- }
- return true;
- });
- // Cleanup pickups (Money)
- moneyPickups = moneyPickups.filter(mp => {
- if (mp.life <= 0) return false;
- const collectionRadius = player.size + mp.size / 2 + MONEY_PICKUP_MAGNET_RADIUS * 0.1;
- const distSq = distanceSq(player.x, player.y, mp.x, mp.y);
- const collected = distSq < collectionRadius * collectionRadius;
- if (collected) {
- player.addMoney(mp.value); // Add money to player
- playSound('pickup_money', {volume: 0.4});
- createParticles(player.x, player.y, 4, MONEY_PICKUP_COLOR, 60, null, null, {life: 0.4}); // Gold particles
- return false; // Remove the pickup
- }
- return true; // Keep the pickup
- });
- ammoPickups = ammoPickups.filter(ap => {
- if (ap.life <= 0) return false;
- const collected = distanceSq(player.x, player.y, ap.x, ap.y) < (player.size + ap.size / 2 + 5) ** 2; // Squared dist
- if (collected) {
- handleAmmoPickup(ap);
- return false;
- }
- return true;
- });
- powerUps = powerUps.filter(pu => {
- if (pu.life <= 0) return false;
- const collected = distanceSq(player.x, player.y, pu.x, pu.y) < (player.size + pu.size) ** 2; // Squared dist
- if (collected) {
- player.activatePowerUp(pu.type, pu.duration);
- createParticles(player.x, player.y, 20, pu.color, 150, null, Math.PI * 2, {life: 0.6, friction: 0.96});
- return false;
- }
- return true;
- });
- particles = particles.filter(p => p.life > 0);
- if (particles.length > MAX_PARTICLES) {
- particles.splice(0, particles.length - MAX_PARTICLES);
- }
- // Collisions
- checkCollisions(dt);
- // Check for next wave
- if (player && spawningDone && enemiesAlive <= 0 && !isShopOpen) { // Don't start wave if shop is open
- currentWave++;
- startWave();
- player.health = Math.min(player.maxHealth, player.health + player.maxHealth * 0.1); // Heal 10%
- Object.keys(player.ammo).forEach(ammoType => {
- const weaponDef = player.weapons.find(w => w.ammoType === ammoType);
- if (weaponDef && weaponDef.maxAmmo) {
- const bonus = Math.ceil(weaponDef.maxAmmo * 0.05);
- player.ammo[ammoType] = Math.min(weaponDef.maxAmmo, (player.ammo[ammoType] || 0) + bonus);
- }
- });
- updateUI();
- }
- }
- function handleAmmoPickup(pickup) {
- if (!player) return;
- const weapon = player.weapons.find(w => w.ammoType === pickup.ammoType);
- let ammoAdded = 0;
- const currentReserve = player.ammo[pickup.ammoType] || 0;
- // Check weapon definition for max ammo limit
- const maxReserve = (weapon && typeof weapon.maxAmmo !== 'undefined') ? weapon.maxAmmo : Infinity; // Assume infinite if no definition
- ammoAdded = Math.min(pickup.amount, maxReserve - currentReserve); // Don't exceed max reserve
- ammoAdded = Math.max(0, ammoAdded); // Ensure not negative
- if (ammoAdded > 0) {
- player.ammo[pickup.ammoType] = currentReserve + ammoAdded;
- const maxDisplay = maxReserve === Infinity ? '∞' : maxReserve;
- console.log(`Picked up ${ammoAdded} ${pickup.ammoType} ammo (Reserve: ${player.ammo[pickup.ammoType]}/${maxDisplay}).`);
- playSound('pickup_ammo');
- createParticles(player.x, player.y, 5, AMMO_PICKUP_COLOR, 40, null, Math.PI * 2, {life: 0.3});
- updateUI();
- // Update shop button states if shop is open
- if (isShopOpen) {
- updateShopItemStates();
- }
- } else {
- // Optional: Sound effect for full ammo?
- }
- }
- // checkCollisions updated to pass dt
- function checkCollisions(dt) {
- if (!player) return;
- // Projectile vs Entity (Enemies and Turrets)
- for (let i = projectiles.length - 1; i >= 0; i--) {
- const p = projectiles[i];
- if (!p) continue;
- let projectileRemoved = false;
- // Check vs Enemies
- for (let j = enemies.length - 1; j >= 0; j--) {
- if (projectileRemoved) break; // Projectile already hit something
- const e = enemies[j];
- if (!e || e.health <= 0) continue;
- // Check if projectile owner is the enemy itself (prevent self-harm if enemies shoot later)
- if (p.owner === e) continue;
- const effectiveEnemyRadius = e.size * 0.5;
- const distSq = distanceSq(p.x, p.y, e.x, e.y);
- const radiiSumSq = (p.size + effectiveEnemyRadius) ** 2;
- if (distSq < radiiSumSq) {
- const damageDealt = p.damage;
- // Pass projectile owner as source
- const died = e.takeDamage(damageDealt, p.owner);
- playSound('hit_enemy', {volume: 0.4});
- const knockbackResistance = e.knockbackResistance || 0;
- const baseKnockback = p.knockback || 0;
- const actualKnockback = baseKnockback * (1 - knockbackResistance);
- if (actualKnockback > 0) {
- const pushDir = normalize(e.x - p.x, e.y - p.y);
- if (pushDir.x !== 0 || pushDir.y !== 0) {
- const impulseStrength = actualKnockback * 0.5;
- e.vx += pushDir.x * impulseStrength;
- e.vy += pushDir.y * impulseStrength;
- }
- }
- projectiles.splice(i, 1);
- projectileRemoved = true;
- if (died) {
- e.onDeath();
- }
- break; // Projectile hits only one enemy
- }
- }
- // Check vs Turrets (only if projectile wasn't from a turret or player)
- if (!projectileRemoved && p.owner !== player && !(p.owner instanceof Turret)) { // Only enemy projectiles can hit turrets
- for (let j = turrets.length - 1; j >= 0; j--) {
- if (projectileRemoved) break;
- const t = turrets[j];
- if (!t || t.health <= 0) continue;
- const distSq = distanceSq(p.x, p.y, t.x, t.y);
- const radiiSumSq = (p.size + t.size) ** 2;
- if (distSq < radiiSumSq) {
- const damageDealt = p.damage; // Enemy projectile damage vs turret
- // Pass projectile owner as source
- const died = t.takeDamage(damageDealt, p.owner);
- // Don't play enemy hit sound, turret has its own
- projectiles.splice(i, 1);
- projectileRemoved = true;
- // Turret death handled in its own onDeath method
- break;
- }
- }
- }
- }
- // Player vs Enemy
- enemies.forEach(e => {
- if (!e || e.health <= 0 || !player) return;
- const effectiveEnemyRadius = e.size * 0.5;
- const distSq = distanceSq(player.x, player.y, e.x, e.y); // Use squared distance
- const collisionThreshold = player.size + effectiveEnemyRadius;
- const collisionThresholdSq = collisionThreshold * collisionThreshold;
- if (distSq < collisionThresholdSq) {
- player.takeDamage(e.damage * dt, e); // Pass enemy as source
- const knockbackStrength = 100 + e.damage * 4;
- const distBetween = Math.sqrt(distSq); // Calculate actual dist only on collision
- const pushMagnitude = (collisionThreshold - distBetween) / 2.0;
- // Avoid division by zero if distBetween is tiny
- const dx = player.x - e.x;
- const dy = player.y - e.y;
- let pushDirX = 0, pushDirY = 0;
- if (distBetween > 0.01) {
- pushDirX = dx / distBetween;
- pushDirY = dy / distBetween;
- } else { // Random push if perfectly overlapped
- const randAngle = Math.random() * Math.PI * 2;
- pushDirX = Math.cos(randAngle);
- pushDirY = Math.sin(randAngle);
- }
- const separationForce = pushMagnitude * 5.0;
- player.vx += pushDirX * separationForce * dt;
- player.vy += pushDirY * separationForce * dt;
- e.vx -= pushDirX * separationForce * 0.5 * dt;
- e.vy -= pushDirY * separationForce * 0.5 * dt;
- const playerKnockbackImpulse = knockbackStrength * dt * 1.5;
- player.vx += pushDirX * playerKnockbackImpulse;
- player.vy += pushDirY * playerKnockbackImpulse;
- }
- });
- // Shield Orb vs Enemy
- if (player.powerUps.shieldOrbs > 0) {
- const orbBaseRadius = SHIELD_ORB_RADIUS;
- const now = gameTime;
- for (let i = 0; i < SHIELD_ORB_COUNT; i++) {
- const angle = player.shieldOrbAngle + (i * Math.PI * 2 / SHIELD_ORB_COUNT);
- const orbX = player.x + Math.cos(angle) * orbBaseRadius;
- const orbY = player.y + Math.sin(angle) * orbBaseRadius;
- const orbRadius = 8; // Visual radius, use slightly larger for collision?
- const orbCollisionRadiusSq = (orbRadius + 5) ** 2; // Squared radius for collision
- enemies.forEach(e => {
- if (!e || e.health <= 0) return;
- // Check if enemy is on cooldown for this orb hit
- const cooldownKey = `${e.id}`; // Key based on enemy ID
- if (player.shieldOrbHitCooldowns[cooldownKey] > now) {
- return; // On cooldown for this enemy
- }
- const distSq = distanceSq(orbX, orbY, e.x, e.y);
- const radiiSumSq = orbCollisionRadiusSq + (e.size * 0.5) ** 2;
- if (distSq < radiiSumSq) {
- const died = e.takeDamage(SHIELD_ORB_DAMAGE, player); // Damage enemy, source is player
- playSound('shield_hit_enemy', {volume: 0.4});
- createParticles(orbX, orbY, 5, 'rgba(100, 200, 255, 0.8)', 80, null, Math.PI, {life: 0.3}); // Hit particles
- // Apply cooldown for this enemy
- player.shieldOrbHitCooldowns[cooldownKey] = now + SHIELD_ORB_HIT_COOLDOWN;
- // Apply slight knockback from orb hit
- const knockbackResistance = e.knockbackResistance || 0;
- const knockbackStrength = 80 * (1 - knockbackResistance);
- const pushDir = normalize(e.x - orbX, e.y - orbY);
- if (pushDir.x !== 0 || pushDir.y !== 0) {
- e.vx += pushDir.x * knockbackStrength * 0.8;
- e.vy += pushDir.y * knockbackStrength * 0.8;
- }
- if (died) {
- e.onDeath();
- }
- }
- });
- }
- }
- // Player vs Pickups (handled in update loop filter)
- }
- function render() {
- ctx.fillStyle = '#1a1a1a';
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- drawBackground();
- // --- Draw Game Objects ---
- xpOrbs.forEach(orb => orb.draw(ctx));
- moneyPickups.forEach(mp => mp.draw(ctx)); // Draw money pickups
- ammoPickups.forEach(ap => ap.draw(ctx));
- powerUps.forEach(pu => pu.draw(ctx));
- enemies.forEach(enemy => enemy.draw(ctx));
- turrets.forEach(t => t.draw(ctx)); // Draw turrets
- if (player) {
- player.draw(ctx); // Draws player AND shield orbs
- }
- projectiles.forEach(p => p.draw(ctx));
- particles.forEach(p => p.draw(ctx));
- // --- Draw Off-Screen Enemy Indicators ---
- drawOffscreenIndicators();
- // Damage Text is handled by DOM elements
- }
- function drawBackground() {
- if (noise) {
- const TILE_RENDER_SIZE = GRID_SIZE;
- ctx.globalAlpha = 0.5;
- const shakeBuffer = camera.shakeIntensity > 0 ? camera.shakeIntensity * 1.5 : GRID_SIZE;
- const viewWidth = canvas.width + shakeBuffer * 2;
- const viewHeight = canvas.height + shakeBuffer * 2;
- const startWorldX = camera.x - viewWidth / 2;
- const startWorldY = camera.y - viewHeight / 2;
- const endWorldX = startWorldX + viewWidth;
- const endWorldY = startWorldY + viewHeight;
- 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);
- const baseHue = 190 + (currentWave * 8) % 170;
- const baseSaturation = 50;
- const baseLightness = 15;
- 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;
- const timeFactor = gameTime * 5;
- const noiseValue = noise.simplex3(worldX / BACKGROUND_NOISE_SCALE, worldY / BACKGROUND_NOISE_SCALE, timeFactor / BACKGROUND_NOISE_SCALE);
- const lightnessVariation = Math.floor((noiseValue + 1) / 2 * 10);
- const saturationVariation = Math.floor((noiseValue + 1) / 2 * 15);
- ctx.fillStyle = `hsl(${baseHue}, ${baseSaturation + saturationVariation}%, ${baseLightness + lightnessVariation}%)`;
- const screenPos = worldToScreen(worldX + TILE_RENDER_SIZE / 2, worldY + TILE_RENDER_SIZE / 2); // Center of tile to screen
- const screenTileX = screenPos.x - TILE_RENDER_SIZE / 2;
- const screenTileY = screenPos.y - TILE_RENDER_SIZE / 2;
- ctx.fillRect(Math.floor(screenTileX), Math.floor(screenTileY), TILE_RENDER_SIZE, TILE_RENDER_SIZE);
- }
- }
- ctx.globalAlpha = 1.0;
- } else {
- // Fallback Grid
- ctx.strokeStyle = '#444';
- ctx.lineWidth = 1;
- const screenOffsetX = (camera.x - canvas.width / 2) % GRID_SIZE;
- const screenOffsetY = (camera.y - canvas.height / 2) % GRID_SIZE;
- const startGridX = -screenOffsetX;
- const startGridY = -screenOffsetY;
- for (let x = startGridX; x < canvas.width; x += GRID_SIZE) {
- ctx.beginPath();
- ctx.moveTo(x, 0);
- ctx.lineTo(x, canvas.height);
- ctx.stroke();
- }
- for (let y = startGridY; y < canvas.height; y += GRID_SIZE) {
- ctx.beginPath();
- ctx.moveTo(0, y);
- ctx.lineTo(canvas.width, y);
- ctx.stroke();
- }
- }
- }
- // --- drawOffscreenIndicators (FIXED) ---
- function drawOffscreenIndicators() {
- if (!player || enemies.length === 0) return;
- // Use camera center as the reference point on screen
- const playerScreenPos = {x: canvas.width / 2, y: canvas.height / 2};
- const margin = OFFSCREEN_INDICATOR_MARGIN;
- const minX = margin;
- const minY = margin;
- const maxX = canvas.width - margin;
- const maxY = canvas.height - margin;
- const screenBounds = {x: minX, y: minY, w: maxX - minX, h: maxY - minY}; // Use min/max coords
- enemies.forEach(enemy => {
- if (enemy.health <= 0) return;
- const enemyScreenPos = worldToScreen(enemy.x, enemy.y);
- // Check if enemy is outside the margin area
- if (enemyScreenPos.x < minX || enemyScreenPos.x > maxX ||
- enemyScreenPos.y < minY || enemyScreenPos.y > maxY) {
- // Calculate direction vector from player screen pos to enemy screen pos
- const dirX = enemyScreenPos.x - playerScreenPos.x;
- const dirY = enemyScreenPos.y - playerScreenPos.y;
- // Find intersection using parametric line equation: P = player + t * dir
- let t = Infinity; // Parameter t for intersection
- // Check intersection with vertical edges
- if (dirX !== 0) {
- let tX1 = (minX - playerScreenPos.x) / dirX; // Intersection with left edge
- let tX2 = (maxX - playerScreenPos.x) / dirX; // Intersection with right edge
- // Ensure t > 0 (intersection is *beyond* the player towards the enemy)
- if (tX1 > 0) {
- let intersectY = playerScreenPos.y + tX1 * dirY;
- if (intersectY >= minY && intersectY <= maxY) { // Check if within vertical bounds of the edge
- t = Math.min(t, tX1);
- }
- }
- if (tX2 > 0) {
- let intersectY = playerScreenPos.y + tX2 * dirY;
- if (intersectY >= minY && intersectY <= maxY) {
- t = Math.min(t, tX2);
- }
- }
- }
- // Check intersection with horizontal edges
- if (dirY !== 0) {
- let tY1 = (minY - playerScreenPos.y) / dirY; // Intersection with top edge
- let tY2 = (maxY - playerScreenPos.y) / dirY; // Intersection with bottom edge
- // Ensure t > 0
- if (tY1 > 0) {
- let intersectX = playerScreenPos.x + tY1 * dirX;
- if (intersectX >= minX && intersectX <= maxX) { // Check if within horizontal bounds of the edge
- t = Math.min(t, tY1);
- }
- }
- if (tY2 > 0) {
- let intersectX = playerScreenPos.x + tY2 * dirX;
- if (intersectX >= minX && intersectX <= maxX) {
- t = Math.min(t, tY2);
- }
- }
- }
- let edgePos;
- if (t !== Infinity && t > 0) {
- // Calculate the actual intersection point on the boundary
- edgePos = {
- x: playerScreenPos.x + t * dirX,
- y: playerScreenPos.y + t * dirY
- };
- // Clamp to be strictly within bounds just in case of floating point errors
- edgePos.x = Math.max(minX, Math.min(maxX, edgePos.x));
- edgePos.y = Math.max(minY, Math.min(maxY, edgePos.y));
- } else {
- // Fallback: If no valid intersection found (shouldn't happen theoretically)
- // Clamp the original enemy screen position to the edge
- edgePos = {
- x: Math.max(minX, Math.min(maxX, enemyScreenPos.x)),
- y: Math.max(minY, Math.min(maxY, enemyScreenPos.y))
- };
- }
- // Calculate angle from the edge position towards the actual off-screen enemy position
- const angle = Math.atan2(enemyScreenPos.y - edgePos.y, enemyScreenPos.x - edgePos.x);
- // Calculate distance from player to enemy for opacity/color
- const dist = distance(player.x, player.y, enemy.x, enemy.y);
- // --- Opacity Calculation (same as before) ---
- let alpha = 0;
- if (dist > OFFSCREEN_INDICATOR_MIN_DIST_OPACITY) {
- alpha = Math.min(1, (dist - OFFSCREEN_INDICATOR_MIN_DIST_OPACITY) / (OFFSCREEN_INDICATOR_MAX_DIST_OPACITY - OFFSCREEN_INDICATOR_MIN_DIST_OPACITY));
- }
- alpha = Math.max(0, Math.min(1, alpha)); // Clamp alpha 0-1
- // Dim further if very close to screen edge (using enemyScreenPos distance to edge)
- const distToEdgeX = Math.min(enemyScreenPos.x - minX, maxX - enemyScreenPos.x);
- const distToEdgeY = Math.min(enemyScreenPos.y - minY, maxY - enemyScreenPos.y);
- const minDistToEdge = Math.min(distToEdgeX, distToEdgeY);
- if (minDistToEdge < OFFSCREEN_INDICATOR_MIN_DIST_OPACITY * 0.5 && minDistToEdge > 0) {
- alpha *= Math.max(0, minDistToEdge / (OFFSCREEN_INDICATOR_MIN_DIST_OPACITY * 0.5));
- }
- if (alpha <= 0.01) return; // Don't draw if basically invisible
- // --- End Opacity Calculation ---
- // --- Draw the indicator (same as before) ---
- ctx.save();
- ctx.translate(edgePos.x, edgePos.y);
- ctx.rotate(angle);
- ctx.fillStyle = `rgba(255, 0, 0, ${alpha * 0.8})`; // Red indicator, use calculated alpha
- // Draw triangle pointing in the calculated angle
- ctx.beginPath();
- ctx.moveTo(OFFSCREEN_INDICATOR_SIZE * 0.8, 0); // Point tip
- ctx.lineTo(-OFFSCREEN_INDICATOR_SIZE * 0.4, -OFFSCREEN_INDICATOR_SIZE * 0.5); // Back left
- ctx.lineTo(-OFFSCREEN_INDICATOR_SIZE * 0.4, OFFSCREEN_INDICATOR_SIZE * 0.5); // Back right
- ctx.closePath();
- ctx.fill();
- ctx.restore();
- // --- End Drawing ---
- }
- });
- }
- function createParticles(x, y, count, color, speed, baseAngle = null, spread = Math.PI * 2, options = {}) {
- if (!Number.isFinite(x) || !Number.isFinite(y)) {
- console.warn("Attempted to create particles at invalid position:", x, y);
- return;
- }
- if (count <= 0) return;
- const maxToAdd = Math.max(0, MAX_PARTICLES - particles.length);
- count = Math.min(count, maxToAdd);
- if (count === 0) return;
- for (let i = 0; i < count; i++) {
- const angle = (baseAngle !== null)
- ? baseAngle + (Math.random() - 0.5) * spread
- : Math.random() * Math.PI * 2;
- const currentSpeed = speed * (options.speedVariance !== false ? (0.6 + Math.random() * 0.8) : 1.0);
- const vx = Math.cos(angle) * currentSpeed;
- const vy = Math.sin(angle) * currentSpeed;
- const size = options.size || (Math.random() * 2.5 + 1);
- const life = (options.life || PARTICLE_LIFETIME) * (0.7 + Math.random() * 0.6);
- let particleColor = color;
- // --- Color variation logic (keep existing) ---
- if (options.colorVariance && typeof color === 'string' && color.startsWith('hsl')) {
- const match = color.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
- if (match) {
- const h = parseInt(match[1]);
- const s = parseInt(match[2]);
- const l = parseInt(match[3]);
- const lVar = Math.floor((Math.random() - 0.5) * 10);
- const sVar = Math.floor((Math.random() - 0.5) * 10);
- particleColor = `hsl(${h}, ${Math.max(0, Math.min(100, s + sVar))}%, ${Math.max(0, Math.min(100, l + lVar))}%)`;
- }
- } else if (options.colorVariance && typeof color === 'string' && color.startsWith('#')) {
- try {
- let r = parseInt(color.substring(1, 3), 16);
- let g = parseInt(color.substring(3, 5), 16);
- let b = parseInt(color.substring(5, 7), 16);
- r = Math.max(0, Math.min(255, r + Math.floor((Math.random() - 0.5) * 40)));
- g = Math.max(0, Math.min(255, g + Math.floor((Math.random() - 0.5) * 40)));
- b = Math.max(0, Math.min(255, b + Math.floor((Math.random() - 0.5) * 40)));
- particleColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
- } catch (e) { /* Ignore */
- }
- } else if (options.colorVariance && typeof color === 'string' && color.startsWith('rgba')) {
- // Try simple alpha variation for rgba
- const match = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/);
- if (match) {
- const r = match[1];
- const g = match[2];
- const b = match[3];
- let a = parseFloat(match[4]);
- a = Math.max(0.1, Math.min(1, a * (0.8 + Math.random() * 0.4))); // Vary alpha
- particleColor = `rgba(${r}, ${g}, ${b}, ${a.toFixed(2)})`;
- }
- } else if (options.colorVariance && ['red', 'yellow', 'blue', 'green', 'orange', 'cyan', 'magenta'].includes(color)) {
- if (color === 'red') {
- const shades = ['#ff0000', '#ee0000', '#dd0000', '#cc0000', '#bb0000', '#aa0000'];
- particleColor = shades[Math.floor(Math.random() * shades.length)];
- }
- }
- // --- End color variation ---
- particles.push(new Particle(x, y, vx, vy, size, particleColor, life, options));
- }
- }
- function updateUI() {
- if (!player) {
- uiLevel.textContent = '-';
- uiXp.textContent = '-';
- uiXpNeeded.textContent = '-';
- uiHealth.textContent = '-';
- uiMaxHealth.textContent = '-';
- uiStaminaBar.style.width = '0%'; // Clear stamina bar
- uiMoney.textContent = '0'; // Clear money
- uiWave.textContent = '-';
- uiEnemiesLeft.textContent = '-';
- uiCurrentWeapon.textContent = '-';
- uiAmmo.textContent = '-';
- uiPowerUpStatus.innerHTML = '';
- levelUpIndicator.style.display = 'none';
- levelUpOptionsDiv.style.display = 'none';
- reloadIndicatorDiv.style.display = 'none';
- return;
- }
- uiLevel.textContent = player.level;
- uiXp.textContent = Math.floor(player.xp);
- uiXpNeeded.textContent = player.xpToNextLevel;
- uiHealth.textContent = Math.ceil(player.health);
- uiMaxHealth.textContent = player.maxHealth;
- // Update Stamina Bar
- uiStaminaBar.style.width = `${Math.max(0, player.currentStamina / player.maxStamina * 100)}%`;
- uiStaminaBar.style.backgroundColor = player.canSprint ? '#4CAF50' : '#FFA500'; // Green normally, Orange when depleted/cooling down
- uiMoney.textContent = player.money; // Update money display
- uiWave.textContent = currentWave;
- uiEnemiesLeft.textContent = enemiesAlive + enemiesToSpawn;
- // Weapon and Ammo
- const weapon = player.currentWeapon;
- uiCurrentWeapon.textContent = weapon.name;
- if (player.isReloading) {
- uiAmmo.innerHTML = `<span style="color:orange;">Reloading... (${player.reloadTimer.toFixed(1)}s)</span>`;
- } else if (weapon.type === 'ranged' && typeof weapon.clipSize !== 'undefined') {
- const ammoInClip = weapon.ammoInClip;
- const reserveAmmo = player.ammo[weapon.ammoType] || 0;
- const maxReserve = weapon.maxAmmo !== undefined ? weapon.maxAmmo : '∞';
- let ammoColor = 'white';
- if (ammoInClip <= weapon.clipSize * 0.2) ammoColor = 'orange';
- if (ammoInClip <= 0) ammoColor = 'red';
- uiAmmo.innerHTML = `<span style="color:${ammoColor};">${ammoInClip}</span> / ${reserveAmmo}${maxReserve !== '∞' ? '/' + maxReserve : ''}`;
- } else if (weapon.type === 'melee') {
- const swingsPerSecond = (weapon.rateOfFire * (player.powerUps.rapidFire > 0 ? 1.5 : 1)).toFixed(1);
- uiAmmo.textContent = `Swing: ${swingsPerSecond}/s`;
- } else {
- uiAmmo.textContent = "∞";
- }
- // Power-up Status
- let powerUpHtml = '';
- for (const type in player.powerUps) {
- if (player.powerUps[type] > 0) {
- const formattedName = type.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
- // Special name for shield
- const displayName = type === 'shieldOrbs' ? 'Shield Orbs' : formattedName;
- powerUpHtml += `<div class="power-up-timer">${displayName}: ${player.powerUps[type].toFixed(1)}s</div>`;
- }
- }
- if (player.healthRegen && player.healthRegen > 0) {
- powerUpHtml += `<div style="color:lightgreen;">Regen: ${player.healthRegen.toFixed(1)}/s</div>`;
- }
- uiPowerUpStatus.innerHTML = powerUpHtml;
- // Level Up Indicator UI
- if (pendingLevelUps > 0 && !isShopOpen) { // Hide if shop is open
- levelUpIndicator.style.display = 'block';
- pendingLevelUpsSpan.textContent = pendingLevelUps;
- } else {
- levelUpIndicator.style.display = 'none';
- if (isShowingLevelUpOptions) { // Hide options panel if indicator is hidden
- levelUpOptionsDiv.style.display = 'none';
- isShowingLevelUpOptions = false;
- }
- }
- }
- // --- Level Up Logic (Modified) ---
- function presentLevelUpOptions() {
- if (!player || pendingLevelUps <= 0 || isShowingLevelUpOptions || isShopOpen) {
- if (pendingLevelUps <= 0 || isShopOpen) {
- levelUpOptionsDiv.style.display = 'none';
- isShowingLevelUpOptions = false;
- }
- return;
- }
- isShowingLevelUpOptions = true;
- levelUpOptionsDiv.innerHTML = '';
- levelUpOptionsDiv.style.display = 'flex';
- const options = generateUpgradeOptions(3);
- const header = document.createElement('div');
- const upgradeLevel = player.level - pendingLevelUps + 1;
- header.textContent = `Choose Upgrade (Level ${upgradeLevel})`;
- header.style.fontWeight = 'bold';
- header.style.marginBottom = '10px';
- header.style.textAlign = 'center';
- levelUpOptionsDiv.appendChild(header);
- options.forEach(option => {
- const button = document.createElement('button');
- button.classList.add('level-up-button');
- button.textContent = option.description;
- button.onclick = () => {
- applyUpgrade(option);
- pendingLevelUps--;
- updateUI();
- levelUpOptionsDiv.style.display = 'none';
- isShowingLevelUpOptions = false;
- playSound('upgrade_select');
- createParticles(player.x, player.y, 30, 'gold', 120, null, Math.PI * 1.5);
- if (pendingLevelUps > 0) {
- setTimeout(presentLevelUpOptions, 50);
- }
- };
- levelUpOptionsDiv.appendChild(button);
- });
- }
- function generateUpgradeOptions(count) {
- if (!player) return [];
- const possibleUpgrades = [];
- // --- Player Stat Upgrades ---
- possibleUpgrades.push({type: 'player_stat', stat: 'maxHealth', value: 25, description: 'Max Health +25'});
- possibleUpgrades.push({type: 'player_stat', stat: 'speed', value: 8, description: 'Move Speed +8%'});
- possibleUpgrades.push({
- type: 'player_stat',
- stat: 'magnet',
- value: 30,
- description: 'Pickup Magnet Radius +30%'
- });
- possibleUpgrades.push({type: 'player_stat', stat: 'regen', value: 0.1, description: 'Health Regen +0.1/s'});
- // Sprint Upgrades
- possibleUpgrades.push({type: 'player_stat', stat: 'maxStamina', value: 20, description: 'Max Stamina +20%'}); // Percentage based
- possibleUpgrades.push({
- type: 'player_stat',
- stat: 'staminaRegen',
- value: 15,
- description: 'Stamina Regen +15%'
- }); // Percentage based
- // --- Weapon Specific Upgrades ---
- player.weapons.forEach((weapon, index) => {
- if (!weapon) return;
- const upgradePrefix = `${weapon.name}: `;
- if (weapon.type === 'ranged') {
- if (typeof weapon.damage !== 'undefined') {
- const dmgIncrease = Math.max(1, Math.ceil(weapon.damage * 0.12));
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'damage',
- value: dmgIncrease,
- description: upgradePrefix + `Damage +${dmgIncrease}`
- });
- }
- if (typeof weapon.rateOfFire !== 'undefined') {
- const rofIncrease = weapon.rateOfFire * 0.1;
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'rateOfFire',
- value: rofIncrease,
- description: upgradePrefix + `Fire Rate +10%`
- });
- }
- if (typeof weapon.clipSize !== 'undefined' && typeof weapon.reloadTime !== 'undefined') {
- const clipIncrease = Math.max(1, Math.ceil(weapon.clipSize * 0.15));
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'clipSize',
- value: clipIncrease,
- description: upgradePrefix + `Clip Size +${clipIncrease}`
- });
- const reloadDecrease = -weapon.reloadTime * 0.15;
- if (weapon.reloadTime + reloadDecrease >= 0.3) {
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'reloadTime',
- value: reloadDecrease,
- description: upgradePrefix + `Reload Speed +15%`
- });
- }
- }
- if (typeof weapon.maxAmmo !== 'undefined') {
- const ammoIncrease = Math.ceil(weapon.maxAmmo * 0.2);
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'maxAmmo',
- value: ammoIncrease,
- description: upgradePrefix + `Max Reserve Ammo +20%`
- });
- }
- if (typeof weapon.recoil !== 'undefined' && weapon.recoil > 0) {
- const recoilReduction = -weapon.recoil * 0.20;
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'recoil',
- value: recoilReduction,
- description: upgradePrefix + `Recoil -20%`
- });
- }
- if (typeof weapon.accuracy !== 'undefined' && weapon.accuracy > 0.01) {
- const accuracyImprovement = -weapon.accuracy * 0.15;
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'accuracy',
- value: accuracyImprovement,
- description: upgradePrefix + `Accuracy +15%`
- });
- }
- } else if (weapon.type === 'melee') {
- if (typeof weapon.damage !== 'undefined') {
- const dmgIncrease = Math.max(1, Math.ceil(weapon.damage * 0.15));
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'damage',
- value: dmgIncrease,
- description: upgradePrefix + `Damage +${dmgIncrease}`
- });
- }
- if (typeof weapon.rateOfFire !== 'undefined') {
- const rofIncrease = weapon.rateOfFire * 0.12;
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'rateOfFire',
- value: rofIncrease,
- description: upgradePrefix + `Swing Speed +12%`
- });
- }
- if (typeof weapon.range !== 'undefined') {
- const rangeIncrease = Math.ceil(weapon.range * 0.1);
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'range',
- value: rangeIncrease,
- description: upgradePrefix + `Range +10%`
- });
- }
- if (typeof weapon.knockback !== 'undefined') {
- const knockbackIncrease = weapon.knockback * 0.15;
- possibleUpgrades.push({
- type: 'weapon_stat',
- index: index,
- stat: 'knockback',
- value: knockbackIncrease,
- description: upgradePrefix + `Knockback +15%`
- });
- }
- }
- });
- const filteredUpgrades = possibleUpgrades.filter(opt => {
- if (opt.type === 'weapon_stat') {
- if (!player.weapons[opt.index]) return false;
- const weapon = player.weapons[opt.index];
- if (opt.stat === 'accuracy' && typeof weapon.accuracy !== 'undefined' && weapon.accuracy < 0.02) return false;
- if (opt.stat === 'reloadTime' && typeof weapon.reloadTime !== 'undefined' && weapon.reloadTime < 0.5) return false;
- if (typeof weapon?.[opt.stat] === 'undefined') return false;
- }
- if (opt.type === 'player_stat') {
- // Check player stat upgrade limits/conditions
- if (opt.stat === 'regen' && player.healthRegen >= 2) return false;
- // Ensure base stat exists for percentage increases
- if ((opt.stat === 'maxStamina' && typeof player.maxStamina === 'undefined') ||
- (opt.stat === 'staminaRegen' && typeof player.staminaRegenRate === 'undefined') ||
- (opt.stat === 'speed' && typeof player.baseSpeed === 'undefined') ||
- (opt.stat === 'magnet' && typeof player.baseMagnetRadius === 'undefined')) {
- // If base stat doesn't exist, don't offer % upgrade (safety check)
- // return false; // Or handle differently, maybe offer additive? For now, assume they exist.
- }
- // General check for non-special player stats
- else if (typeof player[opt.stat] === 'undefined' && !['regen', 'magnet', 'speed', 'maxStamina', 'staminaRegen'].includes(opt.stat)) {
- return false;
- }
- }
- return true;
- });
- const shuffled = filteredUpgrades.sort(() => 0.5 - Math.random());
- const selectedOptions = [];
- const usedWeaponIndices = new Set();
- const usedPlayerStats = new Set(); // Track player stats offered
- let playerStatCount = 0;
- for (const option of shuffled) {
- if (selectedOptions.length >= count) break;
- let allowOption = true;
- if (option.type === 'weapon_stat') {
- // Reduce chance of multiple upgrades for the SAME weapon
- if (usedWeaponIndices.has(option.index) && Math.random() > 0.4) {
- allowOption = false;
- }
- } else if (option.type === 'player_stat') {
- // Reduce chance of offering the SAME player stat twice
- if (usedPlayerStats.has(option.stat) && Math.random() > 0.2) {
- allowOption = false;
- }
- // Limit total player stats shown
- if (playerStatCount >= 2 && Math.random() > 0.3) {
- allowOption = false;
- }
- }
- if (allowOption) {
- selectedOptions.push(option);
- if (option.type === 'weapon_stat') {
- usedWeaponIndices.add(option.index);
- } else if (option.type === 'player_stat') {
- usedPlayerStats.add(option.stat);
- playerStatCount++;
- }
- }
- }
- // Fill remaining slots if needed
- let shuffleIndex = 0;
- while (selectedOptions.length < count && shuffleIndex < shuffled.length) {
- const nextOption = shuffled[shuffleIndex++];
- if (!selectedOptions.some(sel => sel.description === nextOption.description)) {
- selectedOptions.push(nextOption);
- }
- }
- while (selectedOptions.length < count && selectedOptions.length > 0) {
- selectedOptions.push(selectedOptions[0]); // Duplicate first option as last resort
- }
- if (selectedOptions.length === 0) {
- selectedOptions.push({
- type: 'player_stat',
- stat: 'maxHealth',
- value: 10,
- description: 'Max Health +10 (Fallback)'
- });
- while (selectedOptions.length < count) selectedOptions.push(selectedOptions[0]);
- }
- return selectedOptions.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 && typeof weapon[option.stat] !== 'undefined') {
- const oldValue = weapon[option.stat];
- weapon[option.stat] += option.value;
- // Apply constraints
- if (option.stat === 'rateOfFire') weapon[option.stat] = Math.max(0.1, weapon[option.stat]);
- if (option.stat === 'reloadTime') weapon[option.stat] = Math.max(0.3, weapon[option.stat]);
- if (option.stat === 'accuracy') weapon[option.stat] = Math.max(0.0, weapon[option.stat]);
- if (option.stat === 'recoil') weapon[option.stat] = Math.max(0, weapon[option.stat]);
- if (option.stat === 'clipSize') weapon[option.stat] = Math.max(1, Math.round(weapon[option.stat]));
- if (option.stat === 'maxAmmo') weapon[option.stat] = Math.max(0, Math.round(weapon[option.stat]));
- if (option.stat === 'knockback') weapon[option.stat] = Math.max(0, weapon[option.stat]);
- if (option.stat === 'range') weapon[option.stat] = Math.max(5, weapon[option.stat]);
- console.log(`New ${weapon.name} ${option.stat}: ${weapon[option.stat].toFixed ? weapon[option.stat].toFixed(2) : weapon[option.stat]} (was ${oldValue.toFixed ? oldValue.toFixed(2) : oldValue})`);
- } else {
- console.warn(`Tried to upgrade missing weapon or stat: Index ${option.index}, Stat ${option.stat}`);
- }
- } else if (option.type === 'player_stat') {
- let statChanged = false;
- let logValue;
- if (option.stat === 'maxHealth') {
- player.maxHealth += option.value;
- player.health += option.value;
- logValue = player.maxHealth;
- statChanged = true;
- } else if (option.stat === 'speed') {
- player.baseSpeed *= (1 + option.value / 100);
- logValue = player.baseSpeed;
- statChanged = true;
- } else if (option.stat === 'magnet') {
- player.baseMagnetRadius *= (1 + option.value / 100);
- logValue = player.baseMagnetRadius;
- statChanged = true;
- } else if (option.stat === 'regen') {
- player.healthRegen = (player.healthRegen || 0) + option.value;
- logValue = player.healthRegen;
- statChanged = true;
- console.log(`New player Health Regen: ${logValue.toFixed(2)}/s`);
- } else if (option.stat === 'maxStamina') {
- player.maxStamina *= (1 + option.value / 100);
- // Give current stamina proportional increase
- player.currentStamina *= (1 + option.value / 100);
- player.currentStamina = Math.min(player.currentStamina, player.maxStamina); // Clamp
- logValue = player.maxStamina;
- statChanged = true;
- } else if (option.stat === 'staminaRegen') {
- player.staminaRegenRate *= (1 + option.value / 100);
- logValue = player.staminaRegenRate;
- statChanged = true;
- }
- if (statChanged && option.stat !== 'regen') { // Regen logged separately
- console.log(`New player ${option.stat}: ${logValue !== undefined && logValue.toFixed ? logValue.toFixed(2) : logValue}`);
- }
- }
- updateUI();
- }
- function gameOver() {
- if (isGameOver) return;
- isGameOver = true;
- console.log("Game Over!");
- playSound('game_over');
- mouse.down = false;
- applyCameraShake(35, 0.6);
- setTimeout(() => {
- alert(`Game Over! You reached Wave ${currentWave} and Level ${player ? player.level : '-'}. Final Money: $${player ? player.money : '0'}`);
- if (animationFrameId) {
- cancelAnimationFrame(animationFrameId);
- animationFrameId = null;
- console.log("Animation frame cancelled in gameOver timeout.");
- }
- // Reset Game State Thoroughly
- player = null;
- enemies = [];
- projectiles = [];
- xpOrbs = [];
- moneyPickups = [];
- ammoPickups = [];
- powerUps = [];
- particles = [];
- turrets = []; // Clear turrets
- damageTexts = [];
- damageTextContainer.innerHTML = '';
- keys = {};
- mouse = {x: 0, y: 0, down: false};
- pendingLevelUps = 0;
- isShowingLevelUpOptions = false;
- isShopOpen = false; // Ensure shop is closed
- // Reset camera immediately
- camera = {x: 0, y: 0, shakeIntensity: 0, shakeDuration: 0};
- gameTime = 0; // Reset game time too
- updateUI(); // Final clear of UI
- gameContainerDiv.style.display = 'none';
- colorSelectionDiv.style.display = 'block';
- // Remove listeners added during init
- window.removeEventListener('keydown', handleKeyDown);
- window.removeEventListener('keyup', handleKeyUp);
- window.removeEventListener('mousedown', handleMouseDown);
- window.removeEventListener('mouseup', handleMouseUp);
- window.removeEventListener('mousemove', handleMouseMove);
- window.removeEventListener('resize', handleResize);
- levelUpIndicator.removeEventListener('click', toggleLevelUpOptions);
- shopButton.removeEventListener('click', toggleShop);
- shopCloseButton.removeEventListener('click', toggleShop);
- console.log("Game state reset. Ready for new game via setupColorSelection.");
- // Re-setup color selection for the next game
- setupColorSelection();
- }, 600);
- }
- function gameLoop(currentTime) {
- if (isGameOver && player === null) {
- console.log("Game loop stopping: isGameOver=true, player=null.");
- animationFrameId = null;
- return;
- }
- if (!animationFrameId && !isGameOver) {
- // This might happen if gameOver was triggered right before a frame request
- // or if init wasn't called properly. Check if we should restart loop.
- if (!isGameOver) {
- console.warn("gameLoop called without valid animationFrameId but not game over. Restarting loop.");
- animationFrameId = requestAnimationFrame(gameLoop);
- } else {
- console.warn("gameLoop called without valid animationFrameId during game over. Exiting.");
- }
- return;
- }
- const dt = Math.min(0.05, (currentTime - lastTime) / 1000);
- lastTime = currentTime;
- // Update Logic (Only if not fully game over)
- if (!isGameOver || player) {
- update(dt); // Update game state (handles internal pausing via isShopOpen)
- }
- // Apply Health Regen if player exists and has it (runs even if shop is open)
- if (player && player.healthRegen > 0 && player.health < player.maxHealth) {
- player.health = Math.min(player.maxHealth, player.health + player.healthRegen * dt);
- }
- // Render Logic (Always render)
- render();
- // UI Update (Always update)
- updateUI();
- // Request next frame
- if (animationFrameId) { // Check if still valid (might be cancelled by gameOver)
- animationFrameId = requestAnimationFrame(gameLoop);
- }
- }
- // --- Start Game ---
- window.onload = setupColorSelection;
- </script>
- </body>
- </html>
Add Comment
Please, Sign In to add comment