Guest User

fruit of 2.5 pro labor

a guest
Mar 31st, 2025
28
0
248 days
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 208.30 KB | None | 0 0
  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <title>Enhanced Endless Top-Down Shooter</title>
  5. <meta charset="utf-8"/>
  6. <script src="https://cdnjs.cloudflare.com/ajax/libs/noisejs/2.1.0/noisejs.min.js"></script>
  7. <style>
  8. body {
  9. margin: 0;
  10. overflow: hidden;
  11. background-color: #111;
  12. display: flex;
  13. flex-direction: column; /* Stack vertically */
  14. justify-content: center;
  15. align-items: center;
  16. height: 100vh;
  17. font-family: sans-serif;
  18. color: white;
  19. }
  20.  
  21. canvas {
  22. border: 1px solid white;
  23. background-color: #1a1a1a;
  24. }
  25.  
  26. #ui {
  27. position: absolute;
  28. top: 10px;
  29. left: 10px;
  30. background: rgba(0, 0, 0, 0.7); /* Slightly more opaque */
  31. padding: 10px;
  32. border-radius: 5px;
  33. font-size: 0.9em;
  34. z-index: 5; /* Ensure UI is above background/game */
  35. min-width: 200px; /* Ensure some minimum width */
  36. }
  37.  
  38. #ui div {
  39. margin-bottom: 5px; /* Increased spacing slightly */
  40. white-space: nowrap; /* Prevent wrapping */
  41. }
  42.  
  43. #stamina-bar-container {
  44. width: 100px;
  45. height: 8px;
  46. background-color: #555;
  47. border: 1px solid #888;
  48. border-radius: 2px;
  49. overflow: hidden;
  50. display: inline-block; /* Align with text */
  51. vertical-align: middle; /* Align with text */
  52. margin-left: 5px;
  53. }
  54.  
  55. #stamina-bar {
  56. width: 100%;
  57. height: 100%;
  58. background-color: #4CAF50; /* Green */
  59. transition: width 0.1s linear;
  60. }
  61.  
  62. #level-up-indicator {
  63. position: absolute;
  64. top: 10px;
  65. right: 10px; /* Position top-right */
  66. background: rgba(255, 215, 0, 0.8); /* Gold background */
  67. color: black;
  68. padding: 8px 15px;
  69. border-radius: 5px;
  70. font-weight: bold;
  71. font-size: 1.1em;
  72. display: none; /* Initially hidden */
  73. cursor: pointer; /* Indicate it's clickable */
  74. z-index: 15; /* Above other UI */
  75. border: 2px solid white;
  76. box-shadow: 0 0 10px gold;
  77. transition: transform 0.1s ease-out;
  78. }
  79.  
  80. /* Container for floating damage text */
  81. #damage-text-container {
  82. position: absolute;
  83. /* --- ADD THESE --- */
  84. top: 0;
  85. left: 0;
  86. /* --- END ADD --- */
  87. /* width/height are set by JS */
  88. pointer-events: none;
  89. overflow: hidden; /* Optional: prevent text spilling if calculations are slightly off */
  90. z-index: 20; /* Ensure it's above canvas, below UI popups if needed */
  91. }
  92.  
  93. #level-up-indicator:hover {
  94. transform: scale(1.05);
  95. }
  96.  
  97.  
  98. #level-up-options {
  99. position: absolute;
  100. top: 50px; /* Below the indicator */
  101. right: 10px;
  102. display: none; /* Initially hidden */
  103. flex-direction: column; /* Stack options vertically */
  104. gap: 10px;
  105. z-index: 10; /* Below indicator but above game */
  106. background: rgba(0, 0, 0, 0.85); /* Darker background */
  107. padding: 15px;
  108. border-radius: 8px;
  109. border: 1px solid #666;
  110. min-width: 250px; /* Ensure buttons fit */
  111. }
  112.  
  113. .level-up-button {
  114. padding: 12px 20px;
  115. background-color: #4CAF50;
  116. color: white;
  117. border: none;
  118. border-radius: 6px; /* Slightly less rounded */
  119. cursor: pointer;
  120. font-size: 0.95em; /* Slightly smaller font */
  121. transition: background-color 0.3s, transform 0.1s;
  122. text-align: left; /* Align text left */
  123. }
  124.  
  125. .level-up-button:hover {
  126. background-color: #45a049;
  127. transform: translateX(-3px); /* Slight move on hover */
  128. }
  129.  
  130. #color-selection {
  131. margin-bottom: 15px;
  132. display: block; /* Initially visible */
  133. text-align: center; /* Center the selection */
  134. }
  135.  
  136. #color-selection span {
  137. display: block;
  138. margin-bottom: 10px;
  139. }
  140.  
  141. #color-selection button {
  142. padding: 8px 15px;
  143. margin: 0 5px 5px 5px; /* Added bottom margin */
  144. cursor: pointer;
  145. border: 1px solid white;
  146. background: none;
  147. color: white;
  148. border-radius: 4px;
  149. min-width: 60px; /* Ensure color is visible */
  150. }
  151.  
  152. #color-selection button.selected {
  153. background-color: white !important; /* Ensure selected style overrides */
  154. color: black;
  155. }
  156.  
  157. #game-container {
  158. position: relative; /* Needed for absolute positioning of UI */
  159. display: none; /* Hidden until game starts */
  160. }
  161.  
  162. /* Style for power-up timer display */
  163. .power-up-timer {
  164. color: yellow;
  165. font-weight: bold;
  166. }
  167.  
  168. /* Style for Damage Text */
  169. .damage-text {
  170. position: absolute;
  171. font-weight: bold;
  172. /* font-size is set by JS */
  173. text-shadow: 1px 1px 2px black;
  174. pointer-events: none;
  175. white-space: nowrap;
  176. z-index: 20;
  177. /* Define transition properties */
  178. transition: opacity 0.5s linear, transform 0.5s ease-out;
  179. /* Initial state set mostly by JS now, but can define defaults */
  180. opacity: 0; /* Start invisible before JS sets it */
  181. transform: translateX(-50%); /* Default horizontal centering */
  182. }
  183.  
  184. .damage-text.burn {
  185. color: orange !important; /* Override default color */
  186. /* font-size adjusted dynamically like normal damage */
  187. }
  188.  
  189. /* Reloading indicator style */
  190. #reload-indicator {
  191. position: absolute;
  192. bottom: 10px; /* Position near bottom center */
  193. left: 50%;
  194. transform: translateX(-50%);
  195. background: rgba(255, 0, 0, 0.7);
  196. color: white;
  197. padding: 5px 10px;
  198. border-radius: 4px;
  199. font-weight: bold;
  200. display: none; /* Hidden initially */
  201. z-index: 6;
  202. }
  203.  
  204. /* Shop Button Style */
  205. #shop-button {
  206. position: absolute;
  207. bottom: 10px;
  208. left: 10px;
  209. padding: 10px 15px;
  210. background-color: #007bff;
  211. color: white;
  212. border: none;
  213. border-radius: 5px;
  214. cursor: pointer;
  215. font-size: 1em;
  216. font-weight: bold;
  217. z-index: 10;
  218. transition: background-color 0.3s;
  219. }
  220.  
  221. #shop-button:hover {
  222. background-color: #0056b3;
  223. }
  224.  
  225. /* Shop Screen Style */
  226. #shop-screen {
  227. position: absolute;
  228. top: 50%;
  229. left: 50%;
  230. transform: translate(-50%, -50%);
  231. width: 80%;
  232. max-width: 500px;
  233. height: 70%;
  234. max-height: 450px;
  235. background: rgba(30, 30, 30, 0.95);
  236. border: 2px solid #888;
  237. border-radius: 10px;
  238. padding: 20px;
  239. display: none; /* Hidden by default */
  240. flex-direction: column;
  241. z-index: 50; /* Above everything else */
  242. overflow-y: auto; /* Allow scrolling */
  243. color: white;
  244. box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
  245. }
  246.  
  247. #shop-screen h2 {
  248. text-align: center;
  249. margin-top: 0;
  250. margin-bottom: 20px;
  251. color: #FFD700; /* Gold color for title */
  252. }
  253.  
  254. #shop-screen .shop-item {
  255. display: flex;
  256. justify-content: space-between;
  257. align-items: center;
  258. padding: 10px;
  259. margin-bottom: 10px;
  260. background: rgba(50, 50, 50, 0.8);
  261. border-radius: 5px;
  262. }
  263.  
  264. #shop-screen .shop-item-info {
  265. flex-grow: 1;
  266. margin-right: 15px;
  267. }
  268.  
  269. #shop-screen .shop-item-name {
  270. font-weight: bold;
  271. font-size: 1.1em;
  272. }
  273.  
  274. #shop-screen .shop-item-desc {
  275. font-size: 0.9em;
  276. color: #ccc;
  277. }
  278.  
  279. #shop-screen .shop-buy-button {
  280. padding: 8px 12px;
  281. background-color: #28a745;
  282. color: white;
  283. border: none;
  284. border-radius: 4px;
  285. cursor: pointer;
  286. font-weight: bold;
  287. white-space: nowrap;
  288. transition: background-color 0.2s;
  289. }
  290.  
  291. #shop-screen .shop-buy-button:hover:not(:disabled) {
  292. background-color: #218838;
  293. }
  294.  
  295. #shop-screen .shop-buy-button:disabled {
  296. background-color: #6c757d; /* Grey out disabled */
  297. cursor: not-allowed;
  298. }
  299.  
  300. #shop-close-button {
  301. position: absolute;
  302. top: 10px;
  303. right: 10px;
  304. background: #dc3545;
  305. color: white;
  306. border: none;
  307. border-radius: 50%;
  308. width: 25px;
  309. height: 25px;
  310. font-weight: bold;
  311. cursor: pointer;
  312. line-height: 25px; /* Center 'X' vertically */
  313. text-align: center;
  314. }
  315.  
  316. #shop-close-button:hover {
  317. background: #c82333;
  318. }
  319.  
  320. </style>
  321. </head>
  322. <body>
  323.  
  324. <div id="color-selection">
  325. <span>Choose Player Color:</span>
  326. <button data-color="blue" class="selected">Blue</button>
  327. <button data-color="cyan">Cyan</button>
  328. <button data-color="#ff8800">Orange</button>
  329. <button data-color="magenta">Magenta</button>
  330. <button data-color="#33ff33">Green</button> <!-- Added green -->
  331. </div>
  332.  
  333. <div id="game-container">
  334. <div id="ui">
  335. <div>Level: <span id="level">1</span></div>
  336. <div>XP: <span id="xp">0</span> / <span id="xp-needed">100</span></div>
  337. <div>Health: <span id="health">100</span> / <span id="max-health">100</span></div>
  338. <div>Stamina:
  339. <div id="stamina-bar-container">
  340. <div id="stamina-bar"></div>
  341. </div>
  342. </div>
  343. <div>Money: $<span id="money">0</span></div> <!-- Money Display -->
  344. <div>Wave: <span id="wave">1</span></div>
  345. <div>Enemies Left: <span id="enemies-left">0</span></div>
  346. <div>Weapon: <span id="current-weapon">Pistol</span></div>
  347. <div>Ammo: <span id="ammo">--</span></div>
  348. <div id="power-up-status"></div>
  349. </div>
  350.  
  351. <div id="level-up-indicator">LEVEL UP! (<span id="pending-levelups">0</span>)</div>
  352.  
  353. <div id="level-up-options"></div>
  354.  
  355. <div id="reload-indicator">RELOADING...</div>
  356.  
  357. <button id="shop-button">Shop (B)</button> <!-- Shop Button -->
  358.  
  359. <canvas id="gameCanvas"></canvas>
  360.  
  361. <!-- Container for floating damage text -->
  362. <div id="damage-text-container"></div>
  363.  
  364. <!-- Shop Screen -->
  365. <div id="shop-screen">
  366. <button id="shop-close-button">X</button>
  367. <h2>Ye Olde Upgrade Shoppe</h2>
  368. <div id="shop-items-container">
  369. <!-- Shop items will be populated by script -->
  370. </div>
  371. </div>
  372. </div>
  373.  
  374. <script>
  375. // --- DOM Elements ---
  376. const canvas = document.getElementById('gameCanvas');
  377. const ctx = canvas.getContext('2d');
  378. const uiLevel = document.getElementById('level');
  379. const uiXp = document.getElementById('xp');
  380. const uiXpNeeded = document.getElementById('xp-needed');
  381. const uiHealth = document.getElementById('health');
  382. const uiMaxHealth = document.getElementById('max-health');
  383. const uiStaminaBar = document.getElementById('stamina-bar'); // Stamina UI
  384. const uiMoney = document.getElementById('money'); // Money UI
  385. const uiWave = document.getElementById('wave');
  386. const uiEnemiesLeft = document.getElementById('enemies-left');
  387. const uiCurrentWeapon = document.getElementById('current-weapon');
  388. const uiAmmo = document.getElementById('ammo');
  389. const uiPowerUpStatus = document.getElementById('power-up-status');
  390. const levelUpOptionsDiv = document.getElementById('level-up-options');
  391. const levelUpIndicator = document.getElementById('level-up-indicator');
  392. const pendingLevelUpsSpan = document.getElementById('pending-levelups');
  393. const reloadIndicatorDiv = document.getElementById('reload-indicator');
  394. const colorSelectionDiv = document.getElementById('color-selection');
  395. const gameContainerDiv = document.getElementById('game-container');
  396. const damageTextContainer = document.getElementById('damage-text-container');
  397. const shopButton = document.getElementById('shop-button');
  398. const shopScreen = document.getElementById('shop-screen');
  399. const shopCloseButton = document.getElementById('shop-close-button');
  400. const shopItemsContainer = document.getElementById('shop-items-container');
  401.  
  402. // --- Game Settings ---
  403. let PLAYER_COLOR = 'blue'; // Default, will be set by selection
  404. const PLAYER_SIZE = 15;
  405. const PLAYER_BASE_SPEED = 250; // Pixels per second
  406. const PLAYER_BASE_MAX_STAMINA = 100;
  407. const PLAYER_BASE_STAMINA_REGEN = 25; // Stamina points per second
  408. const PLAYER_STAMINA_CONSUMPTION = 40; // Stamina points per second while sprinting
  409. const PLAYER_SPRINT_MULTIPLIER = 1.6; // Speed multiplier when sprinting
  410. const ENEMY_START_SIZE = 20;
  411. const ENEMY_START_HEALTH = 50;
  412. const ENEMY_START_SPEED = 125; // Increased base speed
  413. const ENEMY_BASE_DAMAGE = 10;
  414. const PROJECTILE_BASE_SPEED = 500;
  415. const PROJECTILE_BASE_SIZE = 5;
  416. const PROJECTILE_COLOR = 'yellow';
  417. const XP_ORB_SIZE = 5;
  418. const XP_ORB_COLOR = 'lime';
  419. const MONEY_PICKUP_SIZE_MIN = 4;
  420. const MONEY_PICKUP_SIZE_MAX = 8;
  421. const MONEY_PICKUP_COLOR = '#FFD700'; // Gold
  422. let MONEY_PICKUP_MAGNET_RADIUS = 90; // Similar to XP orbs
  423. const MONEY_PICKUP_MAGNET_SPEED = 450;
  424. const MONEY_PICKUP_FRICTION = 0.9;
  425. const MONEY_PICKUP_LIFE = 30; // Seconds before disappearing
  426. const BASE_MONEY_DROP_MIN = 1;
  427. const BASE_MONEY_DROP_MAX = 250;
  428. const MONEY_DROP_CHANCE = 0.95; // High chance to drop at least *some* money
  429. const AMMO_PICKUP_SIZE = 6;
  430. const AMMO_PICKUP_COLOR = 'lightblue';
  431. const POWERUP_SIZE = 10;
  432. const POWERUP_SPAWN_CHANCE = 0.09; // Slightly increased chance for powerups
  433. const XP_PER_KILL = 25;
  434. const BASE_XP_TO_LEVEL = 100;
  435. const XP_LEVEL_MULTIPLIER = 1.05;
  436. const HEALTH_BAR_HEIGHT = 5;
  437. const HEALTH_BAR_WIDTH_FACTOR = 1.5;
  438. const PARTICLE_LIFETIME = 0.5; // seconds
  439. const MAX_PARTICLES = 5000; // Increased max particles
  440. const GRID_SIZE = 50;
  441. const BACKGROUND_NOISE_SCALE = 250;
  442. const CAMERA_SHAKE_DECAY = 0.85;
  443. const AMMO_DROP_CHANCE = 0.55;
  444. const DAMAGE_TEXT_DURATION = 0.7; // seconds
  445. const DAMAGE_TEXT_SPEED = 50; // pixels per second upward drift
  446. const RELOAD_KEY = 'r';
  447. const SPRINT_KEY = 'shift'; // Sprint key
  448. const SHOP_KEY = 'b'; // Shop toggle key
  449. const PLAYER_HIT_SOUND_COOLDOWN = 0.01;
  450. const OFFSCREEN_INDICATOR_SIZE = 10;
  451. const OFFSCREEN_INDICATOR_MARGIN = 20; // Distance from edge
  452. const OFFSCREEN_INDICATOR_MAX_DIST_OPACITY = 1500; // Distance where indicator is fully opaque
  453. const OFFSCREEN_INDICATOR_MIN_DIST_OPACITY = 400; // Distance where indicator starts fading in
  454. const SHIELD_ORB_COUNT = 3;
  455. const SHIELD_ORB_RADIUS = 50;
  456. const SHIELD_ORB_ROTATION_SPEED = 2.5; // Radians per second
  457. const SHIELD_ORB_DAMAGE = 30; // Damage per hit
  458. const SHIELD_ORB_HIT_COOLDOWN = 0.3; // Seconds between hits per enemy per orb
  459. const SHIELD_ORB_PARTICLE_RATE = 2; // Particles per orb per second
  460. const TURRET_COST = 500;
  461. const TURRET_SIZE = 12;
  462. const TURRET_HEALTH = 150;
  463. const TURRET_FIRE_RATE = 2;
  464. const TURRET_DAMAGE = 15;
  465. const TURRET_RANGE = 350;
  466. const TURRET_PROJECTILE_SPEED = 400;
  467. const TURRET_FOLLOW_DISTANCE = 50;
  468. const TURRET_FOLLOW_LAG = 0.05;
  469.  
  470. const FLAMETHROWER_PARTICLE_SPEED = 280;
  471. const FLAMETHROWER_PARTICLE_LIFE = 0.3;
  472. const FLAMETHROWER_PARTICLE_SIZE = 8;
  473. const FLAMETHROWER_RANGE = 180; // Effective range for collision check
  474. const FLAMETHROWER_CONE_ANGLE = 0.4; // Radians (approx 23 degrees)
  475. const FLAMETHROWER_BURN_DAMAGE_PER_TICK = 5;
  476. const FLAMETHROWER_BURN_DURATION = 3.0; // Seconds burn lasts after being hit
  477. const ENEMY_BURN_TICK_RATE = 0.5; // Seconds between burn damage ticks
  478.  
  479. const BAZOOKA_PROJECTILE_SPEED = 250;
  480. const BAZOOKA_PROJECTILE_SIZE = 12;
  481. const BAZOOKA_EXPLOSION_RADIUS = 100; // Pixel radius of explosion
  482. const BAZOOKA_MAX_DAMAGE = 120; // Damage at center of explosion
  483.  
  484. // --- Game State ---
  485. let player;
  486. let enemies = [];
  487. let projectiles = [];
  488. let xpOrbs = [];
  489. let moneyPickups = []; // Money pickups array
  490. let ammoPickups = [];
  491. let powerUps = [];
  492. let particles = [];
  493. let turrets = []; // Array for turrets
  494. let damageTexts = []; // For floating damage text elements
  495. let keys = {};
  496. let mouse = {x: 0, y: 0, down: false};
  497. let camera = {x: 0, y: 0, shakeIntensity: 0, shakeDuration: 0};
  498. let currentWave = 1;
  499. let enemiesToSpawn = 0;
  500. let enemiesAlive = 0;
  501. let spawningDone = false;
  502. let lastTime = 0;
  503. let timeToNextSpawn = 0;
  504. let gameTime = 0;
  505. let isGameOver = false;
  506. let noise; // For procedural background
  507. let audioCtx; // For Web Audio API
  508. let masterGain; // Master volume control
  509. let lastPlayerHitSoundTime = 0;
  510. let pendingLevelUps = 0; // Queue for level ups
  511. let isShowingLevelUpOptions = false; // To prevent overlap issues
  512. let isShopOpen = false; // Track shop state
  513. let animationFrameId = null; // Store animation frame request ID
  514.  
  515. let lightEffects = [];
  516.  
  517. // --- Audio Context & Sound Generation ---
  518. function initAudio() {
  519. try {
  520. audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  521. masterGain = audioCtx.createGain();
  522. masterGain.gain.value = 0.5; // Set master volume (0 to 1)
  523. masterGain.connect(audioCtx.destination);
  524. console.log("AudioContext initialized.");
  525. } catch (e) {
  526. console.error("Web Audio API is not supported in this browser", e);
  527. }
  528. }
  529.  
  530. // More sophisticated sound function
  531. function playSound(type, options = {}) {
  532. if (!audioCtx || !masterGain) return;
  533.  
  534. const time = audioCtx.currentTime;
  535. const volume = options.volume !== undefined ? options.volume : 0.3; // Default volume
  536.  
  537. switch (type) {
  538. // --- Existing Sounds (keep all of them) ---
  539. case 'shoot_pistol': {
  540. const osc = audioCtx.createOscillator();
  541. const gain = audioCtx.createGain();
  542. osc.connect(gain).connect(masterGain);
  543. osc.type = 'sawtooth';
  544. osc.frequency.setValueAtTime(900, time);
  545. osc.frequency.exponentialRampToValueAtTime(400, time + 0.08);
  546. gain.gain.setValueAtTime(volume * 0.8, time);
  547. gain.gain.linearRampToValueAtTime(0, time + 0.1);
  548. osc.start(time);
  549. osc.stop(time + 0.1);
  550. break;
  551. }
  552. case 'shoot_smg': { // Faster, brighter sound
  553. const osc = audioCtx.createOscillator();
  554. const gain = audioCtx.createGain();
  555. osc.connect(gain).connect(masterGain);
  556. osc.type = 'square';
  557. osc.frequency.setValueAtTime(1200, time);
  558. osc.frequency.exponentialRampToValueAtTime(800, time + 0.05);
  559. gain.gain.setValueAtTime(volume * 0.6, time);
  560. gain.gain.linearRampToValueAtTime(0, time + 0.07);
  561. osc.start(time);
  562. osc.stop(time + 0.07);
  563. break;
  564. }
  565. case 'shoot_shotgun': { // Low "thump" with noise burst
  566. // Thump
  567. const osc = audioCtx.createOscillator();
  568. const oscGain = audioCtx.createGain();
  569. osc.connect(oscGain).connect(masterGain);
  570. osc.type = 'sine';
  571. osc.frequency.setValueAtTime(150, time);
  572. osc.frequency.exponentialRampToValueAtTime(50, time + 0.15);
  573. oscGain.gain.setValueAtTime(volume * 1.2, time);
  574. oscGain.gain.linearRampToValueAtTime(0, time + 0.2);
  575. osc.start(time);
  576. osc.stop(time + 0.2);
  577. // Noise burst
  578. const bufferSize = audioCtx.sampleRate * 0.15; // 0.15 sec noise
  579. const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  580. const data = buffer.getChannelData(0);
  581. for (let i = 0; i < bufferSize; i++) {
  582. data[i] = Math.random() * 2 - 1;
  583. }
  584. const noise = audioCtx.createBufferSource();
  585. const noiseGain = audioCtx.createGain();
  586. const filter = audioCtx.createBiquadFilter();
  587. noise.buffer = buffer;
  588. noise.connect(filter).connect(noiseGain).connect(masterGain);
  589. filter.type = 'bandpass';
  590. filter.frequency.value = 1500;
  591. filter.Q.value = 0.5;
  592. noiseGain.gain.setValueAtTime(volume * 0.5, time);
  593. noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
  594. noise.start(time);
  595. noise.stop(time + 0.15);
  596. break;
  597. }
  598. case 'shoot_minigun': { // Very fast, slightly metallic sound
  599. const osc = audioCtx.createOscillator();
  600. const gain = audioCtx.createGain();
  601. const filter = audioCtx.createBiquadFilter();
  602. osc.connect(gain).connect(filter).connect(masterGain);
  603. osc.type = 'sawtooth';
  604. filter.type = 'highpass';
  605. filter.frequency.value = 1500;
  606. osc.frequency.setValueAtTime(1400 + Math.random() * 100, time);
  607. osc.frequency.linearRampToValueAtTime(1200, time + 0.04);
  608. gain.gain.setValueAtTime(volume * 0.4, time);
  609. gain.gain.linearRampToValueAtTime(0, time + 0.06);
  610. osc.start(time);
  611. osc.stop(time + 0.06);
  612. break;
  613. }
  614. case 'shoot_flamethrower': { // Short burst of rushing noise
  615. const bufferSize = audioCtx.sampleRate * 0.15; // Longer duration for effect
  616. const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  617. const data = buffer.getChannelData(0);
  618. for (let i = 0; i < bufferSize; i++) {
  619. data[i] = (Math.random() * 2 - 1);
  620. }
  621. const noise = audioCtx.createBufferSource();
  622. const noiseGain = audioCtx.createGain();
  623. const filter = audioCtx.createBiquadFilter();
  624. noise.buffer = buffer;
  625. noise.connect(filter).connect(noiseGain).connect(masterGain);
  626. filter.type = 'bandpass';
  627. filter.frequency.setValueAtTime(1500, time);
  628. filter.frequency.linearRampToValueAtTime(2500, time + 0.1);
  629. filter.Q.value = 1;
  630. noiseGain.gain.setValueAtTime(volume * 0.5, time);
  631. noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
  632. noise.start(time);
  633. noise.stop(time + 0.15);
  634. break;
  635. }
  636. case 'hit_fire': { // Sizzling sound for fire damage tick or hit
  637. const bufferSize = audioCtx.sampleRate * 0.08;
  638. const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  639. const data = buffer.getChannelData(0);
  640. for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1);
  641. const noise = audioCtx.createBufferSource();
  642. const noiseGain = audioCtx.createGain();
  643. const filter = audioCtx.createBiquadFilter();
  644. noise.buffer = buffer;
  645. noise.connect(filter).connect(noiseGain).connect(masterGain);
  646. filter.type = 'highpass';
  647. filter.frequency.value = 3000 + Math.random() * 1000;
  648. filter.Q.value = 1;
  649. noiseGain.gain.setValueAtTime(volume * 0.3, time); // Quieter than normal hit
  650. noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.08);
  651. noise.start(time);
  652. noise.stop(time + 0.08);
  653. break;
  654. }
  655. case 'shoot_bazooka': { // Low "Thoomp" and a high whistle/whoosh
  656. // Thoomp
  657. const osc = audioCtx.createOscillator();
  658. const oscGain = audioCtx.createGain();
  659. osc.connect(oscGain).connect(masterGain);
  660. osc.type = 'sine';
  661. osc.frequency.setValueAtTime(100, time);
  662. osc.frequency.exponentialRampToValueAtTime(40, time + 0.3);
  663. oscGain.gain.setValueAtTime(volume * 1.0, time);
  664. oscGain.gain.linearRampToValueAtTime(0, time + 0.3);
  665. osc.start(time);
  666. osc.stop(time + 0.3);
  667. // Whoosh (Noise)
  668. const bufferSize = audioCtx.sampleRate * 0.25;
  669. const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  670. const data = buffer.getChannelData(0);
  671. for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1);
  672. const noise = audioCtx.createBufferSource();
  673. const noiseGain = audioCtx.createGain();
  674. const filter = audioCtx.createBiquadFilter();
  675. noise.buffer = buffer;
  676. noise.connect(filter).connect(noiseGain).connect(masterGain);
  677. filter.type = 'bandpass';
  678. filter.frequency.setValueAtTime(800, time + 0.05); // Delayed start
  679. filter.frequency.linearRampToValueAtTime(3000, time + 0.05 + 0.2);
  680. filter.Q.value = 0.8;
  681. noiseGain.gain.setValueAtTime(volume * 0.6, time + 0.05);
  682. noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.05 + 0.25);
  683. noise.start(time + 0.05);
  684. noise.stop(time + 0.05 + 0.25);
  685. break;
  686. }
  687. case 'explosion': { // Deep boom with crackling noise
  688. // Boom
  689. const osc = audioCtx.createOscillator();
  690. const oscGain = audioCtx.createGain();
  691. const filter = audioCtx.createBiquadFilter();
  692. osc.connect(oscGain).connect(filter).connect(masterGain);
  693. osc.type = 'sawtooth';
  694. filter.type = 'lowpass';
  695. filter.frequency.value = 600;
  696. osc.frequency.setValueAtTime(80, time);
  697. osc.frequency.exponentialRampToValueAtTime(30, time + 0.8);
  698. oscGain.gain.setValueAtTime(volume * 1.5, time); // Loud
  699. oscGain.gain.exponentialRampToValueAtTime(0.001, time + 0.8);
  700. osc.start(time);
  701. osc.stop(time + 0.8);
  702. // Crackle (Noise)
  703. const bufferSize = audioCtx.sampleRate * 0.6;
  704. const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  705. const data = buffer.getChannelData(0);
  706. for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1);
  707. const noise = audioCtx.createBufferSource();
  708. const noiseGain = audioCtx.createGain();
  709. const noiseFilter = audioCtx.createBiquadFilter();
  710. noise.buffer = buffer;
  711. noise.connect(noiseFilter).connect(noiseGain).connect(masterGain);
  712. noiseFilter.type = 'highpass';
  713. noiseFilter.frequency.value = 1000;
  714. noiseGain.gain.setValueAtTime(volume * 0.5, time + 0.05);
  715. noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.6);
  716. noise.start(time + 0.05);
  717. noise.stop(time + 0.6);
  718. break;
  719. }
  720. case 'shoot_turret': { // Lighter, faster pew sound
  721. const osc = audioCtx.createOscillator();
  722. const gain = audioCtx.createGain();
  723. osc.connect(gain).connect(masterGain);
  724. osc.type = 'triangle';
  725. osc.frequency.setValueAtTime(1100, time);
  726. osc.frequency.exponentialRampToValueAtTime(700, time + 0.06);
  727. gain.gain.setValueAtTime(volume * 0.5, time); // Quieter than player weapons
  728. gain.gain.linearRampToValueAtTime(0, time + 0.08);
  729. osc.start(time);
  730. osc.stop(time + 0.08);
  731. break;
  732. }
  733. case 'melee_swing': { // Quick "whoosh"
  734. const bufferSize = audioCtx.sampleRate * 0.1;
  735. const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  736. const data = buffer.getChannelData(0);
  737. for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
  738. const noise = audioCtx.createBufferSource();
  739. const noiseGain = audioCtx.createGain();
  740. const filter = audioCtx.createBiquadFilter();
  741. noise.buffer = buffer;
  742. noise.connect(filter).connect(noiseGain).connect(masterGain);
  743. filter.type = 'highpass';
  744. filter.frequency.setValueAtTime(1000, time);
  745. filter.frequency.linearRampToValueAtTime(8000, time + 0.1);
  746. noiseGain.gain.setValueAtTime(volume * 0.4, time);
  747. noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.1);
  748. noise.start(time);
  749. noise.stop(time + 0.1);
  750. break;
  751. }
  752. case 'melee_hit': { // Short, sharp impact
  753. const osc = audioCtx.createOscillator();
  754. const gain = audioCtx.createGain();
  755. osc.connect(gain).connect(masterGain);
  756. osc.type = 'square';
  757. osc.frequency.setValueAtTime(400, time);
  758. gain.gain.setValueAtTime(volume * 0.9, time);
  759. gain.gain.exponentialRampToValueAtTime(0.001, time + 0.08);
  760. osc.start(time);
  761. osc.stop(time + 0.08);
  762. // Add noise click
  763. const noise = audioCtx.createBufferSource();
  764. const noiseGain = audioCtx.createGain();
  765. const buffer = audioCtx.createBuffer(1, 44100 * 0.03, audioCtx.sampleRate);
  766. const data = buffer.getChannelData(0);
  767. for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
  768. noise.buffer = buffer;
  769. noise.connect(noiseGain).connect(masterGain);
  770. noiseGain.gain.setValueAtTime(volume * 0.3, time);
  771. noiseGain.gain.linearRampToValueAtTime(0, time + 0.03);
  772. noise.start(time);
  773. noise.stop(time + 0.03);
  774. break;
  775. }
  776. case 'hit_enemy': { // Short click/thump
  777. const osc = audioCtx.createOscillator();
  778. const gain = audioCtx.createGain();
  779. osc.connect(gain).connect(masterGain);
  780. osc.type = 'triangle';
  781. osc.frequency.setValueAtTime(300 + Math.random() * 100, time); // Vary pitch slightly
  782. gain.gain.setValueAtTime(volume * 0.6, time);
  783. gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
  784. osc.start(time);
  785. osc.stop(time + 0.05);
  786. break;
  787. }
  788. case 'shield_hit_enemy': { // Slightly different hit sound for shield orbs
  789. const osc = audioCtx.createOscillator();
  790. const gain = audioCtx.createGain();
  791. osc.connect(gain).connect(masterGain);
  792. osc.type = 'sine'; // Softer sine wave
  793. osc.frequency.setValueAtTime(500 + Math.random() * 150, time);
  794. gain.gain.setValueAtTime(volume * 0.5, time);
  795. gain.gain.exponentialRampToValueAtTime(0.001, time + 0.07);
  796. osc.start(time);
  797. osc.stop(time + 0.07);
  798. break;
  799. }
  800. case 'enemy_death': { // Low crunch/squish
  801. const osc = audioCtx.createOscillator();
  802. const oscGain = audioCtx.createGain();
  803. osc.connect(oscGain).connect(masterGain);
  804. osc.type = 'sawtooth';
  805. osc.frequency.setValueAtTime(100, time);
  806. osc.frequency.exponentialRampToValueAtTime(30, time + 0.3);
  807. oscGain.gain.setValueAtTime(volume * 0.7, time);
  808. oscGain.gain.linearRampToValueAtTime(0, time + 0.3);
  809. osc.start(time);
  810. osc.stop(time + 0.3);
  811. // Noise
  812. const buffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.2, audioCtx.sampleRate);
  813. const data = buffer.getChannelData(0);
  814. for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
  815. const noise = audioCtx.createBufferSource();
  816. const noiseGain = audioCtx.createGain();
  817. const filter = audioCtx.createBiquadFilter();
  818. noise.buffer = buffer;
  819. noise.connect(filter).connect(noiseGain).connect(masterGain);
  820. filter.type = 'lowpass';
  821. filter.frequency.value = 800;
  822. noiseGain.gain.setValueAtTime(volume * 0.4, time);
  823. noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.2);
  824. noise.start(time);
  825. noise.stop(time + 0.2);
  826. break;
  827. }
  828. case 'hit_player': { // Lower pitch, slightly longer hurt sound
  829. const osc = audioCtx.createOscillator();
  830. const gain = audioCtx.createGain();
  831. osc.connect(gain).connect(masterGain);
  832. osc.type = 'square';
  833. osc.frequency.setValueAtTime(150, time);
  834. gain.gain.setValueAtTime(volume * 0.2, time);
  835. gain.gain.linearRampToValueAtTime(0, time + 0.15);
  836. osc.start(time);
  837. osc.stop(time + 0.15);
  838. break;
  839. }
  840. case 'hit_turret': { // Metallic ping/clank for turret hit
  841. const osc = audioCtx.createOscillator();
  842. const gain = audioCtx.createGain();
  843. const filter = audioCtx.createBiquadFilter();
  844. osc.connect(gain).connect(filter).connect(masterGain);
  845. osc.type = 'square';
  846. osc.frequency.setValueAtTime(600, time);
  847. osc.frequency.exponentialRampToValueAtTime(300, time + 0.1);
  848. gain.gain.setValueAtTime(volume * 0.6, time);
  849. gain.gain.linearRampToValueAtTime(0, time + 0.1);
  850. filter.type = 'highpass'; // Make it sound metallic
  851. filter.frequency.value = 1500;
  852. filter.Q.value = 2;
  853. osc.start(time);
  854. osc.stop(time + 0.1);
  855. break;
  856. }
  857. case 'turret_death': { // Small explosion/break sound
  858. // Low thump
  859. const osc = audioCtx.createOscillator();
  860. const oscGain = audioCtx.createGain();
  861. osc.connect(oscGain).connect(masterGain);
  862. osc.type = 'triangle';
  863. osc.frequency.setValueAtTime(120, time);
  864. osc.frequency.exponentialRampToValueAtTime(40, time + 0.3);
  865. oscGain.gain.setValueAtTime(volume * 0.8, time);
  866. oscGain.gain.linearRampToValueAtTime(0, time + 0.3);
  867. osc.start(time);
  868. osc.stop(time + 0.3);
  869. // Noise burst
  870. const buffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.25, audioCtx.sampleRate);
  871. const data = buffer.getChannelData(0);
  872. for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
  873. const noise = audioCtx.createBufferSource();
  874. const noiseGain = audioCtx.createGain();
  875. const filter = audioCtx.createBiquadFilter();
  876. noise.buffer = buffer;
  877. noise.connect(filter).connect(noiseGain).connect(masterGain);
  878. filter.type = 'bandpass';
  879. filter.frequency.value = 1000;
  880. noiseGain.gain.setValueAtTime(volume * 0.6, time);
  881. noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.25);
  882. noise.start(time);
  883. noise.stop(time + 0.25);
  884. break;
  885. }
  886. case 'level_up': { // Ascending chime sequence
  887. const freqs = [440, 554, 659, 880]; // A4, C#5, E5, A5
  888. const attack = 0.01;
  889. const decay = 0.15;
  890. const step = 0.08;
  891. freqs.forEach((freq, i) => {
  892. const osc = audioCtx.createOscillator();
  893. const gain = audioCtx.createGain();
  894. osc.connect(gain).connect(masterGain);
  895. osc.type = 'triangle';
  896. osc.frequency.setValueAtTime(freq, time + i * step);
  897. gain.gain.setValueAtTime(0, time + i * step);
  898. gain.gain.linearRampToValueAtTime(volume * 0.8, time + i * step + attack);
  899. gain.gain.exponentialRampToValueAtTime(0.001, time + i * step + attack + decay);
  900. osc.start(time + i * step);
  901. osc.stop(time + i * step + attack + decay);
  902. });
  903. break;
  904. }
  905. case 'upgrade_select': { // Confirming "blip"
  906. const osc = audioCtx.createOscillator();
  907. const gain = audioCtx.createGain();
  908. osc.connect(gain).connect(masterGain);
  909. osc.type = 'sine';
  910. osc.frequency.setValueAtTime(880, time); // A5
  911. gain.gain.setValueAtTime(volume * 0.6, time);
  912. gain.gain.exponentialRampToValueAtTime(0.001, time + 0.1);
  913. osc.start(time);
  914. osc.stop(time + 0.1);
  915. break;
  916. }
  917. case 'buy_item': { // "Cha-ching" or positive confirmation sound
  918. const freq1 = 1046.50; // C6
  919. const freq2 = 1318.51; // E6
  920. const osc1 = audioCtx.createOscillator();
  921. const gain1 = audioCtx.createGain();
  922. osc1.connect(gain1).connect(masterGain);
  923. osc1.type = 'sine';
  924. osc1.frequency.value = freq1;
  925. gain1.gain.setValueAtTime(volume * 0.7, time);
  926. gain1.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
  927. osc1.start(time);
  928. osc1.stop(time + 0.15);
  929.  
  930. const osc2 = audioCtx.createOscillator();
  931. const gain2 = audioCtx.createGain();
  932. osc2.connect(gain2).connect(masterGain);
  933. osc2.type = 'sine';
  934. osc2.frequency.value = freq2;
  935. gain2.gain.setValueAtTime(volume * 0.7, time + 0.08); // Slightly delayed
  936. gain2.gain.exponentialRampToValueAtTime(0.001, time + 0.08 + 0.15);
  937. osc2.start(time + 0.08);
  938. osc2.stop(time + 0.08 + 0.15);
  939. break;
  940. }
  941. case 'cant_afford': { // Negative/buzz sound
  942. const osc = audioCtx.createOscillator();
  943. const gain = audioCtx.createGain();
  944. osc.connect(gain).connect(masterGain);
  945. osc.type = 'sawtooth';
  946. osc.frequency.setValueAtTime(100, time);
  947. gain.gain.setValueAtTime(volume * 0.5, time);
  948. gain.gain.exponentialRampToValueAtTime(0.001, time + 0.2);
  949. osc.start(time);
  950. osc.stop(time + 0.2);
  951. break;
  952. }
  953. case 'pickup_xp': { // Quick, high pitched "ping"
  954. const osc = audioCtx.createOscillator();
  955. const gain = audioCtx.createGain();
  956. osc.connect(gain).connect(masterGain);
  957. osc.type = 'sine';
  958. osc.frequency.setValueAtTime(1200 + Math.random() * 200, time); // Slightly varied pitch
  959. gain.gain.setValueAtTime(volume * 0.3, time);
  960. gain.gain.exponentialRampToValueAtTime(0.001, time + 0.08);
  961. osc.start(time);
  962. osc.stop(time + 0.08);
  963. break;
  964. }
  965. case 'pickup_money': { // Coin clink/jingle sound
  966. const freq1 = 1567.98; // G6
  967. const freq2 = 1975.53; // B6
  968. const osc1 = audioCtx.createOscillator();
  969. const gain1 = audioCtx.createGain();
  970. osc1.connect(gain1).connect(masterGain);
  971. osc1.type = 'square'; // Brighter sound
  972. osc1.frequency.value = freq1 * (1 + Math.random() * 0.05 - 0.025);
  973. gain1.gain.setValueAtTime(volume * 0.4, time);
  974. gain1.gain.exponentialRampToValueAtTime(0.001, time + 0.07);
  975. osc1.start(time);
  976. osc1.stop(time + 0.07);
  977.  
  978. const osc2 = audioCtx.createOscillator();
  979. const gain2 = audioCtx.createGain();
  980. osc2.connect(gain2).connect(masterGain);
  981. osc2.type = 'square';
  982. osc2.frequency.value = freq2 * (1 + Math.random() * 0.05 - 0.025);
  983. gain2.gain.setValueAtTime(volume * 0.4, time + 0.04); // Slightly delayed
  984. gain2.gain.exponentialRampToValueAtTime(0.001, time + 0.04 + 0.07);
  985. osc2.start(time + 0.04);
  986. osc2.stop(time + 0.04 + 0.07);
  987. break;
  988. }
  989. case 'pickup_ammo': { // Mechanical "click"
  990. const bufferSize = audioCtx.sampleRate * 0.05;
  991. const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  992. const data = buffer.getChannelData(0);
  993. for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
  994. const noise = audioCtx.createBufferSource();
  995. const noiseGain = audioCtx.createGain();
  996. const filter = audioCtx.createBiquadFilter();
  997. noise.buffer = buffer;
  998. noise.connect(filter).connect(noiseGain).connect(masterGain);
  999. filter.type = 'bandpass';
  1000. filter.frequency.value = 4000;
  1001. filter.Q.value = 1;
  1002. noiseGain.gain.setValueAtTime(volume * 0.7, time);
  1003. noiseGain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
  1004. noise.start(time);
  1005. noise.stop(time + 0.05);
  1006. break;
  1007. }
  1008. case 'pickup_powerup': { // Magical shimmer
  1009. const baseFreq = 600;
  1010. for (let i = 0; i < 3; ++i) {
  1011. const osc = audioCtx.createOscillator();
  1012. const gain = audioCtx.createGain();
  1013. osc.connect(gain).connect(masterGain);
  1014. osc.type = 'sine';
  1015. const freq = baseFreq * (1 + Math.random() * 0.1 - 0.05); // Slight freq variation
  1016. const detune = (i - 1) * 500; // Cents detune for chorus effect
  1017. osc.frequency.value = freq;
  1018. osc.detune.value = detune;
  1019. const startTime = time + i * 0.04;
  1020. gain.gain.setValueAtTime(0, startTime);
  1021. gain.gain.linearRampToValueAtTime(volume * 0.4, startTime + 0.1);
  1022. gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.4);
  1023. osc.start(startTime);
  1024. osc.stop(startTime + 0.4);
  1025. }
  1026. break;
  1027. }
  1028. case 'powerup_shield_activate': { // Activation sound for shield
  1029. const osc = audioCtx.createOscillator();
  1030. const gain = audioCtx.createGain();
  1031. const filter = audioCtx.createBiquadFilter();
  1032. osc.connect(gain).connect(filter).connect(masterGain);
  1033. osc.type = 'sawtooth';
  1034. osc.frequency.setValueAtTime(200, time);
  1035. osc.frequency.exponentialRampToValueAtTime(800, time + 0.3);
  1036. gain.gain.setValueAtTime(volume * 0.6, time);
  1037. gain.gain.exponentialRampToValueAtTime(0.001, time + 0.5);
  1038. filter.type = 'lowpass';
  1039. filter.frequency.setValueAtTime(500, time);
  1040. filter.frequency.exponentialRampToValueAtTime(2000, time + 0.3);
  1041. osc.start(time);
  1042. osc.stop(time + 0.5);
  1043. break;
  1044. }
  1045. case 'powerup_shield_expire': { // Deactivation sound for shield
  1046. const osc = audioCtx.createOscillator();
  1047. const gain = audioCtx.createGain();
  1048. const filter = audioCtx.createBiquadFilter();
  1049. osc.connect(gain).connect(filter).connect(masterGain);
  1050. osc.type = 'sawtooth';
  1051. osc.frequency.setValueAtTime(800, time);
  1052. osc.frequency.exponentialRampToValueAtTime(200, time + 0.4);
  1053. gain.gain.setValueAtTime(volume * 0.5, time);
  1054. gain.gain.exponentialRampToValueAtTime(0.001, time + 0.5);
  1055. filter.type = 'lowpass';
  1056. filter.frequency.setValueAtTime(2000, time);
  1057. filter.frequency.exponentialRampToValueAtTime(500, time + 0.4);
  1058. osc.start(time);
  1059. osc.stop(time + 0.5);
  1060. break;
  1061. }
  1062. case 'reload_start': { // Click sound
  1063. const noise = audioCtx.createBufferSource();
  1064. const noiseGain = audioCtx.createGain();
  1065. const buffer = audioCtx.createBuffer(1, 44100 * 0.06, audioCtx.sampleRate);
  1066. const data = buffer.getChannelData(0);
  1067. for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
  1068. noise.buffer = buffer;
  1069. noise.connect(noiseGain).connect(masterGain);
  1070. noiseGain.gain.setValueAtTime(volume * 0.5, time);
  1071. noiseGain.gain.linearRampToValueAtTime(0, time + 0.06);
  1072. noise.start(time);
  1073. noise.stop(time + 0.06);
  1074. break;
  1075. }
  1076. case 'reload_finish': { // Chunkier click/slide
  1077. // Click
  1078. const noise1 = audioCtx.createBufferSource();
  1079. const noiseGain1 = audioCtx.createGain();
  1080. const buffer1 = audioCtx.createBuffer(1, 44100 * 0.05, audioCtx.sampleRate);
  1081. const data1 = buffer1.getChannelData(0);
  1082. for (let i = 0; i < data1.length; i++) data1[i] = Math.random() * 2 - 1;
  1083. noise1.buffer = buffer1;
  1084. noise1.connect(noiseGain1).connect(masterGain);
  1085. noiseGain1.gain.setValueAtTime(volume * 0.6, time);
  1086. noiseGain1.gain.linearRampToValueAtTime(0, time + 0.05);
  1087. noise1.start(time);
  1088. noise1.stop(time + 0.05);
  1089. // Chunk/slide (slightly delayed)
  1090. const noise2 = audioCtx.createBufferSource();
  1091. const noiseGain2 = audioCtx.createGain();
  1092. const filter2 = audioCtx.createBiquadFilter();
  1093. const buffer2 = audioCtx.createBuffer(1, 44100 * 0.1, audioCtx.sampleRate);
  1094. const data2 = buffer2.getChannelData(0);
  1095. for (let i = 0; i < data2.length; i++) data2[i] = Math.random() * 2 - 1;
  1096. noise2.buffer = buffer2;
  1097. noise2.connect(filter2).connect(noiseGain2).connect(masterGain);
  1098. filter2.type = 'bandpass';
  1099. filter2.frequency.value = 1500;
  1100. filter2.Q.value = 0.8;
  1101. noiseGain2.gain.setValueAtTime(volume * 0.4, time + 0.03);
  1102. noiseGain2.gain.exponentialRampToValueAtTime(0.001, time + 0.13);
  1103. noise2.start(time + 0.03);
  1104. noise2.stop(time + 0.13);
  1105. break;
  1106. }
  1107. case 'out_of_ammo': { // Empty "click" sound
  1108. const noise = audioCtx.createBufferSource();
  1109. const noiseGain = audioCtx.createGain();
  1110. const buffer = audioCtx.createBuffer(1, 44100 * 0.04, audioCtx.sampleRate);
  1111. const data = buffer.getChannelData(0);
  1112. for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
  1113. noise.buffer = buffer;
  1114. noise.connect(noiseGain).connect(masterGain);
  1115. noiseGain.gain.setValueAtTime(volume * 0.3, time); // Quieter click
  1116. noiseGain.gain.linearRampToValueAtTime(0, time + 0.04);
  1117. noise.start(time);
  1118. noise.stop(time + 0.04);
  1119. break;
  1120. }
  1121. case 'stamina_depleted': { // Low energy/breath sound
  1122. const bufferSize = audioCtx.sampleRate * 0.3;
  1123. const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  1124. const data = buffer.getChannelData(0);
  1125. for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize); // Noise fading out
  1126. const noise = audioCtx.createBufferSource();
  1127. const noiseGain = audioCtx.createGain();
  1128. const filter = audioCtx.createBiquadFilter();
  1129. noise.buffer = buffer;
  1130. noise.connect(filter).connect(noiseGain).connect(masterGain);
  1131. filter.type = 'bandpass';
  1132. filter.frequency.setValueAtTime(800, time);
  1133. filter.frequency.linearRampToValueAtTime(400, time + 0.3);
  1134. filter.Q.value = 0.7;
  1135. noiseGain.gain.setValueAtTime(volume * 0.3, time);
  1136. noiseGain.gain.linearRampToValueAtTime(0.001, time + 0.3);
  1137. noise.start(time);
  1138. noise.stop(time + 0.3);
  1139. break;
  1140. }
  1141. case 'game_over': { // Descending sad trombone-ish sound
  1142. const osc = audioCtx.createOscillator();
  1143. const gain = audioCtx.createGain();
  1144. osc.connect(gain).connect(masterGain);
  1145. osc.type = 'sawtooth';
  1146. osc.frequency.setValueAtTime(300, time);
  1147. osc.frequency.exponentialRampToValueAtTime(100, time + 1.5);
  1148. gain.gain.setValueAtTime(volume * 0.8, time);
  1149. gain.gain.linearRampToValueAtTime(0, time + 1.5);
  1150. osc.start(time);
  1151. osc.stop(time + 1.5);
  1152. break;
  1153. }
  1154. case 'wave_start': { // Alert sound / short rising tone
  1155. const osc = audioCtx.createOscillator();
  1156. const gain = audioCtx.createGain();
  1157. osc.connect(gain).connect(masterGain);
  1158. osc.type = 'square';
  1159. osc.frequency.setValueAtTime(500, time);
  1160. osc.frequency.exponentialRampToValueAtTime(1000, time + 0.3);
  1161. gain.gain.setValueAtTime(volume * 0.7, time);
  1162. gain.gain.linearRampToValueAtTime(0, time + 0.5);
  1163. osc.start(time);
  1164. osc.stop(time + 0.5);
  1165. break;
  1166. }
  1167. case 'weapon_switch': { // Short clicking sound for switching
  1168. const osc = audioCtx.createOscillator();
  1169. const gain = audioCtx.createGain();
  1170. osc.connect(gain).connect(masterGain);
  1171. osc.type = 'square';
  1172. osc.frequency.setValueAtTime(1500, time);
  1173. gain.gain.setValueAtTime(volume * 0.5, time);
  1174. gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
  1175. osc.start(time);
  1176. osc.stop(time + 0.05);
  1177. break;
  1178. }
  1179.  
  1180. default:
  1181. break;
  1182. }
  1183. }
  1184.  
  1185.  
  1186. // --- Utility Functions ---
  1187. function getRandomColor() {
  1188. const letters = '0123456789ABCDEF';
  1189. let color = '#';
  1190. for (let i = 0; i < 6; i++) {
  1191. color += letters[Math.floor(Math.random() * 16)];
  1192. }
  1193. return color;
  1194. }
  1195.  
  1196. function distance(x1, y1, x2, y2) {
  1197. return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
  1198. }
  1199.  
  1200. function distanceSq(x1, y1, x2, y2) { // Added squared distance utility
  1201. return (x2 - x1) ** 2 + (y2 - y1) ** 2;
  1202. }
  1203.  
  1204. function normalize(x, y) {
  1205. const len = Math.sqrt(x * x + y * y);
  1206. if (len === 0) return {x: 0, y: 0}; // Handle zero length vector
  1207. return {x: x / len, y: y / len};
  1208. }
  1209.  
  1210. function worldToScreen(x, y) {
  1211. // Apply camera shake
  1212. const screenX = x - camera.x + canvas.width / 2 + (Math.random() - 0.5) * camera.shakeIntensity;
  1213. const screenY = y - camera.y + canvas.height / 2 + (Math.random() - 0.5) * camera.shakeIntensity;
  1214. return {x: screenX, y: screenY};
  1215. }
  1216.  
  1217. function screenToWorld(x, y) {
  1218. return {
  1219. x: x + camera.x - canvas.width / 2,
  1220. y: y + camera.y - canvas.height / 2
  1221. };
  1222. }
  1223.  
  1224. function applyCameraShake(intensity, duration) {
  1225. camera.shakeIntensity = Math.max(camera.shakeIntensity, intensity);
  1226. camera.shakeDuration = Math.max(camera.shakeDuration, duration);
  1227. }
  1228.  
  1229. function randomInRange(min, max) {
  1230. return Math.random() * (max - min) + min;
  1231. }
  1232.  
  1233. // Clamps a screen coordinate to the canvas edge with margin
  1234. function clampToScreenEdge(x, y, margin) {
  1235. return {
  1236. x: Math.max(margin, Math.min(canvas.width - margin, x)),
  1237. y: Math.max(margin, Math.min(canvas.height - margin, y))
  1238. };
  1239. }
  1240.  
  1241. function addLightEffect(x, y, radius, color, duration) {
  1242. lightEffects.push({x, y, radius, color, duration, startTime: gameTime});
  1243. }
  1244.  
  1245. // Revised Line-Rectangle Intersection using Slab Method (Axis-Aligned)
  1246. function intersectLineRectangle(x1, y1, x2, y2, rectX, rectY, rectW, rectH) {
  1247. const dx = x2 - x1;
  1248. const dy = y2 - y1;
  1249. let tNear = 0; // Start parameter of line segment p1->p2
  1250. let tFar = 1; // End parameter of line segment p1->p2
  1251.  
  1252. // --- Slab tests for X and Y ---
  1253. const p = [-dx, dx, -dy, dy];
  1254. const q = [x1 - rectX, (rectX + rectW) - x1, y1 - rectY, (rectY + rectH) - y1];
  1255.  
  1256. for (let i = 0; i < 4; i++) {
  1257. if (p[i] === 0) {
  1258. // Line is parallel to the slab edge i
  1259. if (q[i] < 0) {
  1260. return null; // Parallel and outside the box boundary
  1261. }
  1262. // Parallel and inside or on edge - continue checks
  1263. } else {
  1264. const r = q[i] / p[i];
  1265. if (p[i] < 0) { // Line enters slab i
  1266. if (r > tFar) return null; // Enters after leaving the valid segment [tNear, tFar]
  1267. if (r > tNear) tNear = r; // Potential new near intersection time
  1268. } else { // Line leaves slab i
  1269. if (r < tNear) return null; // Leaves before entering the valid segment [tNear, tFar]
  1270. if (r < tFar) tFar = r; // Potential new far intersection time
  1271. }
  1272. }
  1273. }
  1274.  
  1275. // If tNear > tFar, the intersection interval is empty (or invalid)
  1276. // Also check if tNear is within the original segment [0, 1]
  1277. if (tNear <= tFar && tNear >= 0 && tNear <= 1) {
  1278. // Calculate the intersection point using tNear
  1279. let intersectX = x1 + dx * tNear;
  1280. let intersectY = y1 + dy * tNear;
  1281.  
  1282. // Snap the intersection point precisely onto the boundary edge to avoid floating point errors
  1283. // This assumes tNear correctly identifies the first boundary hit from inside->outside
  1284. const tolerance = 0.01; // Small tolerance for boundary checks
  1285. if (Math.abs(intersectX - rectX) < tolerance) intersectX = rectX;
  1286. else if (Math.abs(intersectX - (rectX + rectW)) < tolerance) intersectX = rectX + rectW;
  1287. if (Math.abs(intersectY - rectY) < tolerance) intersectY = rectY;
  1288. else if (Math.abs(intersectY - (rectY + rectH)) < tolerance) intersectY = rectY + rectH;
  1289.  
  1290. // Final clamp just in case snapping wasn't perfect
  1291. intersectX = Math.max(rectX, Math.min(rectX + rectW, intersectX));
  1292. intersectY = Math.max(rectY, Math.min(rectY + rectH, intersectY));
  1293.  
  1294. return {x: intersectX, y: intersectY};
  1295. }
  1296.  
  1297. // No valid intersection found within the line segment [p1, p2] that hits the rectangle boundary
  1298. return null;
  1299. }
  1300.  
  1301. // --- Classes ---
  1302.  
  1303. class Entity {
  1304. constructor(x, y, size, color, health) {
  1305. this.x = x;
  1306. this.y = y;
  1307. this.size = size;
  1308. this.color = color;
  1309. this.maxHealth = health;
  1310. this.health = health;
  1311. this.vx = 0; // velocity x
  1312. this.vy = 0; // velocity y
  1313. this.id = Math.random().toString(36).substring(2, 15); // Unique ID for tracking hits etc.
  1314. }
  1315.  
  1316. draw(ctx) {
  1317. // Abstract method - should be implemented by subclasses
  1318. }
  1319.  
  1320. update(dt) {
  1321. this.x += this.vx * dt;
  1322. this.y += this.vy * dt;
  1323. }
  1324.  
  1325. drawHealthBar(ctx) {
  1326. if (this.health < this.maxHealth && this.health > 0) { // Don't draw if dead or full
  1327. const screenPos = worldToScreen(this.x, this.y);
  1328. const barWidth = this.size * HEALTH_BAR_WIDTH_FACTOR;
  1329. const barX = screenPos.x - barWidth / 2;
  1330. const barY = screenPos.y - this.size - 10; // Position ABOVE the entity slightly higher
  1331.  
  1332. // Background of health bar
  1333. ctx.fillStyle = 'rgba(100, 100, 100, 0.7)'; // Slightly transparent grey
  1334. ctx.fillRect(barX, barY, barWidth, HEALTH_BAR_HEIGHT);
  1335.  
  1336. // Current health
  1337. const healthPercentage = this.health / this.maxHealth;
  1338. 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
  1339. ctx.fillStyle = healthColor;
  1340. ctx.fillRect(barX, barY, barWidth * healthPercentage, HEALTH_BAR_HEIGHT);
  1341.  
  1342. // Add a thin border to the health bar for definition
  1343. ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
  1344. ctx.lineWidth = 0.5;
  1345. ctx.strokeRect(barX, barY, barWidth, HEALTH_BAR_HEIGHT);
  1346. ctx.lineWidth = 1; // Reset line width
  1347. }
  1348. }
  1349.  
  1350. takeDamage(amount, source = null, burnDamage = 0, burnDuration = 0) { // Added burn params
  1351. if (this.health <= 0) return false; // Already dead
  1352.  
  1353. const actualDamage = amount * (1 - (this.damageResistance || 0)); // Apply resistance if exists
  1354. if (actualDamage <= 0) return false; // No damage dealt
  1355.  
  1356. this.health -= actualDamage;
  1357.  
  1358. // --- Create Damage Text ---
  1359. let damageColor = 'white';
  1360. let textClass = '';
  1361. let damageSourceType = typeof source === 'string' ? source : source?.constructor?.name; // Get source type
  1362.  
  1363. if (damageSourceType === 'fire' || source === 'fire') { // Check if source is 'fire' (for burn ticks)
  1364. damageColor = 'orange';
  1365. textClass = 'burn'; // Add class for CSS styling
  1366. playSound('hit_fire', {volume: 0.3}); // Play fire hit sound
  1367. } else if (amount > 0) {
  1368. playSound('hit_enemy', {volume: 0.4}); // Play normal hit sound only if direct damage > 0
  1369. }
  1370.  
  1371. if (amount > 0) { // Only show text for initial hit or burn ticks, not just status application
  1372. createDamageText(this.x, this.y, actualDamage.toFixed(0), damageColor, this.size, textClass);
  1373. }
  1374.  
  1375.  
  1376. // --- NEW --- Handle Burn Effect Application (only if source isn't 'fire' itself)
  1377. if (burnDamage > 0 && burnDuration > 0 && source !== 'fire') {
  1378. this.applyBurn(burnDamage, burnDuration);
  1379. }
  1380. // --- END NEW ---
  1381.  
  1382.  
  1383. if (this.health <= 0) {
  1384. this.health = 0;
  1385. return true; // Died
  1386. }
  1387. return false; // Survived
  1388. }
  1389.  
  1390. applyBurn(damagePerTick, duration) {
  1391. // Apply resistance to burn damage? Maybe not for fire...
  1392. this.burnDamage = Math.max(this.burnDamage, damagePerTick); // Take the highest burn damage if multiple sources? Or stack? Let's take highest for now.
  1393. this.burnDuration = Math.max(this.burnDuration, duration); // Refresh/extend duration
  1394. this.isOnFire = true; // Set flag immediately
  1395. this.lastBurnTickTime = gameTime; // Start ticking soon
  1396. console.log(`Enemy ${this.id} is burning! Damage: ${this.burnDamage}, Duration: ${this.burnDuration}`);
  1397. }
  1398. }
  1399.  
  1400. class Player extends Entity {
  1401. constructor(x, y, color) {
  1402. super(x, y, PLAYER_SIZE, color, 100);
  1403. this.baseSpeed = PLAYER_BASE_SPEED;
  1404. this.speed = this.baseSpeed;
  1405. this.xp = 0;
  1406. this.level = 1;
  1407. this.xpToNextLevel = BASE_XP_TO_LEVEL;
  1408. this.healthRegen = 0; // Initialize health regen
  1409. this.money = 0; // Initialize money
  1410.  
  1411. // Sprinting
  1412. this.maxStamina = PLAYER_BASE_MAX_STAMINA;
  1413. this.currentStamina = this.maxStamina;
  1414. this.staminaRegenRate = PLAYER_BASE_STAMINA_REGEN;
  1415. this.isSprinting = false;
  1416. this.canSprint = true; // Can start sprinting
  1417. this.staminaDepletedCooldown = 0; // Cooldown after depletion
  1418.  
  1419. // --- Weapon Definitions ---
  1420. this.weapons = [ // Use an array for easy switching by index
  1421. { // 0: Bat (Melee)
  1422. name: "Bat", type: 'melee', damage: 25, rateOfFire: 2, // swings per second
  1423. range: 80, swingArc: Math.PI / 2, // 90 degree arc, INCREASED RANGE
  1424. lastSwingTime: 0, color: '#aaa', knockback: 150 // Added default knockback
  1425. },
  1426. { // 1: Knife (Melee) - Faster, less damage/range
  1427. name: "Knife", type: 'melee', damage: 15, rateOfFire: 4,
  1428. range: 60, swingArc: Math.PI / 3, // 60 degree arc, INCREASED RANGE
  1429. lastSwingTime: 0, color: '#ccc', knockback: 50 // Added default knockback
  1430. },
  1431. { // 2: Pistol (Ranged)
  1432. name: "Pistol", type: 'ranged', damage: 18, rateOfFire: 5,
  1433. projectileSpeed: PROJECTILE_BASE_SPEED, projectileSize: PROJECTILE_BASE_SIZE,
  1434. ammoType: 'pistol', ammoPerShot: 1, clipSize: 12, maxAmmo: 100, // Max reserve ammo
  1435. lastShotTime: 0, color: PROJECTILE_COLOR, recoil: 50,
  1436. accuracy: 0.05, projectileCount: 1, spreadAngle: 0,
  1437. ammoInClip: 12, reloadTime: 1.5 // Seconds
  1438. },
  1439. { // 3: SMG (Ranged) - High fire rate, less damage, less accurate
  1440. name: "SMG", type: 'ranged', damage: 12, rateOfFire: 12,
  1441. projectileSpeed: PROJECTILE_BASE_SPEED * 0.9, projectileSize: PROJECTILE_BASE_SIZE * 0.8,
  1442. ammoType: 'smg', ammoPerShot: 1, clipSize: 30, maxAmmo: 200,
  1443. lastShotTime: 0, color: 'orange', recoil: 70,
  1444. accuracy: 0.15, projectileCount: 1, spreadAngle: 0,
  1445. ammoInClip: 30, reloadTime: 2.2
  1446. },
  1447. { // 4: Shotgun (Ranged) - Multi-projectile, spread
  1448. name: "Shotgun", type: 'ranged', damage: 10, rateOfFire: 1.5, // Damage per pellet
  1449. projectileSpeed: PROJECTILE_BASE_SPEED * 0.8, projectileSize: PROJECTILE_BASE_SIZE * 0.7,
  1450. ammoType: 'shotgun', ammoPerShot: 1, clipSize: 6, maxAmmo: 50, // Uses 1 shell, fires multiple pellets
  1451. lastShotTime: 0, color: 'red', recoil: 250,
  1452. accuracy: 0.1, projectileCount: 6, spreadAngle: 0.5, // Radians (approx 30 deg)
  1453. ammoInClip: 6, reloadTime: 3.0 // Long reload
  1454. },
  1455. { // 5: Minigun (Ranged)
  1456. name: "Minigun", type: 'ranged', damage: 8, rateOfFire: 25, // High RoF, low damage
  1457. projectileSpeed: PROJECTILE_BASE_SPEED * 1.1, projectileSize: PROJECTILE_BASE_SIZE * 0.7,
  1458. ammoType: 'minigun', ammoPerShot: 1, clipSize: 500, maxAmmo: 2000, // Large clip, large reserve
  1459. lastShotTime: 0, color: '#ADD8E6', recoil: 40, // Less recoil per shot, but fires fast
  1460. accuracy: 0.1, projectileCount: 1, spreadAngle: 0, // Initial accuracy okay
  1461. accuracyBloomPerShot: 0.015, // --- NEW: Spread increases when firing
  1462. maxAccuracyPenalty: 0.3, // --- NEW: Max additional spread
  1463. currentAccuracyPenalty: 0, // --- NEW: Current penalty state
  1464. ammoInClip: 100, reloadTime: 4.5, muzzleFlashSize: 10 // Long reload
  1465. },
  1466. { // 6: Flamethrower (Ranged - Special Particle Stream)
  1467. name: "Flamethrower", type: 'ranged', isFlamethrower: true, // Flag for special handling
  1468. damage: 2, // Low direct damage per "shot" (particle burst)
  1469. rateOfFire: 15, // Controls particle burst rate
  1470. ammoType: 'fuel', ammoPerShot: 1, clipSize: 50, maxAmmo: 150,
  1471. lastShotTime: 0, color: 'orange', // Used for particles
  1472. recoil: 20, accuracy: 0.2, // Affects particle spread
  1473. ammoInClip: 50, reloadTime: 3.0, muzzleFlashSize: 20
  1474. // Burn damage/duration defined in constants
  1475. },
  1476. { // 7: Bazooka (Ranged - Explosive)
  1477. name: "Bazooka", type: 'ranged', damage: 30, // Direct hit damage (less important)
  1478. rateOfFire: 0.7, // Very slow
  1479. projectileSpeed: BAZOOKA_PROJECTILE_SPEED, projectileSize: BAZOOKA_PROJECTILE_SIZE,
  1480. ammoType: 'rocket', ammoPerShot: 1, clipSize: 1, maxAmmo: 10, // Single shot, low reserve
  1481. lastShotTime: 0, color: '#FFA500', recoil: 400, // High recoil
  1482. accuracy: 0.08, projectileCount: 1, spreadAngle: 0,
  1483. ammoInClip: 1, reloadTime: 4.0, muzzleFlashSize: 35,
  1484. explosionRadius: BAZOOKA_EXPLOSION_RADIUS, // --- NEW: AoE radius
  1485. explosionDamage: BAZOOKA_MAX_DAMAGE, // --- NEW: Max AoE damage
  1486. isRocket: true // --- NEW: Flag for projectile type
  1487. }
  1488. ];
  1489. this.currentWeaponIndex = 2; // Start with Pistol
  1490. this.currentWeapon = this.weapons[this.currentWeaponIndex];
  1491.  
  1492. // --- Ammo (Reserve) ---
  1493. this.ammo = {
  1494. pistol: 50,
  1495. smg: 0,
  1496. shotgun: 10,
  1497. minigun: 0, // New
  1498. fuel: 0, // New
  1499. rocket: 2 // New
  1500. };
  1501.  
  1502. // --- Power-up Effects ---
  1503. this.powerUps = {
  1504. invincibility: 0, // duration remaining
  1505. doubleDamage: 0,
  1506. speedBoost: 0,
  1507. rapidFire: 0, // Multiplier for fire rate
  1508. xpMultiplier: 0, // Multiplier for XP gain
  1509. magnetRadius: 0, // Increased XP orb magnet radius
  1510. shieldOrbs: 0, // Duration for shield orbs
  1511. // Add future power-up types here
  1512. };
  1513. this.baseMagnetRadius = XP_ORB_MAGNET_RADIUS; // Store base value
  1514.  
  1515. // Shield Orb State
  1516. this.shieldOrbAngle = 0;
  1517. this.shieldOrbHitCooldowns = {}; // Tracks cooldowns per enemy ID for orbs
  1518.  
  1519. this.aimAngle = 0; // For drawing aim line/weapon
  1520.  
  1521. // --- Reloading State ---
  1522. this.isReloading = false;
  1523. this.reloadTimer = 0;
  1524.  
  1525. // --- Melee Animation State ---
  1526. this.meleeAnimationProgress = 1; // 0 = start, 1 = end
  1527. this.meleeAnimationDuration = 0;
  1528.  
  1529. this.minigunAccuracyDecayTimer = 0;
  1530. this.minigunAccuracyDecayRate = 0.1; // How much penalty decays per second when not firing
  1531. }
  1532.  
  1533. draw(ctx) {
  1534. const screenPos = worldToScreen(this.x, this.y);
  1535. let drawColor = this.color;
  1536.  
  1537. // Draw Shield Orbs if active
  1538. if (this.powerUps.shieldOrbs > 0) {
  1539. const orbBaseRadius = SHIELD_ORB_RADIUS;
  1540. const pulseFactor = Math.sin(gameTime * 8) * 3; // Gentle pulsing size
  1541. const currentOrbRadius = orbBaseRadius + pulseFactor;
  1542.  
  1543. for (let i = 0; i < SHIELD_ORB_COUNT; i++) {
  1544. const angle = this.shieldOrbAngle + (i * Math.PI * 2 / SHIELD_ORB_COUNT);
  1545. const orbX = this.x + Math.cos(angle) * currentOrbRadius;
  1546. const orbY = this.y + Math.sin(angle) * currentOrbRadius;
  1547. const orbScreenPos = worldToScreen(orbX, orbY);
  1548. const orbSize = 8 + pulseFactor * 0.5; // Orb size pulsing slightly
  1549.  
  1550. // Draw Orb
  1551. ctx.fillStyle = `rgba(100, 200, 255, ${0.6 + Math.sin(gameTime * 5 + i) * 0.2})`; // Pulsating blue alpha
  1552. ctx.beginPath();
  1553. ctx.arc(orbScreenPos.x, orbScreenPos.y, orbSize, 0, Math.PI * 2);
  1554. ctx.fill();
  1555.  
  1556. // Add core glow
  1557. ctx.fillStyle = `rgba(220, 240, 255, ${0.7 + Math.sin(gameTime * 5 + i + 1) * 0.2})`;
  1558. ctx.beginPath();
  1559. ctx.arc(orbScreenPos.x, orbScreenPos.y, orbSize * 0.5, 0, Math.PI * 2);
  1560. ctx.fill();
  1561.  
  1562. // Simple Particle trail for orbs
  1563. if (Math.random() < SHIELD_ORB_PARTICLE_RATE * (1 / 60)) { // Rate adjusted for assumed 60fps
  1564. createParticles(orbX, orbY, 1, 'rgba(150, 220, 255, 0.7)', 20, angle + Math.PI, 0.5, {
  1565. life: 0.3,
  1566. size: 2,
  1567. friction: 0.9,
  1568. glowColor: 'rgba(200, 230, 255, 0.5)', // --- NEW --- Subtle glow
  1569. glowSizeFactor: 2.0
  1570. });
  1571. }
  1572. }
  1573. }
  1574.  
  1575. // Visual feedback for invincibility
  1576. if (this.powerUps.invincibility > 0) {
  1577. ctx.globalAlpha = 0.5 + Math.sin(gameTime * 10) * 0.2;
  1578. ctx.fillStyle = 'white'; // Aura color
  1579. ctx.beginPath();
  1580. ctx.arc(screenPos.x, screenPos.y, this.size + 5 + Math.sin(gameTime * 10) * 2, 0, Math.PI * 2); // Pulsating size too
  1581. ctx.fill();
  1582. ctx.globalAlpha = 1.0;
  1583. }
  1584.  
  1585. // Sprinting visual effect (subtle trail/afterimage)
  1586. if (this.isSprinting && this.currentStamina > 0) {
  1587. ctx.globalAlpha = 0.3;
  1588. const trailOffsetFactor = 0.1; // How far back the trail is
  1589. const trailX = screenPos.x - this.vx * trailOffsetFactor;
  1590. const trailY = screenPos.y - this.vy * trailOffsetFactor;
  1591. ctx.fillStyle = this.color;
  1592. ctx.beginPath();
  1593. ctx.arc(trailX, trailY, this.size * 0.9, 0, Math.PI * 2);
  1594. ctx.fill();
  1595. ctx.globalAlpha = 1.0;
  1596. }
  1597.  
  1598.  
  1599. ctx.fillStyle = drawColor;
  1600. ctx.beginPath();
  1601. ctx.arc(screenPos.x, screenPos.y, this.size, 0, Math.PI * 2);
  1602. ctx.fill();
  1603.  
  1604. // Draw aiming direction / weapon representation
  1605. const weapon = this.currentWeapon;
  1606. let drawAimLine = true; // Flag to control drawing the default aim line
  1607.  
  1608. // --- MELEE WEAPON DRAWING (Adjusted) ---
  1609. if (weapon.type === 'melee' && this.meleeAnimationProgress < 1) {
  1610. drawAimLine = false; // Don't draw the default line while animating melee
  1611.  
  1612. ctx.save();
  1613. ctx.translate(screenPos.x, screenPos.y); // Move origin to player center
  1614.  
  1615. let weaponWidth, weaponHeight, currentAngle, distOffset;
  1616. const progress = this.meleeAnimationProgress; // 0 to 1
  1617.  
  1618. if (weapon.name === "Bat") {
  1619. weaponWidth = 8;
  1620. weaponHeight = weapon.range * 0.8; // Adjusted: Relate length to range
  1621. const halfArc = weapon.swingArc / 2;
  1622. const swingPhase = Math.sin(progress * Math.PI * 1.5 - Math.PI / 4);
  1623. currentAngle = this.aimAngle + swingPhase * halfArc * 1.2;
  1624. distOffset = this.size * 0.5; // Adjusted: Hold closer to center
  1625.  
  1626. ctx.rotate(currentAngle);
  1627. ctx.fillStyle = weapon.color;
  1628. ctx.fillRect(distOffset, -weaponWidth / 2, weaponHeight, weaponWidth);
  1629.  
  1630. } else if (weapon.name === "Knife") {
  1631. weaponWidth = 4;
  1632. weaponHeight = this.size * 1.5; // Adjusted: Keep relatively small
  1633. const stabPhase = Math.sin(progress * Math.PI); // 0 -> 1 -> 0
  1634. currentAngle = this.aimAngle;
  1635. distOffset = this.size * 0.5 + stabPhase * (weapon.range); // Adjusted: Start close, extend to full range
  1636.  
  1637. ctx.rotate(currentAngle);
  1638. ctx.fillStyle = weapon.color;
  1639. ctx.fillRect(distOffset, -weaponWidth / 2, weaponHeight, weaponWidth);
  1640. }
  1641.  
  1642. ctx.restore(); // Restore context transforms
  1643. }
  1644. // --- END MELEE WEAPON DRAWING ---
  1645.  
  1646. // Draw the default aim line ONLY if not drawing melee animation OR if it's a ranged weapon
  1647. if (drawAimLine && weapon.type === 'ranged') { // Only draw for ranged now
  1648. const angle = this.aimAngle;
  1649. ctx.strokeStyle = this.isReloading ? 'grey' : 'white'; // Grey out while reloading
  1650. ctx.lineWidth = 3;
  1651. ctx.beginPath();
  1652. ctx.moveTo(screenPos.x, screenPos.y);
  1653. const weaponLength = this.size + 8;
  1654. ctx.lineTo(screenPos.x + Math.cos(angle) * weaponLength, screenPos.y + Math.sin(angle) * weaponLength);
  1655. ctx.stroke();
  1656. ctx.lineWidth = 1; // Reset
  1657. }
  1658.  
  1659. this.drawHealthBar(ctx);
  1660. }
  1661.  
  1662. update(dt) {
  1663. if (isGameOver) return;
  1664.  
  1665. // Update aim angle
  1666. this.aimAngle = Math.atan2(mouse.y - canvas.height / 2, mouse.x - canvas.width / 2);
  1667.  
  1668. // Update Power-up timers and effects
  1669. this.updatePowerUps(dt);
  1670.  
  1671. // --- Sprinting Logic ---
  1672. // Update depletion cooldown
  1673. if (this.staminaDepletedCooldown > 0) {
  1674. this.staminaDepletedCooldown -= dt;
  1675. this.canSprint = false; // Cannot sprint during cooldown
  1676. } else {
  1677. this.canSprint = true; // Cooldown finished
  1678. }
  1679.  
  1680. // Check if sprint key is held and player can sprint
  1681. this.isSprinting = keys[SPRINT_KEY] && this.canSprint && this.currentStamina > 0;
  1682.  
  1683. // Stamina Consumption & Regeneration
  1684. if (this.isSprinting) {
  1685. this.currentStamina -= PLAYER_STAMINA_CONSUMPTION * dt;
  1686. if (this.currentStamina <= 0) {
  1687. this.currentStamina = 0;
  1688. this.isSprinting = false; // Stop sprinting
  1689. this.canSprint = false; // Can't sprint immediately
  1690. this.staminaDepletedCooldown = 1.0; // 1 second cooldown after depletion
  1691. playSound('stamina_depleted', {volume: 0.4});
  1692. }
  1693. } else if (this.currentStamina < this.maxStamina) {
  1694. this.currentStamina += this.staminaRegenRate * dt;
  1695. this.currentStamina = Math.min(this.currentStamina, this.maxStamina);
  1696. // Allow sprinting again if stamina recovers above a threshold (e.g., 10%) and cooldown is over
  1697. if (!this.canSprint && this.staminaDepletedCooldown <= 0 && this.currentStamina > this.maxStamina * 0.1) {
  1698. this.canSprint = true;
  1699. }
  1700. }
  1701.  
  1702. // Movement
  1703. let targetVX = 0;
  1704. let targetVY = 0;
  1705. // Apply power-up speed boost AND sprint boost
  1706. const speedBoostMultiplier = (this.powerUps.speedBoost > 0 ? 1.5 : 1);
  1707. const sprintMultiplier = (this.isSprinting ? PLAYER_SPRINT_MULTIPLIER : 1);
  1708. const currentSpeed = this.speed * speedBoostMultiplier * sprintMultiplier;
  1709.  
  1710. if (keys['w'] || keys['ArrowUp']) targetVY = -currentSpeed;
  1711. if (keys['s'] || keys['ArrowDown']) targetVY = currentSpeed;
  1712. if (keys['a'] || keys['ArrowLeft']) targetVX = -currentSpeed;
  1713. if (keys['d'] || keys['ArrowRight']) targetVX = currentSpeed;
  1714.  
  1715. // Normalize diagonal movement
  1716. if (targetVX !== 0 && targetVY !== 0) {
  1717. const factor = 1 / Math.sqrt(2);
  1718. targetVX *= factor;
  1719. targetVY *= factor;
  1720. }
  1721.  
  1722. // Smooth movement towards target velocity (prevents jerky stops)
  1723. const moveLerp = 0.2; // How quickly velocity adjusts (0-1)
  1724. this.vx += (targetVX - this.vx) * moveLerp;
  1725. this.vy += (targetVY - this.vy) * moveLerp;
  1726.  
  1727. // Apply velocity update from Entity class AFTER setting targetVX/VY
  1728. super.update(dt);
  1729.  
  1730. // Update Shield Orb rotation
  1731. if (this.powerUps.shieldOrbs > 0) {
  1732. this.shieldOrbAngle += SHIELD_ORB_ROTATION_SPEED * dt;
  1733. // Clean up old enemy IDs from cooldown tracker
  1734. const now = gameTime;
  1735. Object.keys(this.shieldOrbHitCooldowns).forEach(enemyId => {
  1736. if (now > this.shieldOrbHitCooldowns[enemyId]) {
  1737. delete this.shieldOrbHitCooldowns[enemyId];
  1738. }
  1739. });
  1740. }
  1741.  
  1742. // --- Reloading Logic ---
  1743. if (this.isReloading) {
  1744. this.reloadTimer -= dt;
  1745. reloadIndicatorDiv.style.display = 'block'; // Show indicator
  1746. if (this.reloadTimer <= 0) {
  1747. this.finishReload();
  1748. }
  1749. } else {
  1750. reloadIndicatorDiv.style.display = 'none'; // Hide indicator
  1751. // Check for manual reload input
  1752. if (keys[RELOAD_KEY]) {
  1753. this.startReload();
  1754. keys[RELOAD_KEY] = false; // Consume key press
  1755. }
  1756. }
  1757.  
  1758. // Update melee animation progress
  1759. if (this.meleeAnimationProgress < 1 && this.meleeAnimationDuration > 0) {
  1760. this.meleeAnimationProgress += dt / this.meleeAnimationDuration;
  1761. this.meleeAnimationProgress = Math.min(1, this.meleeAnimationProgress); // Clamp to 1
  1762. }
  1763.  
  1764. // Weapon Switching (Number keys 1 through 0)
  1765. for (let i = 1; i <= 9; i++) {
  1766. if (keys[i.toString()] && this.weapons.length >= i) {
  1767. this.switchWeapon(i - 1);
  1768. keys[i.toString()] = false; // Consume the key press
  1769. }
  1770. }
  1771. if (keys['0'] && this.weapons.length >= 10) { // Handle '0' key for 10th weapon
  1772. this.switchWeapon(9);
  1773. keys['0'] = false;
  1774. }
  1775.  
  1776. if (!mouse.down || this.currentWeapon.name !== "Minigun") {
  1777. if (this.currentWeapon.currentAccuracyPenalty > 0) {
  1778. this.minigunAccuracyDecayTimer += dt;
  1779. if (this.minigunAccuracyDecayTimer >= 0.1) { // Check decay every 0.1s
  1780. this.currentWeapon.currentAccuracyPenalty -= this.minigunAccuracyDecayRate * this.minigunAccuracyDecayTimer;
  1781. this.currentWeapon.currentAccuracyPenalty = Math.max(0, this.currentWeapon.currentAccuracyPenalty);
  1782. this.minigunAccuracyDecayTimer = 0; // Reset timer
  1783. }
  1784. }
  1785. } else {
  1786. this.minigunAccuracyDecayTimer = 0; // Reset timer if firing minigun
  1787. }
  1788.  
  1789.  
  1790. // Shooting / Attacking (Keep existing check)
  1791. if (mouse.down && !this.isReloading && !isShopOpen) {
  1792. this.tryAttack();
  1793. }
  1794. }
  1795.  
  1796. switchWeapon(index) {
  1797. if (index >= 0 && index < this.weapons.length && index !== this.currentWeaponIndex) {
  1798. if (this.isReloading) {
  1799. this.cancelReload(); // Cancel reload if switching weapons
  1800. }
  1801. this.currentWeaponIndex = index;
  1802. this.currentWeapon = this.weapons[index];
  1803. console.log(`Switched to weapon: ${this.currentWeapon.name}`);
  1804. updateUI(); // Update UI for weapon/ammo
  1805. playSound('weapon_switch', {volume: 0.4}); // Play switch sound
  1806. }
  1807. }
  1808.  
  1809. tryAttack() {
  1810. const weapon = this.currentWeapon;
  1811. const fireRateMultiplier = (this.powerUps.rapidFire > 0) ? 1.5 : 1;
  1812. const cooldown = 1 / (weapon.rateOfFire * fireRateMultiplier);
  1813. const lastAttackTime = weapon.type === 'melee' ? weapon.lastSwingTime : weapon.lastShotTime;
  1814.  
  1815. if ((gameTime - lastAttackTime) > cooldown) {
  1816. if (weapon.type === 'melee') {
  1817. this.performMeleeAttack(weapon);
  1818. weapon.lastSwingTime = gameTime;
  1819. } else if (weapon.type === 'ranged') {
  1820. // Check Ammo in Clip
  1821. if (weapon.ammoInClip > 0) {
  1822. weapon.ammoInClip -= weapon.ammoPerShot; // Use clip ammo
  1823. if (weapon.name === "Minigun" && weapon.accuracyBloomPerShot > 0) {
  1824. weapon.currentAccuracyPenalty = Math.min(weapon.maxAccuracyPenalty, weapon.currentAccuracyPenalty + weapon.accuracyBloomPerShot);
  1825. }
  1826.  
  1827. if (weapon.isFlamethrower) { // --- NEW --- Special Flamethrower handling
  1828. this.shootFlamethrower(weapon);
  1829. } else { // Normal Ranged
  1830. this.shootRanged(weapon);
  1831. }
  1832. weapon.lastShotTime = gameTime;
  1833. updateUI(); // Update ammo display
  1834. if (weapon.ammoInClip <= 0) {
  1835. playSound('out_of_ammo', {volume: 0.5});
  1836. }
  1837. } else {
  1838. playSound('out_of_ammo', {volume: 0.5});
  1839. this.startReload(); // Attempt to start reload if empty
  1840. }
  1841. }
  1842. }
  1843. }
  1844.  
  1845. startReload() {
  1846. const weapon = this.currentWeapon;
  1847. if (weapon.type !== 'ranged' || this.isReloading || !weapon.clipSize || weapon.ammoInClip >= weapon.clipSize || !this.ammo[weapon.ammoType] || this.ammo[weapon.ammoType] <= 0) {
  1848. return; // Can't reload
  1849. }
  1850.  
  1851. this.isReloading = true;
  1852. const actualReloadTime = weapon.reloadTime * (this.powerUps.fastReload > 0 ? 0.7 : 1); // Example modifier
  1853. this.reloadTimer = actualReloadTime;
  1854. console.log(`Starting reload for ${weapon.name} (${this.reloadTimer.toFixed(1)}s)...`);
  1855. playSound('reload_start');
  1856. updateUI(); // Show reloading status maybe
  1857. }
  1858.  
  1859. finishReload() {
  1860. const weapon = this.currentWeapon;
  1861. if (!this.isReloading || weapon.type !== 'ranged') return; // Safety checks
  1862.  
  1863. const ammoNeeded = weapon.clipSize - weapon.ammoInClip;
  1864. const ammoAvailable = this.ammo[weapon.ammoType] || 0;
  1865. const ammoToLoad = Math.min(ammoNeeded, ammoAvailable);
  1866.  
  1867. if (ammoToLoad > 0) {
  1868. weapon.ammoInClip += ammoToLoad;
  1869. this.ammo[weapon.ammoType] -= ammoToLoad;
  1870. console.log(`Reloaded ${weapon.name} with ${ammoToLoad} rounds.`);
  1871. playSound('reload_finish');
  1872. } else {
  1873. console.log(`Reload failed for ${weapon.name} - no reserve ammo.`);
  1874. playSound('out_of_ammo', {volume: 0.4}); // Empty click if no reserve
  1875. }
  1876.  
  1877.  
  1878. this.isReloading = false;
  1879. this.reloadTimer = 0;
  1880. updateUI(); // Update ammo display
  1881. reloadIndicatorDiv.style.display = 'none';
  1882. }
  1883.  
  1884. cancelReload() {
  1885. if (!this.isReloading) return;
  1886. this.isReloading = false;
  1887. this.reloadTimer = 0;
  1888. console.log("Reload cancelled.");
  1889. reloadIndicatorDiv.style.display = 'none';
  1890. // Optionally play a cancel sound
  1891. updateUI();
  1892. }
  1893.  
  1894.  
  1895. performMeleeAttack(weapon) {
  1896. playSound('melee_swing'); // Play swing sound
  1897.  
  1898. // Start animation
  1899. this.meleeAnimationProgress = 0;
  1900. const fireRateMultiplier = (this.powerUps.rapidFire > 0 ? 1.5 : 1);
  1901. this.meleeAnimationDuration = 1 / (weapon.rateOfFire * fireRateMultiplier);
  1902. this.meleeAnimationDuration = Math.max(0.05, this.meleeAnimationDuration); // Minimum duration
  1903.  
  1904. const attackAngle = this.aimAngle;
  1905. const halfArc = weapon.swingArc / 2;
  1906. let enemiesHit = 0;
  1907.  
  1908. enemies.forEach(enemy => {
  1909. if (enemy.health <= 0) return; // Skip dead enemies
  1910.  
  1911. const dist = distance(this.x, this.y, enemy.x, enemy.y);
  1912.  
  1913. if (dist < weapon.range + enemy.size) {
  1914. const angleToEnemy = Math.atan2(enemy.y - this.y, enemy.x - this.x);
  1915. let angleDiff = attackAngle - angleToEnemy;
  1916.  
  1917. while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
  1918. while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
  1919.  
  1920. if (Math.abs(angleDiff) < halfArc) {
  1921. const damageMultiplier = (this.powerUps.doubleDamage > 0) ? 2 : 1;
  1922. const damageDealt = weapon.damage * damageMultiplier;
  1923. const died = enemy.takeDamage(damageDealt, this); // Pass player as source
  1924. enemiesHit++;
  1925.  
  1926. const knockbackResistance = enemy.knockbackResistance || 0;
  1927. const baseKnockback = weapon.knockback || 0;
  1928. const actualKnockback = baseKnockback * (1 - knockbackResistance);
  1929.  
  1930. if (actualKnockback > 0) {
  1931. const pushDir = normalize(enemy.x - this.x, enemy.y - this.y);
  1932. if (pushDir.x === 0 && pushDir.y === 0) {
  1933. const randomAngle = Math.random() * Math.PI * 2;
  1934. pushDir.x = Math.cos(randomAngle);
  1935. pushDir.y = Math.sin(randomAngle);
  1936. }
  1937. const impulseStrength = actualKnockback * 1.0;
  1938. enemy.vx += pushDir.x * impulseStrength;
  1939. enemy.vy += pushDir.y * impulseStrength;
  1940. }
  1941.  
  1942. if (died) {
  1943. enemy.onDeath();
  1944. updateUI();
  1945. }
  1946. }
  1947. }
  1948. });
  1949.  
  1950. if (enemiesHit > 0) {
  1951. playSound('melee_hit', {volume: 0.5 + Math.min(0.4, enemiesHit * 0.1)});
  1952. applyCameraShake(5 * enemiesHit, 0.1);
  1953. }
  1954.  
  1955. createParticles(this.x, this.y, 7, weapon.color, 150, attackAngle, weapon.swingArc, {
  1956. life: 0.15,
  1957. friction: 0.8
  1958. });
  1959. }
  1960.  
  1961.  
  1962. shootRanged(weapon) {
  1963. const damageMultiplier = (this.powerUps.doubleDamage > 0) ? 2 : 1;
  1964. const baseAngle = this.aimAngle;
  1965.  
  1966. const muzzleX = this.x + Math.cos(baseAngle) * (this.size + 5);
  1967. const muzzleY = this.y + Math.sin(baseAngle) * (this.size + 5);
  1968. createParticles(muzzleX, muzzleY, 8, 'yellow', 250, baseAngle, Math.PI / 8, {life: 0.1, friction: 0.8});
  1969. const soundName = `shoot_${weapon.name.toLowerCase().replace(' ', '_')}`;
  1970. playSound(soundName);
  1971.  
  1972. // Sprinting Accuracy Penalty (Optional)
  1973. const sprintAccuracyPenalty = this.isSprinting ? (weapon.accuracy || 0) * 0.5 : 0; // Add 50% of base inaccuracy when sprinting
  1974.  
  1975. for (let i = 0; i < weapon.projectileCount; i++) {
  1976. const accuracyOffset = (Math.random() - 0.5) * (weapon.accuracy + sprintAccuracyPenalty); // Include penalty
  1977. let spreadOffset = 0;
  1978. if (weapon.projectileCount > 1) {
  1979. const divisor = weapon.projectileCount > 1 ? weapon.projectileCount - 1 : 1;
  1980. const baseSpread = divisor > 0 ? (i / divisor) - 0.5 : 0;
  1981. spreadOffset = baseSpread * weapon.spreadAngle * (Math.random() * 0.4 + 0.8);
  1982. }
  1983.  
  1984. const currentAngle = baseAngle + accuracyOffset + spreadOffset;
  1985. const dx = Math.cos(currentAngle);
  1986. const dy = Math.sin(currentAngle);
  1987.  
  1988. const projX = this.x + dx * (this.size + weapon.projectileSize + 1);
  1989. const projY = this.y + dy * (this.size + weapon.projectileSize + 1);
  1990.  
  1991. projectiles.push(new Projectile(
  1992. projX, projY,
  1993. dx * weapon.projectileSpeed, dy * weapon.projectileSpeed,
  1994. weapon.damage * damageMultiplier,
  1995. weapon.projectileSize, weapon.color, this
  1996. ));
  1997. }
  1998.  
  1999. const recoilStrength = weapon.recoil || 0;
  2000. this.vx -= Math.cos(baseAngle) * recoilStrength * 0.03;
  2001. this.vy -= Math.sin(baseAngle) * recoilStrength * 0.03;
  2002.  
  2003. applyCameraShake(recoilStrength / 25, 0.1);
  2004. }
  2005.  
  2006. shootFlamethrower(weapon) {
  2007. const baseAngle = this.aimAngle;
  2008. const damageMultiplier = (this.powerUps.doubleDamage > 0) ? 2 : 1;
  2009.  
  2010. const muzzleOffset = this.size + 5;
  2011. const muzzleX = this.x + Math.cos(baseAngle) * muzzleOffset;
  2012. const muzzleY = this.y + Math.sin(baseAngle) * muzzleOffset;
  2013.  
  2014. // Muzzle flash (more orange/red)
  2015. const muzzleFlashSize = weapon.muzzleFlashSize || 20;
  2016. createParticles(muzzleX, muzzleY, 15, 'orange', 300, baseAngle, Math.PI / 5, {
  2017. life: 0.1,
  2018. friction: 0.75,
  2019. size: 6,
  2020. colorVariance: true
  2021. });
  2022. createParticles(muzzleX, muzzleY, 8, 'red', 250, baseAngle, Math.PI / 6, {
  2023. life: 0.12,
  2024. friction: 0.8,
  2025. size: 4
  2026. });
  2027. addLightEffect(muzzleX, muzzleY, muzzleFlashSize * 2, 'rgba(255, 165, 0, 0.5)', 0.1);
  2028.  
  2029.  
  2030. playSound('shoot_flamethrower', {volume: 0.35});
  2031.  
  2032. // Create a burst of fire particles
  2033. const particleCount = 5;
  2034. for (let i = 0; i < particleCount; i++) {
  2035. const accuracy = weapon.accuracy || 0.2;
  2036. const angleOffset = (Math.random() - 0.5) * accuracy * 1.5; // Wider spread for flames
  2037. const currentAngle = baseAngle + angleOffset;
  2038. const speedVariation = 0.8 + Math.random() * 0.4;
  2039. const speed = FLAMETHROWER_PARTICLE_SPEED * speedVariation;
  2040.  
  2041. createParticles(
  2042. muzzleX, muzzleY, 1, // Create one particle at a time
  2043. `rgba(255, ${100 + Math.floor(Math.random() * 100)}, 0, ${0.6 + Math.random() * 0.3})`, // Orange/Yellow/Red gradient
  2044. speed, currentAngle, 0, // No extra spread here
  2045. {
  2046. life: FLAMETHROWER_PARTICLE_LIFE * (0.8 + Math.random() * 0.4),
  2047. friction: 0.9,
  2048. size: FLAMETHROWER_PARTICLE_SIZE * (0.7 + Math.random() * 0.6),
  2049. gravity: randomInRange(-50, 50), // Slight random gravity for flutter
  2050. glowColor: 'rgba(255, 150, 0, 0.4)', // Orange glow
  2051. glowSizeFactor: 1.8
  2052. }
  2053. );
  2054. }
  2055.  
  2056. // Check for hits in a cone - MORE EFFICIENT than particle collision
  2057. const checkRangeSq = FLAMETHROWER_RANGE * FLAMETHROWER_RANGE;
  2058. const halfCone = FLAMETHROWER_CONE_ANGLE / 2;
  2059.  
  2060. enemies.forEach(enemy => {
  2061. if (!enemy || enemy.health <= 0) return;
  2062.  
  2063. const dx = enemy.x - this.x;
  2064. const dy = enemy.y - this.y;
  2065. const distSq = dx * dx + dy * dy;
  2066.  
  2067. if (distSq < checkRangeSq) {
  2068. const angleToEnemy = Math.atan2(dy, dx);
  2069. let angleDiff = baseAngle - angleToEnemy;
  2070. // Normalize angle difference to [-PI, PI]
  2071. while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
  2072. while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
  2073.  
  2074. if (Math.abs(angleDiff) < halfCone) {
  2075. // Hit! Apply direct damage AND burn effect
  2076. const directDamage = weapon.damage * damageMultiplier;
  2077. enemy.takeDamage(directDamage, this, FLAMETHROWER_BURN_DAMAGE_PER_TICK, FLAMETHROWER_BURN_DURATION);
  2078. }
  2079. }
  2080. });
  2081.  
  2082. // Recoil (less than bullets)
  2083. const recoilStrength = weapon.recoil || 20;
  2084. this.vx -= Math.cos(baseAngle) * recoilStrength * 0.02;
  2085. this.vy -= Math.sin(baseAngle) * recoilStrength * 0.02;
  2086. applyCameraShake(recoilStrength / 10, 0.08);
  2087.  
  2088. }
  2089.  
  2090. addXp(amount) {
  2091. if (amount <= 0) return;
  2092. const xpMultiplier = (this.powerUps.xpMultiplier > 0) ? 1.5 : 1;
  2093. const actualAmount = Math.max(1, Math.floor(amount * xpMultiplier));
  2094. this.xp += actualAmount;
  2095. console.log(`+${actualAmount} XP (Total: ${this.xp}/${this.xpToNextLevel})`);
  2096.  
  2097. while (this.xp >= this.xpToNextLevel) {
  2098. this.levelUp();
  2099. }
  2100. updateUI();
  2101. }
  2102.  
  2103. addMoney(amount) {
  2104. if (amount <= 0) return;
  2105. this.money += amount;
  2106. console.log(`+$${amount} (Total: $${this.money})`);
  2107. updateUI();
  2108. // Update shop button states if shop is open
  2109. if (isShopOpen) {
  2110. updateShopItemStates();
  2111. }
  2112. }
  2113.  
  2114. levelUp() {
  2115. this.level++;
  2116. this.xp -= this.xpToNextLevel; // Carry over excess XP
  2117. this.xpToNextLevel = Math.floor(BASE_XP_TO_LEVEL * (XP_LEVEL_MULTIPLIER ** (this.level - 1))); // Recalculate based on base and level
  2118. this.health = this.maxHealth; // Heal fully on level up
  2119.  
  2120. pendingLevelUps++; // Increment pending level ups
  2121.  
  2122. console.log(`Level Up! Reached level ${this.level}. Pending: ${pendingLevelUps}`);
  2123. playSound('level_up');
  2124. createParticles(this.x, this.y, 50, 'gold', 150, null, Math.PI * 2, {friction: 0.95}); // Level up particle burst
  2125. updateUI(); // Update stats display
  2126.  
  2127. levelUpIndicator.style.display = 'block';
  2128. pendingLevelUpsSpan.textContent = pendingLevelUps;
  2129.  
  2130. if (!isShowingLevelUpOptions && pendingLevelUps > 0 && !isShopOpen) { // Don't auto-open if shop is open
  2131. presentLevelUpOptions();
  2132. }
  2133. }
  2134.  
  2135.  
  2136. updatePowerUps(dt) {
  2137. let statusChanged = false;
  2138. let shieldExpired = false;
  2139.  
  2140. for (const type in this.powerUps) {
  2141. if (this.powerUps[type] > 0) {
  2142. this.powerUps[type] -= dt;
  2143. if (this.powerUps[type] <= 0) {
  2144. this.powerUps[type] = 0;
  2145. console.log(`Power-up expired: ${type}`);
  2146. statusChanged = true;
  2147. if (type === 'shieldOrbs') {
  2148. shieldExpired = true;
  2149. playSound('powerup_shield_expire');
  2150. }
  2151. }
  2152. }
  2153. }
  2154.  
  2155. // Expire shield orb hit cooldowns that belong to non-existent enemies
  2156. const activeEnemyIds = new Set(enemies.map(e => e.id));
  2157. Object.keys(this.shieldOrbHitCooldowns).forEach(enemyId => {
  2158. if (!activeEnemyIds.has(enemyId)) {
  2159. delete this.shieldOrbHitCooldowns[enemyId];
  2160. }
  2161. });
  2162.  
  2163.  
  2164. this.speed = this.baseSpeed * (this.powerUps.speedBoost > 0 ? 1.5 : 1);
  2165. XP_ORB_MAGNET_RADIUS = this.baseMagnetRadius * (this.powerUps.magnetRadius > 0 ? 2.0 : 1);
  2166. // Also apply magnet radius to money pickups? Yes.
  2167. MONEY_PICKUP_MAGNET_RADIUS = XP_ORB_MAGNET_RADIUS * 1.1; // Slightly larger for money?
  2168.  
  2169. if (statusChanged) {
  2170. updateUI(); // Update the UI power-up display
  2171. }
  2172. }
  2173.  
  2174. activatePowerUp(type, duration) {
  2175. console.log(`Activated power-up: ${type} for ${duration}s`);
  2176. const wasActive = (this.powerUps[type] || 0) > 0;
  2177. this.powerUps[type] = (this.powerUps[type] || 0) + duration; // Add duration, stacking
  2178.  
  2179. this.speed = this.baseSpeed * (this.powerUps.speedBoost > 0 ? 1.5 : 1);
  2180. XP_ORB_MAGNET_RADIUS = this.baseMagnetRadius * (this.powerUps.magnetRadius > 0 ? 2.0 : 1);
  2181. MONEY_PICKUP_MAGNET_RADIUS = XP_ORB_MAGNET_RADIUS * 1.1;
  2182.  
  2183.  
  2184. if (type === 'shieldOrbs' && !wasActive) { // Play sound only on initial activation
  2185. playSound('powerup_shield_activate');
  2186. this.shieldOrbHitCooldowns = {}; // Reset cooldowns when shield activates/reactivates
  2187. } else {
  2188. playSound('pickup_powerup', {volume: 0.6});
  2189. }
  2190.  
  2191. updateUI();
  2192. }
  2193.  
  2194. takeDamage(amount, source = null) { // Accept source
  2195. if (this.powerUps.invincibility > 0 || isGameOver) {
  2196. createParticles(this.x, this.y, 3, 'grey', 50);
  2197. return false; // Invincible or game over
  2198. }
  2199.  
  2200. createParticles(this.x, this.y, 5, '#ff4444', 100, null, Math.PI * 2, {friction: 0.9, life: 0.5});
  2201.  
  2202. // Call parent takeDamage (which now accepts source)
  2203. const died = super.takeDamage(amount, source);
  2204.  
  2205. const now = audioCtx ? audioCtx.currentTime : gameTime;
  2206. if (now - lastPlayerHitSoundTime > PLAYER_HIT_SOUND_COOLDOWN) {
  2207. playSound('hit_player');
  2208. lastPlayerHitSoundTime = now;
  2209. }
  2210.  
  2211. applyCameraShake(12, 0.15); // Shake on taking damage
  2212. updateUI(); // Update health display
  2213.  
  2214. if (died && !isGameOver) {
  2215. gameOver();
  2216. return true;
  2217. }
  2218. return died;
  2219. }
  2220.  
  2221. buyItem(item) {
  2222. if (this.money >= item.cost) {
  2223. this.money -= item.cost;
  2224. playSound('buy_item');
  2225. console.log(`Purchased ${item.name} for $${item.cost}`);
  2226.  
  2227. // Apply item effect
  2228. if (item.effect.type === 'ammo') {
  2229. const ammoType = item.effect.ammoType;
  2230. const amount = item.effect.amount;
  2231. const weaponDef = this.weapons.find(w => w.ammoType === ammoType);
  2232. const maxReserve = weaponDef ? weaponDef.maxAmmo : Infinity; // Use weapon max or assume infinite if no match
  2233. this.ammo[ammoType] = Math.min(maxReserve, (this.ammo[ammoType] || 0) + amount);
  2234. console.log(`Gained ${amount} ${ammoType} ammo. Reserve: ${this.ammo[ammoType]}/${maxReserve === Infinity ? '∞' : maxReserve}`);
  2235. } else if (item.effect.type === 'turret') {
  2236. // Find a non-overlapping spawn position near the player
  2237. let spawnAngle = Math.random() * Math.PI * 2;
  2238. let spawnOk = false;
  2239. let attempt = 0;
  2240. let tx, ty;
  2241. while (!spawnOk && attempt < 10) {
  2242. tx = this.x + Math.cos(spawnAngle) * TURRET_FOLLOW_DISTANCE;
  2243. ty = this.y + Math.sin(spawnAngle) * TURRET_FOLLOW_DISTANCE;
  2244. spawnOk = true;
  2245. // Check collision with existing turrets
  2246. for (const turret of turrets) {
  2247. if (distanceSq(tx, ty, turret.x, turret.y) < (TURRET_SIZE * 2) ** 2) {
  2248. spawnOk = false;
  2249. spawnAngle += Math.PI / 3; // Try different angle
  2250. break;
  2251. }
  2252. }
  2253. attempt++;
  2254. }
  2255. if (!spawnOk) { // Fallback if no good spot found quickly
  2256. tx = this.x + TURRET_FOLLOW_DISTANCE;
  2257. ty = this.y;
  2258. }
  2259.  
  2260. turrets.push(new Turret(tx, ty, this)); // Add new turret owned by player
  2261. item.buttonElement.disabled = true; // Disable button after purchase (one-time buy)
  2262. item.buttonElement.textContent = 'Owned';
  2263. }
  2264. // Add other item types here...
  2265.  
  2266. updateUI();
  2267. updateShopItemStates(); // Update button disabled states
  2268. return true;
  2269. } else {
  2270. playSound('cant_afford');
  2271. console.log(`Cannot afford ${item.name} ($${item.cost}). Need $${item.cost - this.money} more.`);
  2272. // Maybe flash the money UI red?
  2273. uiMoney.parentElement.style.color = 'red';
  2274. setTimeout(() => {
  2275. uiMoney.parentElement.style.color = 'white';
  2276. }, 500);
  2277. return false;
  2278. }
  2279. }
  2280.  
  2281. }
  2282.  
  2283. // --- Enemy Base Class and Variants ---
  2284. class Enemy extends Entity {
  2285. constructor(x, y, wave, options = {}) {
  2286. const sizeMultiplier = options.sizeMult || (1 + (wave - 1) * 0.05);
  2287. const healthMultiplier = options.healthMult || (1 + (wave - 1) * 0.1);
  2288. const speedMultiplier = options.speedMult || (1 + (wave - 1) * 0.04); // Increased wave speed scaling slightly
  2289.  
  2290. const size = (options.baseSize || ENEMY_START_SIZE) * sizeMultiplier;
  2291. const health = (options.baseHealth || ENEMY_START_HEALTH) * healthMultiplier;
  2292. const speed = (options.baseSpeed || ENEMY_START_SPEED) * speedMultiplier;
  2293. const color = options.color || `hsl(${randomInRange(0, 50)}, 70%, 50%)`; // Red/Orange hues
  2294.  
  2295. super(x, y, size, color, health);
  2296. this.speed = speed;
  2297. this.baseDamage = options.baseDamage || ENEMY_BASE_DAMAGE;
  2298. this.damage = this.baseDamage * (1 + (wave - 1) * 0.08); // Damage scales
  2299. this.xpValue = (options.xpValue || XP_PER_KILL) * (1 + (wave - 1) * 0.05);
  2300. this.enemyType = options.type || 'standard'; // For identification
  2301. this.target = null; // Store the target
  2302. this.knockbackResistance = options.knockbackResistance || 0; // Default no resistance
  2303. this.friction = 0.85; // Add friction to enemies
  2304.  
  2305. // Money Drop Values (scale with health/size/wave)
  2306. const valueMultiplier = healthMultiplier * sizeMultiplier * (1 + wave * 0.02);
  2307. this.moneyDropMin = options.moneyMin || Math.floor(BASE_MONEY_DROP_MIN * valueMultiplier);
  2308. this.moneyDropMax = options.moneyMax || Math.ceil(BASE_MONEY_DROP_MAX * valueMultiplier);
  2309.  
  2310. this.burnDamage = 0;
  2311. this.burnDuration = 0;
  2312. this.lastBurnTickTime = 0;
  2313. this.isOnFire = false;
  2314. this.damageResistance = options.damageResistance || 0; // Optional resistance
  2315. }
  2316.  
  2317. // Override base takeDamage to add blood effect
  2318. // takeDamage(amount, source = null) {
  2319. // if (this.health <= 0) return false; // Already dead
  2320. //
  2321. // const died = super.takeDamage(amount, source); // Call parent for health reduction & damage text
  2322. //
  2323. // createParticles(this.x, this.y, 2 + Math.floor(amount / 15), 'red', 80 + amount * 0.5, null, Math.PI * 2, {
  2324. // friction: 0.92,
  2325. // life: 0.4
  2326. // });
  2327. //
  2328. // return died;
  2329. // }
  2330.  
  2331.  
  2332.  
  2333. draw(ctx) { // Standard square enemy draw
  2334. const screenPos = worldToScreen(this.x, this.y);
  2335. ctx.fillStyle = this.color;
  2336. ctx.fillRect(screenPos.x - this.size / 2, screenPos.y - this.size / 2, this.size, this.size);
  2337. this.drawHealthBar(ctx);
  2338. }
  2339.  
  2340. update(dt, target) {
  2341. this.vx *= (1 - (1 - this.friction) * dt * 60);
  2342. this.vy *= (1 - (1 - this.friction) * dt * 60);
  2343.  
  2344. if (this.health <= 0) {
  2345. super.update(dt);
  2346. return;
  2347. }
  2348.  
  2349. this.target = target;
  2350.  
  2351. if (!this.target || typeof this.target.x === 'undefined') {
  2352. super.update(dt);
  2353. return;
  2354. }
  2355.  
  2356. const dx = this.target.x - this.x;
  2357. const dy = this.target.y - this.y;
  2358. const distToTargetSq = dx * dx + dy * dy;
  2359.  
  2360. // Stop moving if very close and avoid division by zero if dist is 0
  2361. const stoppingDistanceSq = (this.size * 0.5 + (this.target.size || 0) * 0.5 + 5) ** 2; // Add small buffer
  2362. if (distToTargetSq > stoppingDistanceSq && distToTargetSq > 0.1) { // Check dist > small threshold
  2363. const distToTarget = Math.sqrt(distToTargetSq); // Calculate actual distance only when needed
  2364. const moveDirX = dx / distToTarget;
  2365. const moveDirY = dy / distToTarget;
  2366.  
  2367. const targetVX = moveDirX * this.speed;
  2368. const targetVY = moveDirY * this.speed;
  2369.  
  2370. const desiredVX = targetVX - this.vx;
  2371. const desiredVY = targetVY - this.vy;
  2372.  
  2373. const maxAccel = this.speed * 4.0 * dt;
  2374. const desiredSpeed = Math.sqrt(desiredVX * desiredVX + desiredVY * desiredVY);
  2375. let accelX, accelY;
  2376.  
  2377. if (desiredSpeed <= maxAccel || desiredSpeed === 0) {
  2378. accelX = desiredVX;
  2379. accelY = desiredVY;
  2380. } else {
  2381. accelX = (desiredVX / desiredSpeed) * maxAccel;
  2382. accelY = (desiredVY / desiredSpeed) * maxAccel;
  2383. }
  2384.  
  2385. this.vx += accelX;
  2386. this.vy += accelY;
  2387.  
  2388. const currentSpeedSq = this.vx * this.vx + this.vy * this.vy;
  2389. if (currentSpeedSq > this.speed * this.speed * 1.1) {
  2390. const currentSpeed = Math.sqrt(currentSpeedSq);
  2391. this.vx = (this.vx / currentSpeed) * this.speed;
  2392. this.vy = (this.vy / currentSpeed) * this.speed;
  2393. }
  2394. } else {
  2395. // If too close or dist is near zero, just let friction work.
  2396. }
  2397.  
  2398. super.update(dt); // Apply velocity changes
  2399. }
  2400.  
  2401. onDeath() {
  2402. if (!player) return;
  2403.  
  2404. createParticles(this.x, this.y, 15, '#cc0000', 100, null, Math.PI * 2, {friction: 0.9, life: 0.6});
  2405.  
  2406. // Drop XP orb
  2407. xpOrbs.push(new XpOrb(this.x, this.y, this.xpValue));
  2408.  
  2409. // Drop Money
  2410. if (Math.random() < MONEY_DROP_CHANCE) {
  2411. const amount = Math.floor(randomInRange(this.moneyDropMin, this.moneyDropMax + 1)); // +1 because floor
  2412. if (amount > 0) {
  2413. moneyPickups.push(new MoneyPickup(this.x + randomInRange(-5, 5), this.y + randomInRange(-5, 5), amount));
  2414. }
  2415. }
  2416.  
  2417. // Chance to drop Ammo
  2418. if (Math.random() < AMMO_DROP_CHANCE) {
  2419. let typeToDrop = null;
  2420. const currentWeaponAmmoType = player.currentWeapon.ammoType;
  2421. if (currentWeaponAmmoType && Math.random() < 0.6) { // 60% chance to drop ammo for current weapon
  2422. typeToDrop = currentWeaponAmmoType;
  2423. } else {
  2424. const ammoTypes = Object.keys(player.ammo).filter(at => player.weapons.some(w => w.ammoType === at)); // Only drop types player actually has weapons for
  2425. if (ammoTypes.length > 0) {
  2426. typeToDrop = ammoTypes[Math.floor(Math.random() * ammoTypes.length)];
  2427. }
  2428. }
  2429.  
  2430. if (typeToDrop) {
  2431. const weaponDef = player.weapons.find(w => w.ammoType === typeToDrop);
  2432. let amount = 1;
  2433. if (weaponDef && weaponDef.clipSize) {
  2434. amount = Math.ceil(weaponDef.clipSize * randomInRange(0.10, 0.25));
  2435. } else {
  2436. amount = typeToDrop === 'shotgun' ? randomInRange(1, 3) : randomInRange(5, 15);
  2437. }
  2438. amount = Math.max(1, Math.floor(amount));
  2439. ammoPickups.push(new AmmoPickup(this.x + randomInRange(-8, 8), this.y + randomInRange(-8, 8), typeToDrop, amount));
  2440. }
  2441. }
  2442.  
  2443. // Chance to drop PowerUp
  2444. if (Math.random() < POWERUP_SPAWN_CHANCE) {
  2445. powerUps.push(PowerUp.createRandom(this.x + randomInRange(-8, 8), this.y + randomInRange(-8, 8)));
  2446. }
  2447.  
  2448. createParticles(this.x, this.y, 25, this.color, 120, null, Math.PI * 2, {friction: 0.94, life: 0.8});
  2449. createParticles(this.x, this.y, 10, 'white', 80, null, Math.PI * 2, {friction: 0.96, life: 0.5});
  2450. playSound('enemy_death', {volume: 0.5});
  2451. }
  2452. }
  2453.  
  2454. class FastEnemy extends Enemy {
  2455. constructor(x, y, wave) {
  2456. super(x, y, wave, {
  2457. type: 'fast',
  2458. baseSpeed: ENEMY_START_SPEED * 1.6, // Faster
  2459. baseHealth: ENEMY_START_HEALTH * 0.7,
  2460. baseSize: ENEMY_START_SIZE * 0.8,
  2461. color: `hsl(${randomInRange(200, 240)}, 80%, 60%)`, // Blue/Cyan hues
  2462. xpValue: XP_PER_KILL * 0.9, // Slightly less XP
  2463. baseDamage: ENEMY_BASE_DAMAGE * 0.8, // Slightly less damage
  2464. knockbackResistance: 0.1, // Slight resistance
  2465. moneyMin: Math.floor(BASE_MONEY_DROP_MIN * 0.8), // Less money
  2466. moneyMax: Math.ceil(BASE_MONEY_DROP_MAX * 0.9),
  2467. });
  2468. this.friction = 0.9; // Lower friction for faster changes
  2469. }
  2470.  
  2471. draw(ctx) { // Triangle shape pointing in movement direction
  2472. const screenPos = worldToScreen(this.x, this.y);
  2473. const s = this.size * 1.2; // Make triangle appear similar size
  2474. ctx.fillStyle = this.color;
  2475. ctx.beginPath();
  2476. let angle = Math.atan2(this.vy, this.vx);
  2477. const speedSq = this.vx * this.vx + this.vy * this.vy;
  2478. if (speedSq < 1 && this.target && this.health > 0) {
  2479. angle = Math.atan2(this.target.y - this.y, this.target.x - this.x);
  2480. }
  2481. ctx.moveTo(screenPos.x + Math.cos(angle) * s * 0.8, screenPos.y + Math.sin(angle) * s * 0.8);
  2482. 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);
  2483. 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);
  2484. ctx.closePath();
  2485. ctx.fill();
  2486. this.drawHealthBar(ctx);
  2487. }
  2488. }
  2489.  
  2490. class TankEnemy extends Enemy {
  2491. constructor(x, y, wave) {
  2492. super(x, y, wave, {
  2493. type: 'tank',
  2494. baseSpeed: ENEMY_START_SPEED * 0.55, // Slower
  2495. baseHealth: ENEMY_START_HEALTH * 2.8, // More health
  2496. baseSize: ENEMY_START_SIZE * 1.4, // Larger
  2497. color: `hsl(${randomInRange(270, 310)}, 60%, 45%)`, // Dark Purple/Magenta hues
  2498. xpValue: XP_PER_KILL * 1.8, // More XP
  2499. baseDamage: ENEMY_BASE_DAMAGE * 1.3, // More damage
  2500. knockbackResistance: 0.6, // Higher resistance
  2501. moneyMin: Math.floor(BASE_MONEY_DROP_MIN * 1.5), // More money
  2502. moneyMax: Math.ceil(BASE_MONEY_DROP_MAX * 2.0),
  2503. });
  2504. this.friction = 0.75; // Higher friction (slower to change direction)
  2505. }
  2506.  
  2507. draw(ctx) { // Hexagon shape
  2508. const screenPos = worldToScreen(this.x, this.y);
  2509. const s = this.size / 2 * 1.1; // Make hexagon slightly larger visually
  2510. ctx.fillStyle = this.color;
  2511. ctx.beginPath();
  2512. for (let i = 0; i < 6; i++) {
  2513. ctx.lineTo(screenPos.x + s * Math.cos(Math.PI / 3 * i), screenPos.y + s * Math.sin(Math.PI / 3 * i));
  2514. }
  2515. ctx.closePath();
  2516. ctx.fill();
  2517. this.drawHealthBar(ctx);
  2518. }
  2519. }
  2520.  
  2521.  
  2522. class Projectile {
  2523. constructor(x, y, vx, vy, damage, size, color, owner) {
  2524. this.x = x;
  2525. this.y = y;
  2526. this.vx = vx;
  2527. this.vy = vy;
  2528. this.damage = damage;
  2529. this.size = size;
  2530. this.color = color;
  2531. this.owner = owner; // Could be Player, Turret, or an Enemy type
  2532. this.life = 2.5;
  2533. this.creationTime = gameTime;
  2534. this.knockback = damage * 5;
  2535. }
  2536.  
  2537. draw(ctx) {
  2538. const screenPos = worldToScreen(this.x, this.y);
  2539.  
  2540. // --- Trail Effect ---
  2541. const trailLength = 3;
  2542. const maxTrailOpacity = 0.5;
  2543. for (let i = 1; i <= trailLength; i++) {
  2544. const trailRatio = i / trailLength;
  2545. const prevX = this.x - this.vx * (0.016 * trailRatio);
  2546. const prevY = this.y - this.vy * (0.016 * trailRatio);
  2547. const trailScreenPos = worldToScreen(prevX, prevY);
  2548. ctx.globalAlpha = maxTrailOpacity * (1 - trailRatio);
  2549. ctx.fillStyle = this.color;
  2550. ctx.beginPath();
  2551. ctx.arc(trailScreenPos.x, trailScreenPos.y, this.size * (1 - trailRatio * 0.5), 0, Math.PI * 2);
  2552. ctx.fill();
  2553. }
  2554. ctx.globalAlpha = 1.0;
  2555.  
  2556. // Draw main projectile body
  2557. ctx.fillStyle = this.color;
  2558. ctx.beginPath();
  2559. ctx.arc(screenPos.x, screenPos.y, this.size, 0, Math.PI * 2);
  2560. ctx.fill();
  2561.  
  2562. // Optional: Add a white core for brightness
  2563. ctx.fillStyle = 'white';
  2564. ctx.beginPath();
  2565. ctx.arc(screenPos.x, screenPos.y, this.size * 0.4, 0, Math.PI * 2);
  2566. ctx.fill();
  2567. }
  2568.  
  2569. update(dt) {
  2570. this.x += this.vx * dt;
  2571. this.y += this.vy * dt;
  2572. this.life -= dt;
  2573. }
  2574. }
  2575.  
  2576. // --- Turret ---
  2577. class Turret extends Entity {
  2578. constructor(x, y, owner) {
  2579. super(x, y, TURRET_SIZE, 'grey', TURRET_HEALTH);
  2580. this.owner = owner; // The player who owns it
  2581. this.targetEnemy = null;
  2582. this.fireCooldown = 0;
  2583. this.rangeSq = TURRET_RANGE * TURRET_RANGE; // Store squared range
  2584. this.aimAngle = 0;
  2585. this.barrelColor = '#aaa';
  2586. }
  2587.  
  2588. draw(ctx) {
  2589. const screenPos = worldToScreen(this.x, this.y);
  2590.  
  2591. // Base
  2592. ctx.fillStyle = this.color;
  2593. ctx.beginPath();
  2594. ctx.arc(screenPos.x, screenPos.y, this.size, 0, Math.PI * 2);
  2595. ctx.fill();
  2596. ctx.strokeStyle = '#555';
  2597. ctx.lineWidth = 2;
  2598. ctx.stroke();
  2599.  
  2600. // Barrel pointing towards aim angle
  2601. ctx.save();
  2602. ctx.translate(screenPos.x, screenPos.y);
  2603. ctx.rotate(this.aimAngle);
  2604. ctx.fillStyle = this.barrelColor;
  2605. ctx.fillRect(0, -2, this.size * 1.2, 4); // Barrel shape
  2606. ctx.restore();
  2607.  
  2608. // Draw health bar slightly differently for turret
  2609. this.drawHealthBar(ctx);
  2610. }
  2611.  
  2612. update(dt) {
  2613. // Smoothly follow the owner (player)
  2614. if (this.owner) {
  2615. const targetX = this.owner.x + Math.cos(this.aimAngle + Math.PI) * TURRET_FOLLOW_DISTANCE; // Try to stay behind player aiming dir
  2616. const targetY = this.owner.y + Math.sin(this.aimAngle + Math.PI) * TURRET_FOLLOW_DISTANCE;
  2617.  
  2618. // Simple lerp towards target position (could be improved with steering)
  2619. const lerpFactor = 1 - Math.pow(1 - TURRET_FOLLOW_LAG, dt * 60);
  2620. this.x += (targetX - this.x) * lerpFactor;
  2621. this.y += (targetY - this.y) * lerpFactor;
  2622. }
  2623.  
  2624.  
  2625. // Find target
  2626. this.findTarget();
  2627.  
  2628. // Aim and shoot
  2629. if (this.targetEnemy) {
  2630. // Aim smoothly towards target
  2631. const angleToTarget = Math.atan2(this.targetEnemy.y - this.y, this.targetEnemy.x - this.x);
  2632. // Smooth angle transition (lerp)
  2633. let angleDiff = angleToTarget - this.aimAngle;
  2634. while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
  2635. while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
  2636. const turnSpeed = Math.PI * 2.0 * dt; // Radians per second turn rate
  2637. this.aimAngle += Math.max(-turnSpeed, Math.min(turnSpeed, angleDiff));
  2638.  
  2639. // Shoot if cooldown ready and aimed roughly correctly
  2640. this.fireCooldown -= dt;
  2641. if (this.fireCooldown <= 0 && Math.abs(angleDiff) < 0.2) { // Aim tolerance
  2642. this.shoot();
  2643. this.fireCooldown = 1 / TURRET_FIRE_RATE;
  2644. }
  2645. } else {
  2646. // If no target, slowly rotate barrel back towards owner's aim direction? Or just stay put.
  2647. // Smoothly rotate towards owner's aim direction if no target
  2648. if (this.owner) {
  2649. let angleDiff = this.owner.aimAngle - this.aimAngle;
  2650. while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
  2651. while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
  2652. const idleTurnSpeed = Math.PI * 0.5 * dt; // Slower idle turn
  2653. this.aimAngle += Math.max(-idleTurnSpeed, Math.min(idleTurnSpeed, angleDiff));
  2654. }
  2655. }
  2656. }
  2657.  
  2658. findTarget() {
  2659. // Check if current target is still valid (alive and in range)
  2660. if (this.targetEnemy && (this.targetEnemy.health <= 0 || distanceSq(this.x, this.y, this.targetEnemy.x, this.targetEnemy.y) > this.rangeSq)) {
  2661. this.targetEnemy = null;
  2662. }
  2663.  
  2664. // If no valid target, find the closest enemy in range
  2665. if (!this.targetEnemy) {
  2666. let closestDistSq = this.rangeSq; // Start with max range squared
  2667. let potentialTarget = null;
  2668. for (const enemy of enemies) {
  2669. if (enemy.health <= 0) continue;
  2670. const distSq = distanceSq(this.x, this.y, enemy.x, enemy.y);
  2671. if (distSq < closestDistSq) {
  2672. closestDistSq = distSq;
  2673. potentialTarget = enemy;
  2674. }
  2675. }
  2676. this.targetEnemy = potentialTarget;
  2677. }
  2678. }
  2679.  
  2680. shoot() {
  2681. if (!this.targetEnemy) return;
  2682.  
  2683. const dx = Math.cos(this.aimAngle);
  2684. const dy = Math.sin(this.aimAngle);
  2685. const projX = this.x + dx * (this.size + 3); // Start slightly ahead
  2686. const projY = this.y + dy * (this.size + 3);
  2687.  
  2688. projectiles.push(new Projectile(
  2689. projX, projY,
  2690. dx * TURRET_PROJECTILE_SPEED, dy * TURRET_PROJECTILE_SPEED,
  2691. TURRET_DAMAGE,
  2692. PROJECTILE_BASE_SIZE * 0.8, '#00FFFF', this // Cyan color, owner is turret itself
  2693. ));
  2694. playSound('shoot_turret', {volume: 0.3});
  2695. // Muzzle flash
  2696. createParticles(projX, projY, 3, 'cyan', 150, this.aimAngle, Math.PI / 12, {life: 0.08, friction: 0.8});
  2697. }
  2698.  
  2699. // Override takeDamage for turrets
  2700. takeDamage(amount, source = null) {
  2701. if (this.health <= 0) return false;
  2702. const died = super.takeDamage(amount, source); // Use parent logic
  2703.  
  2704. // Play turret hit sound
  2705. playSound('hit_turret', {volume: 0.5});
  2706. // Small particle effect on hit
  2707. createParticles(this.x, this.y, 3, '#cccccc', 60, null, Math.PI * 2, {friction: 0.9, life: 0.3});
  2708.  
  2709. if (died) {
  2710. this.onDeath();
  2711. return true;
  2712. }
  2713. return false;
  2714. }
  2715.  
  2716. onDeath() {
  2717. console.log("Turret destroyed!");
  2718. playSound('turret_death');
  2719. // Big explosion effect
  2720. createParticles(this.x, this.y, 40, 'orange', 200, null, Math.PI * 2, {life: 0.8, friction: 0.9});
  2721. createParticles(this.x, this.y, 20, 'grey', 150, null, Math.PI * 2, {life: 1.0, friction: 0.92});
  2722. applyCameraShake(15, 0.2);
  2723.  
  2724. // Find the item in the shop and re-enable its button
  2725. const turretShopItem = shopItems.find(item => item.id === 'turret_1');
  2726. if (turretShopItem && turretShopItem.buttonElement) {
  2727. turretShopItem.buttonElement.disabled = false;
  2728. turretShopItem.buttonElement.textContent = `Buy ($${turretShopItem.cost})`;
  2729. }
  2730. }
  2731. }
  2732.  
  2733.  
  2734. // --- Pickups ---
  2735. let XP_ORB_MAGNET_RADIUS = 90; // Default radius
  2736.  
  2737. class XpOrb {
  2738. constructor(x, y, value) {
  2739. this.x = x;
  2740. this.y = y;
  2741. this.value = value;
  2742. this.size = XP_ORB_SIZE;
  2743. this.color = XP_ORB_COLOR;
  2744. this.magnetSpeed = 400;
  2745. this.vx = randomInRange(-30, 30);
  2746. this.vy = randomInRange(-30, 30);
  2747. this.friction = 0.9;
  2748. }
  2749.  
  2750. draw(ctx) {
  2751. const screenPos = worldToScreen(this.x, this.y);
  2752. ctx.fillStyle = this.color;
  2753. ctx.globalAlpha = 0.6 + Math.sin(gameTime * 6 + this.x) * 0.3;
  2754. ctx.beginPath();
  2755. ctx.arc(screenPos.x, screenPos.y, this.size + Math.sin(gameTime * 6 + this.x) * 1.5, 0, Math.PI * 2);
  2756. ctx.fill();
  2757. ctx.globalAlpha = 1.0;
  2758. }
  2759.  
  2760. update(dt, target) {
  2761. if (!target) return;
  2762.  
  2763. this.x += this.vx * dt;
  2764. this.y += this.vy * dt;
  2765. this.vx *= (1 - (1 - this.friction) * dt * 60);
  2766. this.vy *= (1 - (1 - this.friction) * dt * 60);
  2767.  
  2768. const magnetRadius = XP_ORB_MAGNET_RADIUS; // Use global magnet radius
  2769. const distSq = distanceSq(this.x, this.y, target.x, target.y); // Use squared distance
  2770. const magnetRadiusSq = magnetRadius * magnetRadius;
  2771.  
  2772. if (distSq < magnetRadiusSq && distSq > 1) { // Check squared distances
  2773. const dist = Math.sqrt(distSq); // Calculate actual distance only if needed
  2774. const moveDirX = (target.x - this.x) / dist;
  2775. const moveDirY = (target.y - this.y) / dist;
  2776.  
  2777. const speedFactor = Math.pow((magnetRadius - dist) / magnetRadius, 0.5) * 2.0;
  2778. const currentMagnetSpeed = this.magnetSpeed * speedFactor;
  2779.  
  2780. this.vx += (moveDirX * currentMagnetSpeed - this.vx) * 0.3;
  2781. this.vy += (moveDirY * currentMagnetSpeed - this.vy) * 0.3;
  2782. }
  2783. }
  2784. }
  2785.  
  2786. // --- Money Pickup ---
  2787. class MoneyPickup {
  2788. constructor(x, y, value) {
  2789. this.x = x;
  2790. this.y = y;
  2791. this.value = value;
  2792. // Size scales slightly with value
  2793. this.size = Math.min(MONEY_PICKUP_SIZE_MAX, MONEY_PICKUP_SIZE_MIN + Math.log10(value + 1));
  2794. this.color = MONEY_PICKUP_COLOR;
  2795. this.life = MONEY_PICKUP_LIFE;
  2796. this.vx = randomInRange(-40, 40); // Initial burst
  2797. this.vy = randomInRange(-40, 40);
  2798. }
  2799.  
  2800. draw(ctx) {
  2801. const screenPos = worldToScreen(this.x, this.y);
  2802. ctx.fillStyle = this.color;
  2803. ctx.globalAlpha = 0.7 + Math.sin(gameTime * 5 + this.y) * 0.3; // Pulsate alpha
  2804.  
  2805. // Draw shape (e.g., rotating coin '$')
  2806. ctx.save();
  2807. ctx.translate(screenPos.x, screenPos.y);
  2808. ctx.rotate(Math.sin(gameTime * 2.5 + this.x) * 0.3); // Gentle rotation
  2809.  
  2810. // Simple Rect as coin base
  2811. ctx.fillRect(-this.size * 0.6, -this.size * 0.6, this.size * 1.2, this.size * 1.2);
  2812. // Draw '$' sign
  2813. ctx.fillStyle = 'black';
  2814. ctx.font = `bold ${Math.max(6, this.size * 0.8)}px sans-serif`;
  2815. ctx.textAlign = 'center';
  2816. ctx.textBaseline = 'middle';
  2817. ctx.fillText('$', 0, 1);
  2818.  
  2819. ctx.restore();
  2820. ctx.globalAlpha = 1.0; // Reset alpha
  2821. }
  2822.  
  2823. update(dt, target) {
  2824. this.life -= dt;
  2825. if (!target) return;
  2826.  
  2827. // Apply initial velocity and friction
  2828. this.x += this.vx * dt;
  2829. this.y += this.vy * dt;
  2830. this.vx *= (1 - (1 - MONEY_PICKUP_FRICTION) * dt * 60);
  2831. this.vy *= (1 - (1 - MONEY_PICKUP_FRICTION) * dt * 60);
  2832.  
  2833. // Move towards player if close enough
  2834. const magnetRadius = MONEY_PICKUP_MAGNET_RADIUS; // Use global money magnet radius
  2835. const distSq = distanceSq(this.x, this.y, target.x, target.y);
  2836. const magnetRadiusSq = magnetRadius * magnetRadius;
  2837.  
  2838. if (distSq < magnetRadiusSq && distSq > 1) {
  2839. const dist = Math.sqrt(distSq);
  2840. const moveDirX = (target.x - this.x) / dist;
  2841. const moveDirY = (target.y - this.y) / dist;
  2842. const speedFactor = Math.pow((magnetRadius - dist) / magnetRadius, 0.6) * 2.5; // Slightly stronger pull?
  2843. const currentMagnetSpeed = MONEY_PICKUP_MAGNET_SPEED * speedFactor;
  2844.  
  2845. this.vx += (moveDirX * currentMagnetSpeed - this.vx) * 0.35; // Faster lerp?
  2846. this.vy += (moveDirY * currentMagnetSpeed - this.vy) * 0.35;
  2847. }
  2848. }
  2849. }
  2850.  
  2851.  
  2852. class AmmoPickup {
  2853. constructor(x, y, ammoType, amount) {
  2854. this.x = x;
  2855. this.y = y;
  2856. this.ammoType = ammoType;
  2857. this.amount = amount;
  2858. this.size = AMMO_PICKUP_SIZE + Math.log10(amount + 1) * 2;
  2859. this.color = AMMO_PICKUP_COLOR;
  2860. this.life = 25;
  2861. }
  2862.  
  2863. draw(ctx) {
  2864. const screenPos = worldToScreen(this.x, this.y);
  2865. ctx.fillStyle = this.color;
  2866. ctx.globalAlpha = 0.7 + Math.sin(gameTime * 4 + this.y) * 0.3;
  2867. ctx.save();
  2868. ctx.translate(screenPos.x, screenPos.y);
  2869. ctx.rotate(Math.sin(gameTime * 2 + this.x) * 0.2);
  2870. ctx.fillRect(-this.size / 2, -this.size / 2, this.size, this.size);
  2871. ctx.restore();
  2872.  
  2873. ctx.fillStyle = 'black';
  2874. ctx.font = `bold ${Math.max(7, this.size * 0.6)}px sans-serif`;
  2875. ctx.textAlign = 'center';
  2876. ctx.textBaseline = 'middle';
  2877. let ammoInitial = this.ammoType.substring(0, 1).toUpperCase();
  2878. if (this.ammoType === 'shotgun') ammoInitial = 'SG';
  2879. if (this.ammoType === 'smg') ammoInitial = 'SM';
  2880. ctx.fillText(ammoInitial, screenPos.x, screenPos.y + 1);
  2881.  
  2882. ctx.globalAlpha = 1.0;
  2883. }
  2884.  
  2885. update(dt) {
  2886. this.life -= dt;
  2887. }
  2888. }
  2889.  
  2890. class PowerUp {
  2891. constructor(x, y, type, duration, color, symbol) {
  2892. this.x = x;
  2893. this.y = y;
  2894. this.type = type;
  2895. this.duration = duration;
  2896. this.size = POWERUP_SIZE;
  2897. this.color = color;
  2898. this.symbol = symbol;
  2899. this.life = 30;
  2900. this.angle = Math.random() * Math.PI * 2;
  2901. }
  2902.  
  2903. draw(ctx) {
  2904. const screenPos = worldToScreen(this.x, this.y);
  2905.  
  2906. ctx.save();
  2907. ctx.translate(screenPos.x, screenPos.y);
  2908. ctx.rotate(this.angle);
  2909. ctx.globalAlpha = 0.7 + Math.sin(gameTime * 5 + this.x) * 0.3;
  2910.  
  2911. ctx.fillStyle = this.color;
  2912. ctx.beginPath();
  2913. const outerRadius = this.size * (1.1 + Math.sin(gameTime * 5 + this.x) * 0.1);
  2914. const innerRadius = outerRadius * 0.5;
  2915. ctx.moveTo(0, -outerRadius);
  2916. for (let i = 0; i < 5; i++) {
  2917. const outerAngle = Math.PI / 5 * (i * 2) - Math.PI / 2;
  2918. const innerAngle = outerAngle + Math.PI / 5;
  2919. ctx.lineTo(Math.cos(outerAngle) * outerRadius, Math.sin(outerAngle) * outerRadius);
  2920. ctx.lineTo(Math.cos(innerAngle) * innerRadius, Math.sin(innerAngle) * innerRadius);
  2921. }
  2922. ctx.closePath();
  2923. ctx.fill();
  2924.  
  2925. ctx.strokeStyle = 'white';
  2926. ctx.lineWidth = 1.5;
  2927. ctx.stroke();
  2928.  
  2929. ctx.fillStyle = 'white';
  2930. ctx.font = `bold ${Math.max(9, this.size)}px sans-serif`;
  2931. ctx.textAlign = 'center';
  2932. ctx.textBaseline = 'middle';
  2933. ctx.shadowColor = 'black';
  2934. ctx.shadowBlur = 4;
  2935. ctx.fillText(this.symbol, 0, 1);
  2936. ctx.shadowBlur = 0;
  2937.  
  2938. ctx.restore();
  2939. ctx.globalAlpha = 1.0;
  2940. }
  2941.  
  2942. update(dt) {
  2943. this.life -= dt;
  2944. this.angle += dt * 1.5;
  2945. }
  2946.  
  2947. static createRandom(x, y) {
  2948. const types = [
  2949. {type: 'invincibility', duration: 5, color: '#ccccff', symbol: '★'},
  2950. {type: 'doubleDamage', duration: 10, color: '#ff6666', symbol: '2x'},
  2951. {type: 'speedBoost', duration: 10, color: '#87CEEB', symbol: '>>'},
  2952. {type: 'rapidFire', duration: 8, color: '#FFD700', symbol: 'RF'},
  2953. {type: 'xpMultiplier', duration: 15, color: '#90EE90', symbol: 'XP'},
  2954. {type: 'magnetRadius', duration: 20, color: '#DA70D6', symbol: 'M'},
  2955. {type: 'shieldOrbs', duration: 12, color: '#64C8FF', symbol: 'O'} // Added Shield Orbs
  2956. ];
  2957. const choice = types[Math.floor(Math.random() * types.length)];
  2958. return new PowerUp(x, y, choice.type, choice.duration, choice.color, choice.symbol);
  2959. }
  2960. }
  2961.  
  2962.  
  2963. class Particle {
  2964. constructor(x, y, vx, vy, size, color, life, options = {}) {
  2965. this.x = x;
  2966. this.y = y;
  2967. this.vx = vx;
  2968. this.vy = vy;
  2969. this.size = size;
  2970. this.color = color;
  2971. this.life = life;
  2972. this.initialLife = life;
  2973. this.friction = options.friction !== undefined ? options.friction : 0.98;
  2974. this.gravity = options.gravity || 0;
  2975. this.alpha = 1.0;
  2976. this.rotation = Math.random() * Math.PI * 2;
  2977. this.rotationSpeed = (Math.random() - 0.5) * 5;
  2978. }
  2979.  
  2980. draw(ctx) {
  2981. const screenPos = worldToScreen(this.x, this.y);
  2982. const lifeRatio = Math.max(0, this.life / this.initialLife);
  2983. this.alpha = lifeRatio;
  2984.  
  2985. ctx.save();
  2986. ctx.translate(screenPos.x, screenPos.y);
  2987. ctx.rotate(this.rotation);
  2988.  
  2989. ctx.globalAlpha = this.alpha;
  2990. ctx.fillStyle = this.color;
  2991. ctx.beginPath();
  2992. const currentSize = Math.max(0.1, this.size * lifeRatio);
  2993. ctx.arc(0, 0, currentSize, 0, Math.PI * 2);
  2994. ctx.fill();
  2995.  
  2996. ctx.restore();
  2997. ctx.globalAlpha = 1.0;
  2998. }
  2999.  
  3000. update(dt) {
  3001. this.x += this.vx * dt;
  3002. this.y += this.vy * dt;
  3003. this.vy += this.gravity * dt;
  3004. this.vx *= (1 - (1 - this.friction) * dt * 60);
  3005. this.vy *= (1 - (1 - this.friction) * dt * 60);
  3006. this.life -= dt;
  3007. this.rotation += this.rotationSpeed * dt;
  3008. }
  3009. }
  3010.  
  3011.  
  3012. // --- Floating Damage Text ---
  3013. // Added entitySize parameter (with a default fallback)
  3014. function createDamageText(x, y, text, color = 'white', entitySize = 20) {
  3015. const textElement = document.createElement('div');
  3016. textElement.classList.add('damage-text');
  3017. textElement.textContent = text;
  3018. textElement.style.color = color;
  3019. const baseSize = 15;
  3020. const damageScaleFactor = 1.8;
  3021. textElement.style.fontSize = `${baseSize + Math.log10(parseFloat(text) + 1) * damageScaleFactor}px`;
  3022.  
  3023. const screenPos = worldToScreen(x, y);
  3024.  
  3025. // --- ADJUSTED VERTICAL OFFSET ---
  3026. // Position above the entity's estimated top edge + a small gap (15px)
  3027. // Use entitySize * 0.6 as a rough estimate for radius/half-height across different shapes
  3028. const verticalOffset = -(entitySize * 0.6 + 15);
  3029. // --- END ADJUSTMENT ---
  3030.  
  3031. textElement.style.left = `${screenPos.x}px`;
  3032. textElement.style.top = `${screenPos.y + verticalOffset}px`; // Use the calculated offset
  3033. textElement.style.transform = 'translateX(-50%) translateY(0px)'; // Center horizontally, start at calculated top
  3034. textElement.style.opacity = '1';
  3035.  
  3036. damageTextContainer.appendChild(textElement);
  3037.  
  3038. const damageTextObject = {
  3039. element: textElement,
  3040. creationTime: gameTime,
  3041. duration: DAMAGE_TEXT_DURATION
  3042. };
  3043. damageTexts.push(damageTextObject);
  3044.  
  3045. // --- Animation: Use requestAnimationFrame for smoother start ---
  3046. requestAnimationFrame(() => { // Wait for element to be added to DOM
  3047. requestAnimationFrame(() => { // Wait one more frame for styles to apply
  3048. const upwardMovement = DAMAGE_TEXT_SPEED * DAMAGE_TEXT_DURATION;
  3049. textElement.style.opacity = '0';
  3050. // Translate Y moves *upwards* from the initial position
  3051. textElement.style.transform = `translateX(-50%) translateY(-${upwardMovement}px)`;
  3052. });
  3053. });
  3054. // --- End Animation Change ---
  3055.  
  3056.  
  3057. // Cleanup timeout remains the same
  3058. setTimeout(() => {
  3059. if (textElement.parentNode === damageTextContainer) {
  3060. damageTextContainer.removeChild(textElement);
  3061. }
  3062. const index = damageTexts.indexOf(damageTextObject);
  3063. if (index > -1) {
  3064. damageTexts.splice(index, 1);
  3065. }
  3066. }, DAMAGE_TEXT_DURATION * 1000 + 100); // Add small buffer to timeout
  3067. }
  3068.  
  3069. function cleanupDamageTexts() {
  3070. const now = gameTime;
  3071. damageTexts = damageTexts.filter(dt => (now - dt.creationTime) < dt.duration + 0.2);
  3072. }
  3073.  
  3074.  
  3075. // --- Shop Items Definition ---
  3076. let shopItems = []; // Will be populated in setupShop
  3077.  
  3078. function setupShop() {
  3079. shopItemsContainer.innerHTML = ''; // Clear existing items
  3080. shopItems = [
  3081. // Ammo
  3082. {
  3083. id: 'ammo_pistol_small',
  3084. name: 'Pistol Rounds (x20)',
  3085. cost: 25,
  3086. description: 'A small pack of pistol ammo.',
  3087. effect: {type: 'ammo', ammoType: 'pistol', amount: 20}
  3088. },
  3089. {
  3090. id: 'ammo_pistol_large',
  3091. name: 'Pistol Rounds (x50)',
  3092. cost: 50,
  3093. description: 'A box of pistol ammo.',
  3094. effect: {type: 'ammo', ammoType: 'pistol', amount: 50}
  3095. },
  3096. {
  3097. id: 'ammo_smg_small',
  3098. name: 'SMG Mags (x60)',
  3099. cost: 60,
  3100. description: 'A couple of SMG magazines.',
  3101. effect: {type: 'ammo', ammoType: 'smg', amount: 60}
  3102. },
  3103. {
  3104. id: 'ammo_smg_large',
  3105. name: 'SMG Mags (x150)',
  3106. cost: 120,
  3107. description: 'An ammo can of SMG rounds.',
  3108. effect: {type: 'ammo', ammoType: 'smg', amount: 150}
  3109. },
  3110. {
  3111. id: 'ammo_shotgun_small',
  3112. name: 'Shotgun Shells (x10)',
  3113. cost: 40,
  3114. description: 'A bandolier of shells.',
  3115. effect: {type: 'ammo', ammoType: 'shotgun', amount: 10}
  3116. },
  3117. {
  3118. id: 'ammo_shotgun_large',
  3119. name: 'Shotgun Shells (x25)',
  3120. cost: 85,
  3121. description: 'A case of shotgun shells.',
  3122. effect: {type: 'ammo', ammoType: 'shotgun', amount: 25}
  3123. },
  3124. // Upgrades
  3125. {
  3126. id: 'turret_1',
  3127. name: 'Deployable Turret',
  3128. cost: TURRET_COST,
  3129. description: 'Places an automated turret that follows and shoots enemies. (Max 1)',
  3130. effect: {type: 'turret'}
  3131. },
  3132. // Add more unique items: Health pack? Temporary buff? New weapon?
  3133. ];
  3134.  
  3135. shopItems.forEach(item => {
  3136. const itemDiv = document.createElement('div');
  3137. itemDiv.classList.add('shop-item');
  3138.  
  3139. const infoDiv = document.createElement('div');
  3140. infoDiv.classList.add('shop-item-info');
  3141.  
  3142. const nameSpan = document.createElement('span');
  3143. nameSpan.classList.add('shop-item-name');
  3144. nameSpan.textContent = item.name;
  3145. infoDiv.appendChild(nameSpan);
  3146.  
  3147. const descSpan = document.createElement('span');
  3148. descSpan.classList.add('shop-item-desc');
  3149. descSpan.textContent = ` ${item.description}`; // Add space before desc
  3150. infoDiv.appendChild(descSpan);
  3151.  
  3152. const buyButton = document.createElement('button');
  3153. buyButton.classList.add('shop-buy-button');
  3154. buyButton.dataset.itemId = item.id; // Store item ID for lookup
  3155. buyButton.textContent = `Buy ($${item.cost})`;
  3156. // Store reference to button element on the item object for later disabling
  3157. item.buttonElement = buyButton;
  3158.  
  3159. buyButton.addEventListener('click', () => {
  3160. if (player) {
  3161. player.buyItem(item); // Pass the whole item object
  3162. }
  3163. });
  3164.  
  3165. itemDiv.appendChild(infoDiv);
  3166. itemDiv.appendChild(buyButton);
  3167. shopItemsContainer.appendChild(itemDiv);
  3168. });
  3169.  
  3170. updateShopItemStates(); // Set initial button states
  3171. }
  3172.  
  3173. function updateShopItemStates() {
  3174. if (!player) return;
  3175.  
  3176. shopItems.forEach(item => {
  3177. if (!item.buttonElement) return; // Skip if button not found
  3178.  
  3179. let canAfford = player.money >= item.cost;
  3180. let owned = false;
  3181.  
  3182. // Check if turret is already owned
  3183. if (item.id === 'turret_1' && turrets.some(t => t.owner === player)) {
  3184. owned = true;
  3185. canAfford = false; // Can't buy if owned
  3186. }
  3187.  
  3188. // Check if ammo is full for this type
  3189. if (item.effect.type === 'ammo') {
  3190. const ammoType = item.effect.ammoType;
  3191. const weaponDef = player.weapons.find(w => w.ammoType === ammoType);
  3192. const maxReserve = weaponDef ? weaponDef.maxAmmo : Infinity;
  3193. if ((player.ammo[ammoType] || 0) >= maxReserve) {
  3194. canAfford = false; // Can't buy if full
  3195. item.buttonElement.textContent = 'Full';
  3196. } else {
  3197. item.buttonElement.textContent = `Buy ($${item.cost})`; // Reset text if not full
  3198. }
  3199. }
  3200.  
  3201.  
  3202. item.buttonElement.disabled = !canAfford;
  3203.  
  3204. if (owned) {
  3205. item.buttonElement.textContent = 'Owned';
  3206. item.buttonElement.disabled = true;
  3207. } else if (!canAfford && item.effect.type !== 'ammo') { // Keep "Full" text for ammo
  3208. item.buttonElement.textContent = `Buy ($${item.cost})`; // Reset text if just can't afford non-ammo
  3209. }
  3210.  
  3211.  
  3212. });
  3213. }
  3214.  
  3215. function toggleShop() {
  3216. isShopOpen = !isShopOpen;
  3217. shopScreen.style.display = isShopOpen ? 'flex' : 'none';
  3218. if (isShopOpen) {
  3219. updateShopItemStates(); // Update states when opening
  3220. // Don't pause game, maybe give player slight defense buff while shopping? Or just risk it.
  3221. // Close level up options if open
  3222. if (isShowingLevelUpOptions) {
  3223. levelUpOptionsDiv.style.display = 'none';
  3224. isShowingLevelUpOptions = false;
  3225. }
  3226. } else {
  3227. // Focus back on canvas? Not strictly necessary.
  3228. }
  3229. }
  3230.  
  3231.  
  3232. // --- Game Logic ---
  3233. function init() {
  3234. console.log("Initializing game...");
  3235. isGameOver = false;
  3236. gameContainerDiv.style.display = 'block'; // Show game area
  3237. colorSelectionDiv.style.display = 'none'; // Hide color selection
  3238. isShopOpen = false; // Ensure shop starts closed
  3239. shopScreen.style.display = 'none';
  3240.  
  3241. const selectedButton = colorSelectionDiv.querySelector('button.selected');
  3242. PLAYER_COLOR = selectedButton ? selectedButton.dataset.color : 'blue';
  3243.  
  3244. canvas.width = window.innerWidth * 0.9;
  3245. canvas.height = window.innerHeight * 0.9;
  3246. damageTextContainer.style.width = `${canvas.width}px`;
  3247. damageTextContainer.style.height = `${canvas.height}px`;
  3248.  
  3249. if (typeof Noise === 'undefined') {
  3250. console.warn("Noise library not found. Background will be simple grid.");
  3251. noise = null;
  3252. } else {
  3253. noise = new Noise(Math.random());
  3254. console.log("Noise library initialized for background.");
  3255. }
  3256.  
  3257. initAudio(); // Initialize Web Audio API context
  3258.  
  3259. player = new Player(0, 0, PLAYER_COLOR);
  3260. camera.x = player.x;
  3261. camera.y = player.y;
  3262. camera.shakeIntensity = 0;
  3263. camera.shakeDuration = 0;
  3264.  
  3265. enemies = [];
  3266. projectiles = [];
  3267. xpOrbs = [];
  3268. moneyPickups = [];
  3269. ammoPickups = [];
  3270. powerUps = [];
  3271. particles = [];
  3272. turrets = []; // Reset turrets
  3273. damageTexts = [];
  3274. damageTextContainer.innerHTML = '';
  3275.  
  3276. keys = {};
  3277. mouse = {x: 0, y: 0, down: false};
  3278. currentWave = 1;
  3279. gameTime = 0;
  3280. pendingLevelUps = 0;
  3281. isShowingLevelUpOptions = false;
  3282. levelUpOptionsDiv.style.display = 'none';
  3283. levelUpIndicator.style.display = 'none';
  3284. reloadIndicatorDiv.style.display = 'none';
  3285.  
  3286.  
  3287. // Remove old listeners before adding new ones
  3288. window.removeEventListener('keydown', handleKeyDown);
  3289. window.removeEventListener('keyup', handleKeyUp);
  3290. window.removeEventListener('mousedown', handleMouseDown);
  3291. window.removeEventListener('mouseup', handleMouseUp);
  3292. window.removeEventListener('mousemove', handleMouseMove);
  3293. window.removeEventListener('resize', handleResize);
  3294. levelUpIndicator.removeEventListener('click', toggleLevelUpOptions);
  3295. shopButton.removeEventListener('click', toggleShop);
  3296. shopCloseButton.removeEventListener('click', toggleShop);
  3297.  
  3298.  
  3299. // Add Event Listeners
  3300. window.addEventListener('keydown', handleKeyDown);
  3301. window.addEventListener('keyup', handleKeyUp);
  3302. window.addEventListener('mousedown', handleMouseDown);
  3303. window.addEventListener('mouseup', handleMouseUp);
  3304. window.addEventListener('mousemove', handleMouseMove);
  3305. window.addEventListener('resize', handleResize);
  3306. levelUpIndicator.addEventListener('click', toggleLevelUpOptions);
  3307. shopButton.addEventListener('click', toggleShop); // Shop button listener
  3308. shopCloseButton.addEventListener('click', toggleShop); // Shop close button listener
  3309.  
  3310. setupShop(); // Populate shop items
  3311. startWave();
  3312. lastTime = performance.now();
  3313. updateUI(); // Initial UI update
  3314. if (animationFrameId) { // Clear previous loop if any
  3315. cancelAnimationFrame(animationFrameId);
  3316. }
  3317. animationFrameId = requestAnimationFrame(gameLoop); // Start new loop
  3318. console.log("Game initialized and loop started.");
  3319. }
  3320.  
  3321. // --- Event Handlers ---
  3322. function handleKeyDown(e) {
  3323. const key = e.key.toLowerCase();
  3324. keys[key] = true;
  3325.  
  3326. // Prevent browser default actions for game keys
  3327. if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' ', SPRINT_KEY, RELOAD_KEY, SHOP_KEY].includes(key) || (key >= '0' && key <= '9')) {
  3328. e.preventDefault();
  3329. }
  3330.  
  3331. // Toggle shop with keybind (only if not game over)
  3332. if (!isGameOver && key === SHOP_KEY) {
  3333. toggleShop();
  3334. keys[key] = false; // Consume the shop key press immediately
  3335. }
  3336. }
  3337.  
  3338. function handleKeyUp(e) {
  3339. const key = e.key.toLowerCase();
  3340. keys[key] = false;
  3341.  
  3342. // If shift is released, explicitly stop sprinting
  3343. if (key === SPRINT_KEY && player) {
  3344. player.isSprinting = false;
  3345. }
  3346. }
  3347.  
  3348. function handleMouseDown(e) {
  3349. // Only register left clicks on canvas, and only if shop is NOT open
  3350. if (e.target === canvas && e.button === 0 && !isShopOpen) {
  3351. mouse.down = true;
  3352. } else if (isShopOpen && !shopScreen.contains(e.target) && e.target !== shopButton) {
  3353. // If shop is open and click is outside shop (and not on the shop button itself), close shop? Optional behavior.
  3354. // toggleShop();
  3355. }
  3356. }
  3357.  
  3358. function handleMouseUp(e) {
  3359. if (e.button === 0) mouse.down = false; // Release mouse down regardless of target
  3360. }
  3361.  
  3362. function handleMouseMove(e) {
  3363. const rect = canvas.getBoundingClientRect();
  3364. mouse.x = e.clientX - rect.left;
  3365. mouse.y = e.clientY - rect.top;
  3366. }
  3367.  
  3368. function handleResize() {
  3369. const oldWidth = canvas.width;
  3370. const oldHeight = canvas.height;
  3371. canvas.width = window.innerWidth * 0.9;
  3372. canvas.height = window.innerHeight * 0.9;
  3373.  
  3374. damageTextContainer.style.width = `${canvas.width}px`;
  3375. damageTextContainer.style.height = `${canvas.height}px`;
  3376. const canvasRect = canvas.getBoundingClientRect();
  3377. damageTextContainer.style.top = canvasRect.top + 'px';
  3378. damageTextContainer.style.left = canvasRect.left + 'px';
  3379.  
  3380. console.log(`Resized canvas to ${canvas.width}x${canvas.height}`);
  3381. }
  3382.  
  3383.  
  3384. function toggleLevelUpOptions() {
  3385. if (isShopOpen) return; // Don't allow level up interaction if shop is open
  3386.  
  3387. if (isShowingLevelUpOptions) {
  3388. levelUpOptionsDiv.style.display = 'none';
  3389. isShowingLevelUpOptions = false;
  3390. } else if (pendingLevelUps > 0) {
  3391. presentLevelUpOptions(); // Call presentation logic if hidden and pending
  3392. }
  3393. }
  3394.  
  3395. function setupColorSelection() {
  3396. gameContainerDiv.style.display = 'none'; // Ensure game hidden initially
  3397. colorSelectionDiv.style.display = 'block'; // Ensure selection visible
  3398. isShopOpen = false; // Ensure shop closed
  3399. shopScreen.style.display = 'none';
  3400.  
  3401. colorSelectionDiv.querySelectorAll('button[data-color]').forEach(button => {
  3402. button.style.backgroundColor = button.dataset.color;
  3403. const bgColor = button.style.backgroundColor;
  3404. let luminance = 150;
  3405. try {
  3406. // Try to calculate luminance to set text color automatically
  3407. let r, g, b;
  3408. const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(bgColor); // Check hex
  3409. const resultRGB = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(bgColor); // Check rgb()
  3410.  
  3411. if (result) {
  3412. r = parseInt(result[1], 16);
  3413. g = parseInt(result[2], 16);
  3414. b = parseInt(result[3], 16);
  3415. } else if (resultRGB) {
  3416. r = parseInt(resultRGB[1]);
  3417. g = parseInt(resultRGB[2]);
  3418. b = parseInt(resultRGB[3]);
  3419. } else { // Attempt computed style for color names
  3420. const tempElem = document.createElement('div');
  3421. tempElem.style.color = bgColor;
  3422. document.body.appendChild(tempElem);
  3423. const computedColor = window.getComputedStyle(tempElem).color;
  3424. document.body.removeChild(tempElem);
  3425. const rgbMatch = computedColor.match(/\d+/g);
  3426. if (rgbMatch && rgbMatch.length >= 3) {
  3427. r = parseInt(rgbMatch[0]);
  3428. g = parseInt(rgbMatch[1]);
  3429. b = parseInt(rgbMatch[2]);
  3430. }
  3431. }
  3432. if (typeof r !== 'undefined') {
  3433. luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
  3434. }
  3435.  
  3436. } catch (e) {
  3437. console.warn("Could not parse button background color:", bgColor, e);
  3438. }
  3439. button.style.color = luminance > 128 ? 'black' : 'white'; // Set text color based on brightness
  3440.  
  3441. button.addEventListener('click', () => {
  3442. colorSelectionDiv.querySelector('button.selected')?.classList.remove('selected');
  3443. button.classList.add('selected');
  3444. });
  3445. });
  3446. let startButton = colorSelectionDiv.querySelector('#start-game-button');
  3447. if (!startButton) {
  3448. startButton = document.createElement('button');
  3449. startButton.id = 'start-game-button';
  3450. startButton.textContent = 'Start Game';
  3451. startButton.style.marginTop = '20px';
  3452. startButton.style.padding = '12px 25px';
  3453. startButton.style.fontSize = '1.1em';
  3454. startButton.style.cursor = 'pointer';
  3455. startButton.style.backgroundColor = '#4CAF50';
  3456. startButton.style.color = 'white';
  3457. startButton.style.border = 'none';
  3458. startButton.style.borderRadius = '5px';
  3459. startButton.onclick = init;
  3460. colorSelectionDiv.appendChild(startButton);
  3461. } else {
  3462. startButton.onclick = init;
  3463. }
  3464. }
  3465.  
  3466.  
  3467. function startWave() {
  3468. enemiesToSpawn = 5 + Math.floor(currentWave * 2.2); // Slightly more enemies per wave, steeper increase
  3469. enemiesAlive = enemies.length;
  3470. spawningDone = false;
  3471. timeToNextSpawn = 2.0; // Longer initial delay
  3472. console.log(`Starting Wave ${currentWave} with ${enemiesToSpawn} enemies.`);
  3473. playSound('wave_start');
  3474. updateUI();
  3475. }
  3476.  
  3477. function spawnEnemy() {
  3478. if (enemiesToSpawn <= 0) {
  3479. spawningDone = true;
  3480. return;
  3481. }
  3482. if (!player) return;
  3483.  
  3484. const spawnDist = Math.max(canvas.width, canvas.height) * 0.7;
  3485. const angle = Math.random() * Math.PI * 2;
  3486. const safeRadius = 200;
  3487.  
  3488. let spawnXBase = player.x + Math.cos(angle) * spawnDist;
  3489. let spawnYBase = player.y + Math.sin(angle) * spawnDist;
  3490.  
  3491. const dxFromPlayer = spawnXBase - player.x;
  3492. const dyFromPlayer = spawnYBase - player.y;
  3493. // Ensure currentDist is not zero before dividing
  3494. let currentDist = Math.sqrt(dxFromPlayer * dxFromPlayer + dyFromPlayer * dyFromPlayer);
  3495. if (currentDist < 0.1) currentDist = 0.1; // Prevent division by zero
  3496.  
  3497. const dirX = dxFromPlayer / currentDist;
  3498. const dirY = dyFromPlayer / currentDist;
  3499. const finalDist = Math.max(spawnDist, currentDist + safeRadius);
  3500.  
  3501. const spawnX = player.x + dirX * finalDist;
  3502. const spawnY = player.y + dirY * finalDist;
  3503.  
  3504. let enemyTypeChoice = Math.random();
  3505. let newEnemy;
  3506. const waveFactor = currentWave / 10; // Simple factor for adjusting probability
  3507.  
  3508. if (currentWave >= 4 && enemyTypeChoice < 0.15 + waveFactor * 0.1) {
  3509. newEnemy = new TankEnemy(spawnX, spawnY, currentWave);
  3510. } else if (currentWave >= 2 && enemyTypeChoice < 0.4 + waveFactor * 0.15) {
  3511. newEnemy = new FastEnemy(spawnX, spawnY, currentWave);
  3512. } else {
  3513. newEnemy = new Enemy(spawnX, spawnY, currentWave);
  3514. }
  3515. enemies.push(newEnemy);
  3516.  
  3517. enemiesToSpawn--;
  3518.  
  3519. updateUI();
  3520.  
  3521. timeToNextSpawn = Math.max(0.05, 1.1 - currentWave * 0.04); // Reduced min spawn time slightly
  3522. }
  3523.  
  3524. function update(dt) {
  3525. // Skip most updates if shop is open (except things that should continue like timers)
  3526. // Player input and attacks are blocked elsewhere.
  3527. // Enemies, projectiles, pickups should still update.
  3528.  
  3529. gameTime += dt;
  3530.  
  3531. // Update Camera Shake (always update)
  3532. if (camera.shakeDuration > 0) {
  3533. camera.shakeDuration -= dt;
  3534. const decayFactor = Math.pow(CAMERA_SHAKE_DECAY, dt * 60);
  3535. camera.shakeIntensity *= decayFactor;
  3536.  
  3537. if (camera.shakeDuration <= 0 || camera.shakeIntensity < 0.1) {
  3538. camera.shakeIntensity = 0;
  3539. camera.shakeDuration = 0;
  3540. }
  3541. }
  3542.  
  3543. if (!player || isGameOver) return; // Stop if no player or game over
  3544.  
  3545. // Update Player (Handles movement, sprinting, powerups, aiming, reloading, melee anim)
  3546. player.update(dt);
  3547.  
  3548. // Update Camera to follow player smoothly (always update if player exists)
  3549. const cameraLag = 0.08;
  3550. const lerpFactor = 1 - Math.pow(1 - cameraLag, dt * 60);
  3551. camera.x += (player.x - camera.x) * lerpFactor;
  3552. camera.y += (player.y - camera.y) * lerpFactor;
  3553.  
  3554.  
  3555. // --- Updates that run even if shop is open ---
  3556. enemies.forEach(enemy => enemy.update(dt, player));
  3557. projectiles.forEach(p => p.update(dt));
  3558. xpOrbs.forEach(orb => orb.update(dt, player));
  3559. moneyPickups.forEach(mp => mp.update(dt, player)); // Update money pickups
  3560. ammoPickups.forEach(ap => ap.update(dt));
  3561. powerUps.forEach(pu => pu.update(dt));
  3562. particles.forEach(p => p.update(dt));
  3563. turrets.forEach(t => t.update(dt)); // Update turrets
  3564.  
  3565.  
  3566. // Spawning (runs if not paused by shop)
  3567. if (!spawningDone && !isShopOpen) {
  3568. timeToNextSpawn -= dt;
  3569. if (timeToNextSpawn <= 0) {
  3570. spawnEnemy();
  3571. }
  3572. }
  3573.  
  3574.  
  3575. // --- Cleanup Dead/Expired Entities ---
  3576. projectiles = projectiles.filter(p => p.life > 0);
  3577. enemiesAlive = enemies.filter(e => e.health > 0).length;
  3578. enemies = enemies.filter(e => e.health > 0);
  3579. turrets = turrets.filter(t => t.health > 0); // Remove destroyed turrets
  3580.  
  3581. // Cleanup pickups (XP)
  3582. xpOrbs = xpOrbs.filter(orb => {
  3583. const collectionRadius = player.size + orb.size / 2 + XP_ORB_MAGNET_RADIUS * 0.1;
  3584. const distSq = distanceSq(player.x, player.y, orb.x, orb.y); // Use squared distance
  3585. const collected = distSq < collectionRadius * collectionRadius;
  3586.  
  3587. if (collected) {
  3588. player.addXp(orb.value);
  3589. playSound('pickup_xp', {volume: 0.3});
  3590. createParticles(player.x, player.y, 3, XP_ORB_COLOR, 50);
  3591. return false;
  3592. }
  3593. return true;
  3594. });
  3595.  
  3596. // Cleanup pickups (Money)
  3597. moneyPickups = moneyPickups.filter(mp => {
  3598. if (mp.life <= 0) return false;
  3599. const collectionRadius = player.size + mp.size / 2 + MONEY_PICKUP_MAGNET_RADIUS * 0.1;
  3600. const distSq = distanceSq(player.x, player.y, mp.x, mp.y);
  3601. const collected = distSq < collectionRadius * collectionRadius;
  3602.  
  3603. if (collected) {
  3604. player.addMoney(mp.value); // Add money to player
  3605. playSound('pickup_money', {volume: 0.4});
  3606. createParticles(player.x, player.y, 4, MONEY_PICKUP_COLOR, 60, null, null, {life: 0.4}); // Gold particles
  3607. return false; // Remove the pickup
  3608. }
  3609. return true; // Keep the pickup
  3610. });
  3611.  
  3612.  
  3613. ammoPickups = ammoPickups.filter(ap => {
  3614. if (ap.life <= 0) return false;
  3615. const collected = distanceSq(player.x, player.y, ap.x, ap.y) < (player.size + ap.size / 2 + 5) ** 2; // Squared dist
  3616. if (collected) {
  3617. handleAmmoPickup(ap);
  3618. return false;
  3619. }
  3620. return true;
  3621. });
  3622.  
  3623. powerUps = powerUps.filter(pu => {
  3624. if (pu.life <= 0) return false;
  3625. const collected = distanceSq(player.x, player.y, pu.x, pu.y) < (player.size + pu.size) ** 2; // Squared dist
  3626. if (collected) {
  3627. player.activatePowerUp(pu.type, pu.duration);
  3628. createParticles(player.x, player.y, 20, pu.color, 150, null, Math.PI * 2, {life: 0.6, friction: 0.96});
  3629. return false;
  3630. }
  3631. return true;
  3632. });
  3633.  
  3634. particles = particles.filter(p => p.life > 0);
  3635. if (particles.length > MAX_PARTICLES) {
  3636. particles.splice(0, particles.length - MAX_PARTICLES);
  3637. }
  3638.  
  3639. // Collisions
  3640. checkCollisions(dt);
  3641.  
  3642. // Check for next wave
  3643. if (player && spawningDone && enemiesAlive <= 0 && !isShopOpen) { // Don't start wave if shop is open
  3644. currentWave++;
  3645. startWave();
  3646. player.health = Math.min(player.maxHealth, player.health + player.maxHealth * 0.1); // Heal 10%
  3647. Object.keys(player.ammo).forEach(ammoType => {
  3648. const weaponDef = player.weapons.find(w => w.ammoType === ammoType);
  3649. if (weaponDef && weaponDef.maxAmmo) {
  3650. const bonus = Math.ceil(weaponDef.maxAmmo * 0.05);
  3651. player.ammo[ammoType] = Math.min(weaponDef.maxAmmo, (player.ammo[ammoType] || 0) + bonus);
  3652. }
  3653. });
  3654. updateUI();
  3655. }
  3656. }
  3657.  
  3658. function handleAmmoPickup(pickup) {
  3659. if (!player) return;
  3660.  
  3661. const weapon = player.weapons.find(w => w.ammoType === pickup.ammoType);
  3662. let ammoAdded = 0;
  3663. const currentReserve = player.ammo[pickup.ammoType] || 0;
  3664.  
  3665. // Check weapon definition for max ammo limit
  3666. const maxReserve = (weapon && typeof weapon.maxAmmo !== 'undefined') ? weapon.maxAmmo : Infinity; // Assume infinite if no definition
  3667.  
  3668. ammoAdded = Math.min(pickup.amount, maxReserve - currentReserve); // Don't exceed max reserve
  3669. ammoAdded = Math.max(0, ammoAdded); // Ensure not negative
  3670.  
  3671. if (ammoAdded > 0) {
  3672. player.ammo[pickup.ammoType] = currentReserve + ammoAdded;
  3673. const maxDisplay = maxReserve === Infinity ? '∞' : maxReserve;
  3674. console.log(`Picked up ${ammoAdded} ${pickup.ammoType} ammo (Reserve: ${player.ammo[pickup.ammoType]}/${maxDisplay}).`);
  3675. playSound('pickup_ammo');
  3676. createParticles(player.x, player.y, 5, AMMO_PICKUP_COLOR, 40, null, Math.PI * 2, {life: 0.3});
  3677. updateUI();
  3678. // Update shop button states if shop is open
  3679. if (isShopOpen) {
  3680. updateShopItemStates();
  3681. }
  3682. } else {
  3683. // Optional: Sound effect for full ammo?
  3684. }
  3685. }
  3686.  
  3687. // checkCollisions updated to pass dt
  3688. function checkCollisions(dt) {
  3689. if (!player) return;
  3690.  
  3691. // Projectile vs Entity (Enemies and Turrets)
  3692. for (let i = projectiles.length - 1; i >= 0; i--) {
  3693. const p = projectiles[i];
  3694. if (!p) continue;
  3695. let projectileRemoved = false;
  3696.  
  3697. // Check vs Enemies
  3698. for (let j = enemies.length - 1; j >= 0; j--) {
  3699. if (projectileRemoved) break; // Projectile already hit something
  3700. const e = enemies[j];
  3701. if (!e || e.health <= 0) continue;
  3702.  
  3703. // Check if projectile owner is the enemy itself (prevent self-harm if enemies shoot later)
  3704. if (p.owner === e) continue;
  3705.  
  3706. const effectiveEnemyRadius = e.size * 0.5;
  3707. const distSq = distanceSq(p.x, p.y, e.x, e.y);
  3708. const radiiSumSq = (p.size + effectiveEnemyRadius) ** 2;
  3709.  
  3710. if (distSq < radiiSumSq) {
  3711. const damageDealt = p.damage;
  3712. // Pass projectile owner as source
  3713. const died = e.takeDamage(damageDealt, p.owner);
  3714. playSound('hit_enemy', {volume: 0.4});
  3715.  
  3716. const knockbackResistance = e.knockbackResistance || 0;
  3717. const baseKnockback = p.knockback || 0;
  3718. const actualKnockback = baseKnockback * (1 - knockbackResistance);
  3719. if (actualKnockback > 0) {
  3720. const pushDir = normalize(e.x - p.x, e.y - p.y);
  3721. if (pushDir.x !== 0 || pushDir.y !== 0) {
  3722. const impulseStrength = actualKnockback * 0.5;
  3723. e.vx += pushDir.x * impulseStrength;
  3724. e.vy += pushDir.y * impulseStrength;
  3725. }
  3726. }
  3727.  
  3728. projectiles.splice(i, 1);
  3729. projectileRemoved = true;
  3730.  
  3731. if (died) {
  3732. e.onDeath();
  3733. }
  3734. break; // Projectile hits only one enemy
  3735. }
  3736. }
  3737.  
  3738. // Check vs Turrets (only if projectile wasn't from a turret or player)
  3739. if (!projectileRemoved && p.owner !== player && !(p.owner instanceof Turret)) { // Only enemy projectiles can hit turrets
  3740. for (let j = turrets.length - 1; j >= 0; j--) {
  3741. if (projectileRemoved) break;
  3742. const t = turrets[j];
  3743. if (!t || t.health <= 0) continue;
  3744.  
  3745. const distSq = distanceSq(p.x, p.y, t.x, t.y);
  3746. const radiiSumSq = (p.size + t.size) ** 2;
  3747.  
  3748. if (distSq < radiiSumSq) {
  3749. const damageDealt = p.damage; // Enemy projectile damage vs turret
  3750. // Pass projectile owner as source
  3751. const died = t.takeDamage(damageDealt, p.owner);
  3752. // Don't play enemy hit sound, turret has its own
  3753.  
  3754. projectiles.splice(i, 1);
  3755. projectileRemoved = true;
  3756. // Turret death handled in its own onDeath method
  3757. break;
  3758. }
  3759. }
  3760. }
  3761. }
  3762.  
  3763. // Player vs Enemy
  3764. enemies.forEach(e => {
  3765. if (!e || e.health <= 0 || !player) return;
  3766.  
  3767. const effectiveEnemyRadius = e.size * 0.5;
  3768. const distSq = distanceSq(player.x, player.y, e.x, e.y); // Use squared distance
  3769. const collisionThreshold = player.size + effectiveEnemyRadius;
  3770. const collisionThresholdSq = collisionThreshold * collisionThreshold;
  3771.  
  3772. if (distSq < collisionThresholdSq) {
  3773. player.takeDamage(e.damage * dt, e); // Pass enemy as source
  3774.  
  3775. const knockbackStrength = 100 + e.damage * 4;
  3776. const distBetween = Math.sqrt(distSq); // Calculate actual dist only on collision
  3777. const pushMagnitude = (collisionThreshold - distBetween) / 2.0;
  3778.  
  3779. // Avoid division by zero if distBetween is tiny
  3780. const dx = player.x - e.x;
  3781. const dy = player.y - e.y;
  3782. let pushDirX = 0, pushDirY = 0;
  3783.  
  3784. if (distBetween > 0.01) {
  3785. pushDirX = dx / distBetween;
  3786. pushDirY = dy / distBetween;
  3787. } else { // Random push if perfectly overlapped
  3788. const randAngle = Math.random() * Math.PI * 2;
  3789. pushDirX = Math.cos(randAngle);
  3790. pushDirY = Math.sin(randAngle);
  3791. }
  3792.  
  3793. const separationForce = pushMagnitude * 5.0;
  3794. player.vx += pushDirX * separationForce * dt;
  3795. player.vy += pushDirY * separationForce * dt;
  3796. e.vx -= pushDirX * separationForce * 0.5 * dt;
  3797. e.vy -= pushDirY * separationForce * 0.5 * dt;
  3798.  
  3799. const playerKnockbackImpulse = knockbackStrength * dt * 1.5;
  3800. player.vx += pushDirX * playerKnockbackImpulse;
  3801. player.vy += pushDirY * playerKnockbackImpulse;
  3802. }
  3803. });
  3804.  
  3805. // Shield Orb vs Enemy
  3806. if (player.powerUps.shieldOrbs > 0) {
  3807. const orbBaseRadius = SHIELD_ORB_RADIUS;
  3808. const now = gameTime;
  3809.  
  3810. for (let i = 0; i < SHIELD_ORB_COUNT; i++) {
  3811. const angle = player.shieldOrbAngle + (i * Math.PI * 2 / SHIELD_ORB_COUNT);
  3812. const orbX = player.x + Math.cos(angle) * orbBaseRadius;
  3813. const orbY = player.y + Math.sin(angle) * orbBaseRadius;
  3814. const orbRadius = 8; // Visual radius, use slightly larger for collision?
  3815. const orbCollisionRadiusSq = (orbRadius + 5) ** 2; // Squared radius for collision
  3816.  
  3817. enemies.forEach(e => {
  3818. if (!e || e.health <= 0) return;
  3819.  
  3820. // Check if enemy is on cooldown for this orb hit
  3821. const cooldownKey = `${e.id}`; // Key based on enemy ID
  3822. if (player.shieldOrbHitCooldowns[cooldownKey] > now) {
  3823. return; // On cooldown for this enemy
  3824. }
  3825.  
  3826. const distSq = distanceSq(orbX, orbY, e.x, e.y);
  3827. const radiiSumSq = orbCollisionRadiusSq + (e.size * 0.5) ** 2;
  3828.  
  3829. if (distSq < radiiSumSq) {
  3830. const died = e.takeDamage(SHIELD_ORB_DAMAGE, player); // Damage enemy, source is player
  3831. playSound('shield_hit_enemy', {volume: 0.4});
  3832. createParticles(orbX, orbY, 5, 'rgba(100, 200, 255, 0.8)', 80, null, Math.PI, {life: 0.3}); // Hit particles
  3833.  
  3834. // Apply cooldown for this enemy
  3835. player.shieldOrbHitCooldowns[cooldownKey] = now + SHIELD_ORB_HIT_COOLDOWN;
  3836.  
  3837. // Apply slight knockback from orb hit
  3838. const knockbackResistance = e.knockbackResistance || 0;
  3839. const knockbackStrength = 80 * (1 - knockbackResistance);
  3840. const pushDir = normalize(e.x - orbX, e.y - orbY);
  3841. if (pushDir.x !== 0 || pushDir.y !== 0) {
  3842. e.vx += pushDir.x * knockbackStrength * 0.8;
  3843. e.vy += pushDir.y * knockbackStrength * 0.8;
  3844. }
  3845.  
  3846. if (died) {
  3847. e.onDeath();
  3848. }
  3849. }
  3850. });
  3851. }
  3852. }
  3853.  
  3854. // Player vs Pickups (handled in update loop filter)
  3855. }
  3856.  
  3857.  
  3858. function render() {
  3859. ctx.fillStyle = '#1a1a1a';
  3860. ctx.fillRect(0, 0, canvas.width, canvas.height);
  3861.  
  3862. drawBackground();
  3863.  
  3864. // --- Draw Game Objects ---
  3865. xpOrbs.forEach(orb => orb.draw(ctx));
  3866. moneyPickups.forEach(mp => mp.draw(ctx)); // Draw money pickups
  3867. ammoPickups.forEach(ap => ap.draw(ctx));
  3868. powerUps.forEach(pu => pu.draw(ctx));
  3869.  
  3870. enemies.forEach(enemy => enemy.draw(ctx));
  3871. turrets.forEach(t => t.draw(ctx)); // Draw turrets
  3872.  
  3873. if (player) {
  3874. player.draw(ctx); // Draws player AND shield orbs
  3875. }
  3876.  
  3877. projectiles.forEach(p => p.draw(ctx));
  3878. particles.forEach(p => p.draw(ctx));
  3879.  
  3880. // --- Draw Off-Screen Enemy Indicators ---
  3881. drawOffscreenIndicators();
  3882.  
  3883. // Damage Text is handled by DOM elements
  3884. }
  3885.  
  3886. function drawBackground() {
  3887. if (noise) {
  3888. const TILE_RENDER_SIZE = GRID_SIZE;
  3889. ctx.globalAlpha = 0.5;
  3890.  
  3891. const shakeBuffer = camera.shakeIntensity > 0 ? camera.shakeIntensity * 1.5 : GRID_SIZE;
  3892. const viewWidth = canvas.width + shakeBuffer * 2;
  3893. const viewHeight = canvas.height + shakeBuffer * 2;
  3894. const startWorldX = camera.x - viewWidth / 2;
  3895. const startWorldY = camera.y - viewHeight / 2;
  3896. const endWorldX = startWorldX + viewWidth;
  3897. const endWorldY = startWorldY + viewHeight;
  3898.  
  3899. const startTileX = Math.floor(startWorldX / TILE_RENDER_SIZE);
  3900. const startTileY = Math.floor(startWorldY / TILE_RENDER_SIZE);
  3901. const endTileX = Math.ceil(endWorldX / TILE_RENDER_SIZE);
  3902. const endTileY = Math.ceil(endWorldY / TILE_RENDER_SIZE);
  3903.  
  3904. const baseHue = 190 + (currentWave * 8) % 170;
  3905. const baseSaturation = 50;
  3906. const baseLightness = 15;
  3907.  
  3908. for (let ty = startTileY; ty < endTileY; ty++) {
  3909. for (let tx = startTileX; tx < endTileX; tx++) {
  3910. const worldX = tx * TILE_RENDER_SIZE;
  3911. const worldY = ty * TILE_RENDER_SIZE;
  3912.  
  3913. const timeFactor = gameTime * 5;
  3914. const noiseValue = noise.simplex3(worldX / BACKGROUND_NOISE_SCALE, worldY / BACKGROUND_NOISE_SCALE, timeFactor / BACKGROUND_NOISE_SCALE);
  3915.  
  3916. const lightnessVariation = Math.floor((noiseValue + 1) / 2 * 10);
  3917. const saturationVariation = Math.floor((noiseValue + 1) / 2 * 15);
  3918.  
  3919. ctx.fillStyle = `hsl(${baseHue}, ${baseSaturation + saturationVariation}%, ${baseLightness + lightnessVariation}%)`;
  3920.  
  3921. const screenPos = worldToScreen(worldX + TILE_RENDER_SIZE / 2, worldY + TILE_RENDER_SIZE / 2); // Center of tile to screen
  3922. const screenTileX = screenPos.x - TILE_RENDER_SIZE / 2;
  3923. const screenTileY = screenPos.y - TILE_RENDER_SIZE / 2;
  3924.  
  3925. ctx.fillRect(Math.floor(screenTileX), Math.floor(screenTileY), TILE_RENDER_SIZE, TILE_RENDER_SIZE);
  3926. }
  3927. }
  3928. ctx.globalAlpha = 1.0;
  3929.  
  3930. } else {
  3931. // Fallback Grid
  3932. ctx.strokeStyle = '#444';
  3933. ctx.lineWidth = 1;
  3934. const screenOffsetX = (camera.x - canvas.width / 2) % GRID_SIZE;
  3935. const screenOffsetY = (camera.y - canvas.height / 2) % GRID_SIZE;
  3936. const startGridX = -screenOffsetX;
  3937. const startGridY = -screenOffsetY;
  3938.  
  3939. for (let x = startGridX; x < canvas.width; x += GRID_SIZE) {
  3940. ctx.beginPath();
  3941. ctx.moveTo(x, 0);
  3942. ctx.lineTo(x, canvas.height);
  3943. ctx.stroke();
  3944. }
  3945. for (let y = startGridY; y < canvas.height; y += GRID_SIZE) {
  3946. ctx.beginPath();
  3947. ctx.moveTo(0, y);
  3948. ctx.lineTo(canvas.width, y);
  3949. ctx.stroke();
  3950. }
  3951. }
  3952. }
  3953.  
  3954. // --- drawOffscreenIndicators (FIXED) ---
  3955. function drawOffscreenIndicators() {
  3956. if (!player || enemies.length === 0) return;
  3957.  
  3958. // Use camera center as the reference point on screen
  3959. const playerScreenPos = {x: canvas.width / 2, y: canvas.height / 2};
  3960.  
  3961. const margin = OFFSCREEN_INDICATOR_MARGIN;
  3962. const minX = margin;
  3963. const minY = margin;
  3964. const maxX = canvas.width - margin;
  3965. const maxY = canvas.height - margin;
  3966. const screenBounds = {x: minX, y: minY, w: maxX - minX, h: maxY - minY}; // Use min/max coords
  3967.  
  3968. enemies.forEach(enemy => {
  3969. if (enemy.health <= 0) return;
  3970.  
  3971. const enemyScreenPos = worldToScreen(enemy.x, enemy.y);
  3972.  
  3973. // Check if enemy is outside the margin area
  3974. if (enemyScreenPos.x < minX || enemyScreenPos.x > maxX ||
  3975. enemyScreenPos.y < minY || enemyScreenPos.y > maxY) {
  3976.  
  3977. // Calculate direction vector from player screen pos to enemy screen pos
  3978. const dirX = enemyScreenPos.x - playerScreenPos.x;
  3979. const dirY = enemyScreenPos.y - playerScreenPos.y;
  3980.  
  3981. // Find intersection using parametric line equation: P = player + t * dir
  3982. let t = Infinity; // Parameter t for intersection
  3983.  
  3984. // Check intersection with vertical edges
  3985. if (dirX !== 0) {
  3986. let tX1 = (minX - playerScreenPos.x) / dirX; // Intersection with left edge
  3987. let tX2 = (maxX - playerScreenPos.x) / dirX; // Intersection with right edge
  3988.  
  3989. // Ensure t > 0 (intersection is *beyond* the player towards the enemy)
  3990. if (tX1 > 0) {
  3991. let intersectY = playerScreenPos.y + tX1 * dirY;
  3992. if (intersectY >= minY && intersectY <= maxY) { // Check if within vertical bounds of the edge
  3993. t = Math.min(t, tX1);
  3994. }
  3995. }
  3996. if (tX2 > 0) {
  3997. let intersectY = playerScreenPos.y + tX2 * dirY;
  3998. if (intersectY >= minY && intersectY <= maxY) {
  3999. t = Math.min(t, tX2);
  4000. }
  4001. }
  4002. }
  4003.  
  4004. // Check intersection with horizontal edges
  4005. if (dirY !== 0) {
  4006. let tY1 = (minY - playerScreenPos.y) / dirY; // Intersection with top edge
  4007. let tY2 = (maxY - playerScreenPos.y) / dirY; // Intersection with bottom edge
  4008.  
  4009. // Ensure t > 0
  4010. if (tY1 > 0) {
  4011. let intersectX = playerScreenPos.x + tY1 * dirX;
  4012. if (intersectX >= minX && intersectX <= maxX) { // Check if within horizontal bounds of the edge
  4013. t = Math.min(t, tY1);
  4014. }
  4015. }
  4016. if (tY2 > 0) {
  4017. let intersectX = playerScreenPos.x + tY2 * dirX;
  4018. if (intersectX >= minX && intersectX <= maxX) {
  4019. t = Math.min(t, tY2);
  4020. }
  4021. }
  4022. }
  4023.  
  4024. let edgePos;
  4025. if (t !== Infinity && t > 0) {
  4026. // Calculate the actual intersection point on the boundary
  4027. edgePos = {
  4028. x: playerScreenPos.x + t * dirX,
  4029. y: playerScreenPos.y + t * dirY
  4030. };
  4031. // Clamp to be strictly within bounds just in case of floating point errors
  4032. edgePos.x = Math.max(minX, Math.min(maxX, edgePos.x));
  4033. edgePos.y = Math.max(minY, Math.min(maxY, edgePos.y));
  4034.  
  4035. } else {
  4036. // Fallback: If no valid intersection found (shouldn't happen theoretically)
  4037. // Clamp the original enemy screen position to the edge
  4038. edgePos = {
  4039. x: Math.max(minX, Math.min(maxX, enemyScreenPos.x)),
  4040. y: Math.max(minY, Math.min(maxY, enemyScreenPos.y))
  4041. };
  4042. }
  4043.  
  4044.  
  4045. // Calculate angle from the edge position towards the actual off-screen enemy position
  4046. const angle = Math.atan2(enemyScreenPos.y - edgePos.y, enemyScreenPos.x - edgePos.x);
  4047.  
  4048. // Calculate distance from player to enemy for opacity/color
  4049. const dist = distance(player.x, player.y, enemy.x, enemy.y);
  4050.  
  4051. // --- Opacity Calculation (same as before) ---
  4052. let alpha = 0;
  4053. if (dist > OFFSCREEN_INDICATOR_MIN_DIST_OPACITY) {
  4054. alpha = Math.min(1, (dist - OFFSCREEN_INDICATOR_MIN_DIST_OPACITY) / (OFFSCREEN_INDICATOR_MAX_DIST_OPACITY - OFFSCREEN_INDICATOR_MIN_DIST_OPACITY));
  4055. }
  4056. alpha = Math.max(0, Math.min(1, alpha)); // Clamp alpha 0-1
  4057.  
  4058. // Dim further if very close to screen edge (using enemyScreenPos distance to edge)
  4059. const distToEdgeX = Math.min(enemyScreenPos.x - minX, maxX - enemyScreenPos.x);
  4060. const distToEdgeY = Math.min(enemyScreenPos.y - minY, maxY - enemyScreenPos.y);
  4061. const minDistToEdge = Math.min(distToEdgeX, distToEdgeY);
  4062. if (minDistToEdge < OFFSCREEN_INDICATOR_MIN_DIST_OPACITY * 0.5 && minDistToEdge > 0) {
  4063. alpha *= Math.max(0, minDistToEdge / (OFFSCREEN_INDICATOR_MIN_DIST_OPACITY * 0.5));
  4064. }
  4065.  
  4066. if (alpha <= 0.01) return; // Don't draw if basically invisible
  4067. // --- End Opacity Calculation ---
  4068.  
  4069.  
  4070. // --- Draw the indicator (same as before) ---
  4071. ctx.save();
  4072. ctx.translate(edgePos.x, edgePos.y);
  4073. ctx.rotate(angle);
  4074. ctx.fillStyle = `rgba(255, 0, 0, ${alpha * 0.8})`; // Red indicator, use calculated alpha
  4075.  
  4076. // Draw triangle pointing in the calculated angle
  4077. ctx.beginPath();
  4078. ctx.moveTo(OFFSCREEN_INDICATOR_SIZE * 0.8, 0); // Point tip
  4079. ctx.lineTo(-OFFSCREEN_INDICATOR_SIZE * 0.4, -OFFSCREEN_INDICATOR_SIZE * 0.5); // Back left
  4080. ctx.lineTo(-OFFSCREEN_INDICATOR_SIZE * 0.4, OFFSCREEN_INDICATOR_SIZE * 0.5); // Back right
  4081. ctx.closePath();
  4082. ctx.fill();
  4083.  
  4084. ctx.restore();
  4085. // --- End Drawing ---
  4086. }
  4087. });
  4088. }
  4089.  
  4090.  
  4091. function createParticles(x, y, count, color, speed, baseAngle = null, spread = Math.PI * 2, options = {}) {
  4092. if (!Number.isFinite(x) || !Number.isFinite(y)) {
  4093. console.warn("Attempted to create particles at invalid position:", x, y);
  4094. return;
  4095. }
  4096. if (count <= 0) return;
  4097.  
  4098. const maxToAdd = Math.max(0, MAX_PARTICLES - particles.length);
  4099. count = Math.min(count, maxToAdd);
  4100. if (count === 0) return;
  4101.  
  4102. for (let i = 0; i < count; i++) {
  4103. const angle = (baseAngle !== null)
  4104. ? baseAngle + (Math.random() - 0.5) * spread
  4105. : Math.random() * Math.PI * 2;
  4106. const currentSpeed = speed * (options.speedVariance !== false ? (0.6 + Math.random() * 0.8) : 1.0);
  4107. const vx = Math.cos(angle) * currentSpeed;
  4108. const vy = Math.sin(angle) * currentSpeed;
  4109. const size = options.size || (Math.random() * 2.5 + 1);
  4110. const life = (options.life || PARTICLE_LIFETIME) * (0.7 + Math.random() * 0.6);
  4111.  
  4112. let particleColor = color;
  4113. // --- Color variation logic (keep existing) ---
  4114. if (options.colorVariance && typeof color === 'string' && color.startsWith('hsl')) {
  4115. const match = color.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
  4116. if (match) {
  4117. const h = parseInt(match[1]);
  4118. const s = parseInt(match[2]);
  4119. const l = parseInt(match[3]);
  4120. const lVar = Math.floor((Math.random() - 0.5) * 10);
  4121. const sVar = Math.floor((Math.random() - 0.5) * 10);
  4122. particleColor = `hsl(${h}, ${Math.max(0, Math.min(100, s + sVar))}%, ${Math.max(0, Math.min(100, l + lVar))}%)`;
  4123. }
  4124. } else if (options.colorVariance && typeof color === 'string' && color.startsWith('#')) {
  4125. try {
  4126. let r = parseInt(color.substring(1, 3), 16);
  4127. let g = parseInt(color.substring(3, 5), 16);
  4128. let b = parseInt(color.substring(5, 7), 16);
  4129. r = Math.max(0, Math.min(255, r + Math.floor((Math.random() - 0.5) * 40)));
  4130. g = Math.max(0, Math.min(255, g + Math.floor((Math.random() - 0.5) * 40)));
  4131. b = Math.max(0, Math.min(255, b + Math.floor((Math.random() - 0.5) * 40)));
  4132. particleColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
  4133. } catch (e) { /* Ignore */
  4134. }
  4135. } else if (options.colorVariance && typeof color === 'string' && color.startsWith('rgba')) {
  4136. // Try simple alpha variation for rgba
  4137. const match = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/);
  4138. if (match) {
  4139. const r = match[1];
  4140. const g = match[2];
  4141. const b = match[3];
  4142. let a = parseFloat(match[4]);
  4143. a = Math.max(0.1, Math.min(1, a * (0.8 + Math.random() * 0.4))); // Vary alpha
  4144. particleColor = `rgba(${r}, ${g}, ${b}, ${a.toFixed(2)})`;
  4145. }
  4146. } else if (options.colorVariance && ['red', 'yellow', 'blue', 'green', 'orange', 'cyan', 'magenta'].includes(color)) {
  4147. if (color === 'red') {
  4148. const shades = ['#ff0000', '#ee0000', '#dd0000', '#cc0000', '#bb0000', '#aa0000'];
  4149. particleColor = shades[Math.floor(Math.random() * shades.length)];
  4150. }
  4151. }
  4152. // --- End color variation ---
  4153.  
  4154. particles.push(new Particle(x, y, vx, vy, size, particleColor, life, options));
  4155. }
  4156. }
  4157.  
  4158.  
  4159. function updateUI() {
  4160. if (!player) {
  4161. uiLevel.textContent = '-';
  4162. uiXp.textContent = '-';
  4163. uiXpNeeded.textContent = '-';
  4164. uiHealth.textContent = '-';
  4165. uiMaxHealth.textContent = '-';
  4166. uiStaminaBar.style.width = '0%'; // Clear stamina bar
  4167. uiMoney.textContent = '0'; // Clear money
  4168. uiWave.textContent = '-';
  4169. uiEnemiesLeft.textContent = '-';
  4170. uiCurrentWeapon.textContent = '-';
  4171. uiAmmo.textContent = '-';
  4172. uiPowerUpStatus.innerHTML = '';
  4173. levelUpIndicator.style.display = 'none';
  4174. levelUpOptionsDiv.style.display = 'none';
  4175. reloadIndicatorDiv.style.display = 'none';
  4176. return;
  4177. }
  4178.  
  4179. uiLevel.textContent = player.level;
  4180. uiXp.textContent = Math.floor(player.xp);
  4181. uiXpNeeded.textContent = player.xpToNextLevel;
  4182. uiHealth.textContent = Math.ceil(player.health);
  4183. uiMaxHealth.textContent = player.maxHealth;
  4184. // Update Stamina Bar
  4185. uiStaminaBar.style.width = `${Math.max(0, player.currentStamina / player.maxStamina * 100)}%`;
  4186. uiStaminaBar.style.backgroundColor = player.canSprint ? '#4CAF50' : '#FFA500'; // Green normally, Orange when depleted/cooling down
  4187. uiMoney.textContent = player.money; // Update money display
  4188. uiWave.textContent = currentWave;
  4189. uiEnemiesLeft.textContent = enemiesAlive + enemiesToSpawn;
  4190.  
  4191. // Weapon and Ammo
  4192. const weapon = player.currentWeapon;
  4193. uiCurrentWeapon.textContent = weapon.name;
  4194.  
  4195. if (player.isReloading) {
  4196. uiAmmo.innerHTML = `<span style="color:orange;">Reloading... (${player.reloadTimer.toFixed(1)}s)</span>`;
  4197. } else if (weapon.type === 'ranged' && typeof weapon.clipSize !== 'undefined') {
  4198. const ammoInClip = weapon.ammoInClip;
  4199. const reserveAmmo = player.ammo[weapon.ammoType] || 0;
  4200. const maxReserve = weapon.maxAmmo !== undefined ? weapon.maxAmmo : '∞';
  4201. let ammoColor = 'white';
  4202. if (ammoInClip <= weapon.clipSize * 0.2) ammoColor = 'orange';
  4203. if (ammoInClip <= 0) ammoColor = 'red';
  4204. uiAmmo.innerHTML = `<span style="color:${ammoColor};">${ammoInClip}</span> / ${reserveAmmo}${maxReserve !== '∞' ? '/' + maxReserve : ''}`;
  4205. } else if (weapon.type === 'melee') {
  4206. const swingsPerSecond = (weapon.rateOfFire * (player.powerUps.rapidFire > 0 ? 1.5 : 1)).toFixed(1);
  4207. uiAmmo.textContent = `Swing: ${swingsPerSecond}/s`;
  4208. } else {
  4209. uiAmmo.textContent = "∞";
  4210. }
  4211.  
  4212. // Power-up Status
  4213. let powerUpHtml = '';
  4214. for (const type in player.powerUps) {
  4215. if (player.powerUps[type] > 0) {
  4216. const formattedName = type.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
  4217. // Special name for shield
  4218. const displayName = type === 'shieldOrbs' ? 'Shield Orbs' : formattedName;
  4219. powerUpHtml += `<div class="power-up-timer">${displayName}: ${player.powerUps[type].toFixed(1)}s</div>`;
  4220. }
  4221. }
  4222. if (player.healthRegen && player.healthRegen > 0) {
  4223. powerUpHtml += `<div style="color:lightgreen;">Regen: ${player.healthRegen.toFixed(1)}/s</div>`;
  4224. }
  4225. uiPowerUpStatus.innerHTML = powerUpHtml;
  4226.  
  4227.  
  4228. // Level Up Indicator UI
  4229. if (pendingLevelUps > 0 && !isShopOpen) { // Hide if shop is open
  4230. levelUpIndicator.style.display = 'block';
  4231. pendingLevelUpsSpan.textContent = pendingLevelUps;
  4232. } else {
  4233. levelUpIndicator.style.display = 'none';
  4234. if (isShowingLevelUpOptions) { // Hide options panel if indicator is hidden
  4235. levelUpOptionsDiv.style.display = 'none';
  4236. isShowingLevelUpOptions = false;
  4237. }
  4238. }
  4239. }
  4240.  
  4241. // --- Level Up Logic (Modified) ---
  4242. function presentLevelUpOptions() {
  4243. if (!player || pendingLevelUps <= 0 || isShowingLevelUpOptions || isShopOpen) {
  4244. if (pendingLevelUps <= 0 || isShopOpen) {
  4245. levelUpOptionsDiv.style.display = 'none';
  4246. isShowingLevelUpOptions = false;
  4247. }
  4248. return;
  4249. }
  4250.  
  4251. isShowingLevelUpOptions = true;
  4252. levelUpOptionsDiv.innerHTML = '';
  4253. levelUpOptionsDiv.style.display = 'flex';
  4254.  
  4255. const options = generateUpgradeOptions(3);
  4256.  
  4257. const header = document.createElement('div');
  4258. const upgradeLevel = player.level - pendingLevelUps + 1;
  4259. header.textContent = `Choose Upgrade (Level ${upgradeLevel})`;
  4260. header.style.fontWeight = 'bold';
  4261. header.style.marginBottom = '10px';
  4262. header.style.textAlign = 'center';
  4263. levelUpOptionsDiv.appendChild(header);
  4264.  
  4265.  
  4266. options.forEach(option => {
  4267. const button = document.createElement('button');
  4268. button.classList.add('level-up-button');
  4269. button.textContent = option.description;
  4270. button.onclick = () => {
  4271. applyUpgrade(option);
  4272. pendingLevelUps--;
  4273. updateUI();
  4274.  
  4275. levelUpOptionsDiv.style.display = 'none';
  4276. isShowingLevelUpOptions = false;
  4277.  
  4278. playSound('upgrade_select');
  4279. createParticles(player.x, player.y, 30, 'gold', 120, null, Math.PI * 1.5);
  4280.  
  4281. if (pendingLevelUps > 0) {
  4282. setTimeout(presentLevelUpOptions, 50);
  4283. }
  4284. };
  4285. levelUpOptionsDiv.appendChild(button);
  4286. });
  4287. }
  4288.  
  4289. function generateUpgradeOptions(count) {
  4290. if (!player) return [];
  4291.  
  4292. const possibleUpgrades = [];
  4293.  
  4294. // --- Player Stat Upgrades ---
  4295. possibleUpgrades.push({type: 'player_stat', stat: 'maxHealth', value: 25, description: 'Max Health +25'});
  4296. possibleUpgrades.push({type: 'player_stat', stat: 'speed', value: 8, description: 'Move Speed +8%'});
  4297. possibleUpgrades.push({
  4298. type: 'player_stat',
  4299. stat: 'magnet',
  4300. value: 30,
  4301. description: 'Pickup Magnet Radius +30%'
  4302. });
  4303. possibleUpgrades.push({type: 'player_stat', stat: 'regen', value: 0.1, description: 'Health Regen +0.1/s'});
  4304. // Sprint Upgrades
  4305. possibleUpgrades.push({type: 'player_stat', stat: 'maxStamina', value: 20, description: 'Max Stamina +20%'}); // Percentage based
  4306. possibleUpgrades.push({
  4307. type: 'player_stat',
  4308. stat: 'staminaRegen',
  4309. value: 15,
  4310. description: 'Stamina Regen +15%'
  4311. }); // Percentage based
  4312.  
  4313.  
  4314. // --- Weapon Specific Upgrades ---
  4315. player.weapons.forEach((weapon, index) => {
  4316. if (!weapon) return;
  4317. const upgradePrefix = `${weapon.name}: `;
  4318. if (weapon.type === 'ranged') {
  4319. if (typeof weapon.damage !== 'undefined') {
  4320. const dmgIncrease = Math.max(1, Math.ceil(weapon.damage * 0.12));
  4321. possibleUpgrades.push({
  4322. type: 'weapon_stat',
  4323. index: index,
  4324. stat: 'damage',
  4325. value: dmgIncrease,
  4326. description: upgradePrefix + `Damage +${dmgIncrease}`
  4327. });
  4328. }
  4329. if (typeof weapon.rateOfFire !== 'undefined') {
  4330. const rofIncrease = weapon.rateOfFire * 0.1;
  4331. possibleUpgrades.push({
  4332. type: 'weapon_stat',
  4333. index: index,
  4334. stat: 'rateOfFire',
  4335. value: rofIncrease,
  4336. description: upgradePrefix + `Fire Rate +10%`
  4337. });
  4338. }
  4339. if (typeof weapon.clipSize !== 'undefined' && typeof weapon.reloadTime !== 'undefined') {
  4340. const clipIncrease = Math.max(1, Math.ceil(weapon.clipSize * 0.15));
  4341. possibleUpgrades.push({
  4342. type: 'weapon_stat',
  4343. index: index,
  4344. stat: 'clipSize',
  4345. value: clipIncrease,
  4346. description: upgradePrefix + `Clip Size +${clipIncrease}`
  4347. });
  4348. const reloadDecrease = -weapon.reloadTime * 0.15;
  4349. if (weapon.reloadTime + reloadDecrease >= 0.3) {
  4350. possibleUpgrades.push({
  4351. type: 'weapon_stat',
  4352. index: index,
  4353. stat: 'reloadTime',
  4354. value: reloadDecrease,
  4355. description: upgradePrefix + `Reload Speed +15%`
  4356. });
  4357. }
  4358. }
  4359. if (typeof weapon.maxAmmo !== 'undefined') {
  4360. const ammoIncrease = Math.ceil(weapon.maxAmmo * 0.2);
  4361. possibleUpgrades.push({
  4362. type: 'weapon_stat',
  4363. index: index,
  4364. stat: 'maxAmmo',
  4365. value: ammoIncrease,
  4366. description: upgradePrefix + `Max Reserve Ammo +20%`
  4367. });
  4368. }
  4369. if (typeof weapon.recoil !== 'undefined' && weapon.recoil > 0) {
  4370. const recoilReduction = -weapon.recoil * 0.20;
  4371. possibleUpgrades.push({
  4372. type: 'weapon_stat',
  4373. index: index,
  4374. stat: 'recoil',
  4375. value: recoilReduction,
  4376. description: upgradePrefix + `Recoil -20%`
  4377. });
  4378. }
  4379. if (typeof weapon.accuracy !== 'undefined' && weapon.accuracy > 0.01) {
  4380. const accuracyImprovement = -weapon.accuracy * 0.15;
  4381. possibleUpgrades.push({
  4382. type: 'weapon_stat',
  4383. index: index,
  4384. stat: 'accuracy',
  4385. value: accuracyImprovement,
  4386. description: upgradePrefix + `Accuracy +15%`
  4387. });
  4388. }
  4389.  
  4390. } else if (weapon.type === 'melee') {
  4391. if (typeof weapon.damage !== 'undefined') {
  4392. const dmgIncrease = Math.max(1, Math.ceil(weapon.damage * 0.15));
  4393. possibleUpgrades.push({
  4394. type: 'weapon_stat',
  4395. index: index,
  4396. stat: 'damage',
  4397. value: dmgIncrease,
  4398. description: upgradePrefix + `Damage +${dmgIncrease}`
  4399. });
  4400. }
  4401. if (typeof weapon.rateOfFire !== 'undefined') {
  4402. const rofIncrease = weapon.rateOfFire * 0.12;
  4403. possibleUpgrades.push({
  4404. type: 'weapon_stat',
  4405. index: index,
  4406. stat: 'rateOfFire',
  4407. value: rofIncrease,
  4408. description: upgradePrefix + `Swing Speed +12%`
  4409. });
  4410. }
  4411. if (typeof weapon.range !== 'undefined') {
  4412. const rangeIncrease = Math.ceil(weapon.range * 0.1);
  4413. possibleUpgrades.push({
  4414. type: 'weapon_stat',
  4415. index: index,
  4416. stat: 'range',
  4417. value: rangeIncrease,
  4418. description: upgradePrefix + `Range +10%`
  4419. });
  4420. }
  4421. if (typeof weapon.knockback !== 'undefined') {
  4422. const knockbackIncrease = weapon.knockback * 0.15;
  4423. possibleUpgrades.push({
  4424. type: 'weapon_stat',
  4425. index: index,
  4426. stat: 'knockback',
  4427. value: knockbackIncrease,
  4428. description: upgradePrefix + `Knockback +15%`
  4429. });
  4430. }
  4431. }
  4432. });
  4433.  
  4434. const filteredUpgrades = possibleUpgrades.filter(opt => {
  4435. if (opt.type === 'weapon_stat') {
  4436. if (!player.weapons[opt.index]) return false;
  4437. const weapon = player.weapons[opt.index];
  4438. if (opt.stat === 'accuracy' && typeof weapon.accuracy !== 'undefined' && weapon.accuracy < 0.02) return false;
  4439. if (opt.stat === 'reloadTime' && typeof weapon.reloadTime !== 'undefined' && weapon.reloadTime < 0.5) return false;
  4440. if (typeof weapon?.[opt.stat] === 'undefined') return false;
  4441. }
  4442. if (opt.type === 'player_stat') {
  4443. // Check player stat upgrade limits/conditions
  4444. if (opt.stat === 'regen' && player.healthRegen >= 2) return false;
  4445. // Ensure base stat exists for percentage increases
  4446. if ((opt.stat === 'maxStamina' && typeof player.maxStamina === 'undefined') ||
  4447. (opt.stat === 'staminaRegen' && typeof player.staminaRegenRate === 'undefined') ||
  4448. (opt.stat === 'speed' && typeof player.baseSpeed === 'undefined') ||
  4449. (opt.stat === 'magnet' && typeof player.baseMagnetRadius === 'undefined')) {
  4450. // If base stat doesn't exist, don't offer % upgrade (safety check)
  4451. // return false; // Or handle differently, maybe offer additive? For now, assume they exist.
  4452. }
  4453. // General check for non-special player stats
  4454. else if (typeof player[opt.stat] === 'undefined' && !['regen', 'magnet', 'speed', 'maxStamina', 'staminaRegen'].includes(opt.stat)) {
  4455. return false;
  4456. }
  4457. }
  4458. return true;
  4459. });
  4460.  
  4461. const shuffled = filteredUpgrades.sort(() => 0.5 - Math.random());
  4462. const selectedOptions = [];
  4463. const usedWeaponIndices = new Set();
  4464. const usedPlayerStats = new Set(); // Track player stats offered
  4465. let playerStatCount = 0;
  4466.  
  4467.  
  4468. for (const option of shuffled) {
  4469. if (selectedOptions.length >= count) break;
  4470.  
  4471. let allowOption = true;
  4472. if (option.type === 'weapon_stat') {
  4473. // Reduce chance of multiple upgrades for the SAME weapon
  4474. if (usedWeaponIndices.has(option.index) && Math.random() > 0.4) {
  4475. allowOption = false;
  4476. }
  4477. } else if (option.type === 'player_stat') {
  4478. // Reduce chance of offering the SAME player stat twice
  4479. if (usedPlayerStats.has(option.stat) && Math.random() > 0.2) {
  4480. allowOption = false;
  4481. }
  4482. // Limit total player stats shown
  4483. if (playerStatCount >= 2 && Math.random() > 0.3) {
  4484. allowOption = false;
  4485. }
  4486. }
  4487.  
  4488. if (allowOption) {
  4489. selectedOptions.push(option);
  4490. if (option.type === 'weapon_stat') {
  4491. usedWeaponIndices.add(option.index);
  4492. } else if (option.type === 'player_stat') {
  4493. usedPlayerStats.add(option.stat);
  4494. playerStatCount++;
  4495. }
  4496. }
  4497. }
  4498.  
  4499. // Fill remaining slots if needed
  4500. let shuffleIndex = 0;
  4501. while (selectedOptions.length < count && shuffleIndex < shuffled.length) {
  4502. const nextOption = shuffled[shuffleIndex++];
  4503. if (!selectedOptions.some(sel => sel.description === nextOption.description)) {
  4504. selectedOptions.push(nextOption);
  4505. }
  4506. }
  4507. while (selectedOptions.length < count && selectedOptions.length > 0) {
  4508. selectedOptions.push(selectedOptions[0]); // Duplicate first option as last resort
  4509. }
  4510. if (selectedOptions.length === 0) {
  4511. selectedOptions.push({
  4512. type: 'player_stat',
  4513. stat: 'maxHealth',
  4514. value: 10,
  4515. description: 'Max Health +10 (Fallback)'
  4516. });
  4517. while (selectedOptions.length < count) selectedOptions.push(selectedOptions[0]);
  4518. }
  4519.  
  4520. return selectedOptions.slice(0, count);
  4521. }
  4522.  
  4523. function applyUpgrade(option) {
  4524. if (!player) return;
  4525. console.log("Applying upgrade:", option.description);
  4526.  
  4527. if (option.type === 'weapon_stat') {
  4528. const weapon = player.weapons[option.index];
  4529. if (weapon && typeof weapon[option.stat] !== 'undefined') {
  4530. const oldValue = weapon[option.stat];
  4531. weapon[option.stat] += option.value;
  4532.  
  4533. // Apply constraints
  4534. if (option.stat === 'rateOfFire') weapon[option.stat] = Math.max(0.1, weapon[option.stat]);
  4535. if (option.stat === 'reloadTime') weapon[option.stat] = Math.max(0.3, weapon[option.stat]);
  4536. if (option.stat === 'accuracy') weapon[option.stat] = Math.max(0.0, weapon[option.stat]);
  4537. if (option.stat === 'recoil') weapon[option.stat] = Math.max(0, weapon[option.stat]);
  4538. if (option.stat === 'clipSize') weapon[option.stat] = Math.max(1, Math.round(weapon[option.stat]));
  4539. if (option.stat === 'maxAmmo') weapon[option.stat] = Math.max(0, Math.round(weapon[option.stat]));
  4540. if (option.stat === 'knockback') weapon[option.stat] = Math.max(0, weapon[option.stat]);
  4541. if (option.stat === 'range') weapon[option.stat] = Math.max(5, weapon[option.stat]);
  4542.  
  4543. 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})`);
  4544.  
  4545. } else {
  4546. console.warn(`Tried to upgrade missing weapon or stat: Index ${option.index}, Stat ${option.stat}`);
  4547. }
  4548. } else if (option.type === 'player_stat') {
  4549. let statChanged = false;
  4550. let logValue;
  4551. if (option.stat === 'maxHealth') {
  4552. player.maxHealth += option.value;
  4553. player.health += option.value;
  4554. logValue = player.maxHealth;
  4555. statChanged = true;
  4556. } else if (option.stat === 'speed') {
  4557. player.baseSpeed *= (1 + option.value / 100);
  4558. logValue = player.baseSpeed;
  4559. statChanged = true;
  4560. } else if (option.stat === 'magnet') {
  4561. player.baseMagnetRadius *= (1 + option.value / 100);
  4562. logValue = player.baseMagnetRadius;
  4563. statChanged = true;
  4564. } else if (option.stat === 'regen') {
  4565. player.healthRegen = (player.healthRegen || 0) + option.value;
  4566. logValue = player.healthRegen;
  4567. statChanged = true;
  4568. console.log(`New player Health Regen: ${logValue.toFixed(2)}/s`);
  4569. } else if (option.stat === 'maxStamina') {
  4570. player.maxStamina *= (1 + option.value / 100);
  4571. // Give current stamina proportional increase
  4572. player.currentStamina *= (1 + option.value / 100);
  4573. player.currentStamina = Math.min(player.currentStamina, player.maxStamina); // Clamp
  4574. logValue = player.maxStamina;
  4575. statChanged = true;
  4576. } else if (option.stat === 'staminaRegen') {
  4577. player.staminaRegenRate *= (1 + option.value / 100);
  4578. logValue = player.staminaRegenRate;
  4579. statChanged = true;
  4580. }
  4581.  
  4582. if (statChanged && option.stat !== 'regen') { // Regen logged separately
  4583. console.log(`New player ${option.stat}: ${logValue !== undefined && logValue.toFixed ? logValue.toFixed(2) : logValue}`);
  4584. }
  4585. }
  4586. updateUI();
  4587. }
  4588.  
  4589. function gameOver() {
  4590. if (isGameOver) return;
  4591. isGameOver = true;
  4592. console.log("Game Over!");
  4593. playSound('game_over');
  4594.  
  4595. mouse.down = false;
  4596. applyCameraShake(35, 0.6);
  4597.  
  4598. setTimeout(() => {
  4599. alert(`Game Over! You reached Wave ${currentWave} and Level ${player ? player.level : '-'}. Final Money: $${player ? player.money : '0'}`);
  4600.  
  4601. if (animationFrameId) {
  4602. cancelAnimationFrame(animationFrameId);
  4603. animationFrameId = null;
  4604. console.log("Animation frame cancelled in gameOver timeout.");
  4605. }
  4606.  
  4607. // Reset Game State Thoroughly
  4608. player = null;
  4609. enemies = [];
  4610. projectiles = [];
  4611. xpOrbs = [];
  4612. moneyPickups = [];
  4613. ammoPickups = [];
  4614. powerUps = [];
  4615. particles = [];
  4616. turrets = []; // Clear turrets
  4617. damageTexts = [];
  4618. damageTextContainer.innerHTML = '';
  4619. keys = {};
  4620. mouse = {x: 0, y: 0, down: false};
  4621. pendingLevelUps = 0;
  4622. isShowingLevelUpOptions = false;
  4623. isShopOpen = false; // Ensure shop is closed
  4624.  
  4625. // Reset camera immediately
  4626. camera = {x: 0, y: 0, shakeIntensity: 0, shakeDuration: 0};
  4627. gameTime = 0; // Reset game time too
  4628.  
  4629. updateUI(); // Final clear of UI
  4630.  
  4631. gameContainerDiv.style.display = 'none';
  4632. colorSelectionDiv.style.display = 'block';
  4633.  
  4634. // Remove listeners added during init
  4635. window.removeEventListener('keydown', handleKeyDown);
  4636. window.removeEventListener('keyup', handleKeyUp);
  4637. window.removeEventListener('mousedown', handleMouseDown);
  4638. window.removeEventListener('mouseup', handleMouseUp);
  4639. window.removeEventListener('mousemove', handleMouseMove);
  4640. window.removeEventListener('resize', handleResize);
  4641. levelUpIndicator.removeEventListener('click', toggleLevelUpOptions);
  4642. shopButton.removeEventListener('click', toggleShop);
  4643. shopCloseButton.removeEventListener('click', toggleShop);
  4644.  
  4645. console.log("Game state reset. Ready for new game via setupColorSelection.");
  4646.  
  4647. // Re-setup color selection for the next game
  4648. setupColorSelection();
  4649.  
  4650. }, 600);
  4651. }
  4652.  
  4653.  
  4654. function gameLoop(currentTime) {
  4655. if (isGameOver && player === null) {
  4656. console.log("Game loop stopping: isGameOver=true, player=null.");
  4657. animationFrameId = null;
  4658. return;
  4659. }
  4660. if (!animationFrameId && !isGameOver) {
  4661. // This might happen if gameOver was triggered right before a frame request
  4662. // or if init wasn't called properly. Check if we should restart loop.
  4663. if (!isGameOver) {
  4664. console.warn("gameLoop called without valid animationFrameId but not game over. Restarting loop.");
  4665. animationFrameId = requestAnimationFrame(gameLoop);
  4666. } else {
  4667. console.warn("gameLoop called without valid animationFrameId during game over. Exiting.");
  4668. }
  4669. return;
  4670. }
  4671.  
  4672. const dt = Math.min(0.05, (currentTime - lastTime) / 1000);
  4673. lastTime = currentTime;
  4674.  
  4675. // Update Logic (Only if not fully game over)
  4676. if (!isGameOver || player) {
  4677. update(dt); // Update game state (handles internal pausing via isShopOpen)
  4678. }
  4679.  
  4680. // Apply Health Regen if player exists and has it (runs even if shop is open)
  4681. if (player && player.healthRegen > 0 && player.health < player.maxHealth) {
  4682. player.health = Math.min(player.maxHealth, player.health + player.healthRegen * dt);
  4683. }
  4684.  
  4685. // Render Logic (Always render)
  4686. render();
  4687.  
  4688. // UI Update (Always update)
  4689. updateUI();
  4690.  
  4691. // Request next frame
  4692. if (animationFrameId) { // Check if still valid (might be cancelled by gameOver)
  4693. animationFrameId = requestAnimationFrame(gameLoop);
  4694. }
  4695. }
  4696.  
  4697. // --- Start Game ---
  4698. window.onload = setupColorSelection;
  4699.  
  4700. </script>
  4701.  
  4702. </body>
  4703. </html>
Add Comment
Please, Sign In to add comment