/**** * Plugins ****/ var tween = LK.import("@upit/tween.v1"); /**** * Classes ****/ var Bullet = Container.expand(function (type, startX, startY, targetX, targetY, damage) { var self = Container.call(this); var bulletGraphic = self.attachAsset(type, { anchorX: 0.5, anchorY: 0.5 }); // Validate input coordinates if (isNaN(startX) || isNaN(startY) || isNaN(targetX) || isNaN(targetY)) { console.error('Bullet constructor received NaN coordinates:', { startX: startX, startY: startY, targetX: targetX, targetY: targetY }); // Use fallback coordinates to prevent NaN propagation startX = startX || 0; startY = startY || 0; targetX = targetX || 0; targetY = targetY || 0; } self.x = startX; self.y = startY; self.damage = damage; self.speed = 8; self.targetX = targetX; self.targetY = targetY; var dx = targetX - startX; var dy = targetY - startY; var distance = Math.sqrt(dx * dx + dy * dy); // Calculate bullet trajectory angle for proper sprite rotation var trajectoryAngle = Math.atan2(dy, dx); // Set bullet graphic rotation to match trajectory direction bulletGraphic.rotation = trajectoryAngle; // Prevent division by zero and NaN calculations if (distance === 0 || isNaN(distance)) { console.warn('Bullet distance is zero or NaN, using default velocity'); self.velocityX = 1; // Default horizontal movement self.velocityY = 0; } else { self.velocityX = dx / distance * self.speed; self.velocityY = dy / distance * self.speed; } // Final validation to ensure velocities are not NaN if (isNaN(self.velocityX) || isNaN(self.velocityY)) { console.error('Bullet velocities are NaN, using fallback'); self.velocityX = 1; self.velocityY = 0; } self.update = function () { self.x += self.velocityX; self.y += self.velocityY; }; return self; }); var DoublePlasmaCannon = Container.expand(function (gridX, gridY) { var self = Container.call(this); // Create static base - adjust anchor to position base higher for better cannon alignment // Base is 200x150, adjust anchor Y to position base higher than center var base = self.attachAsset('CanonDoublePlasmaBase', { anchorX: 0.5, anchorY: 0.3 // Adjusted to position base higher than center for better cannon alignment with 200x150 sprite }); // Create rotating cannon part - position correctly on top of base // Base is 200x150, cannon is 100x163.06, adjust anchor to position cannon higher in sprite // Adjusting anchor to 0.3 to better center the cannon sprite (100x163.06) var cannon = self.attachAsset('CanonDoublePlasma', { anchorX: 0.5, anchorY: 0.3 // Adjusted anchor point to better center during rotation for 100x163.06 sprite }); self.towerType = 'doublePlasmaCannon'; self.gridX = gridX; self.gridY = gridY; self.range = 360; self.damage = 75; // Increased damage for plasma rounds self.fireRate = 30; // Faster fire rate between shots when loaded self.lastShot = 0; self.cost = 200; self.currentTarget = null; self.noTargetTimer = 0; // Timer to track how long without target // Heavy weapon system properties self.isReloading = true; // Start in reload phase self.reloadTime = 1200; // 20 seconds at 60fps self.reloadTimer = 0; self.maxAmmo = 10; self.currentAmmo = 0; self.isActive = false; // Can only target when loaded and ready self.isRotating = false; // Track if cannon is currently rotating self.isAligned = false; // Track if cannon is aligned with target self.wasRotating = false; // Track previous rotation state for sound triggers self.rotatingStartPlayed = false; // Track if start sound has been played self.rotatingLoopInterval = null; // Store rotation loop sound interval // Random movement properties for idle behavior self.idleMovementTimer = 0; // Timer for random movements self.currentIdleAction = null; // Current idle action being performed self.idleActionDuration = 0; // How long current action should last self.idleActionTimer = 0; // Timer for current action self.lastRotationState = false; // More precise rotation state tracking self.rotationSoundDelay = 0; // Delay counter for better sound timing self.getBulletType = function () { return 'plasmaBullet'; }; self.update = function () { // Heavy weapon reload system if (self.isReloading) { self.reloadTimer++; // No targeting or rotation during reload self.currentTarget = null; self.noTargetTimer = 0; // Visual feedback during reload - gradual blue charging effect var reloadProgress = self.reloadTimer / self.reloadTime; // 0 to 1 var blueIntensity = Math.floor(reloadProgress * 127); // 0 to 127 // Create gradual blue tint - more blue as charging progresses var redComponent = Math.floor(255 - blueIntensity * 0.7); var greenComponent = Math.floor(255 - blueIntensity * 0.3); var blueComponent = 255; var chargeTint = redComponent << 16 | greenComponent << 8 | blueComponent; tween.stop(cannon, { tint: true }); tween(cannon, { tint: chargeTint // Gradual blue charging effect }, { duration: 100, easing: tween.easeInOut }); // Check if reload is complete if (self.reloadTimer >= self.reloadTime) { self.isReloading = false; self.isActive = true; self.currentAmmo = self.maxAmmo; self.reloadTimer = 0; // Stop charging sound loop if (self.chargingLoopInterval) { LK.clearInterval(self.chargingLoopInterval); self.chargingLoopInterval = null; } if (self.chargingLoopTimeout) { LK.clearTimeout(self.chargingLoopTimeout); self.chargingLoopTimeout = null; } safePlayAudio('ChargingCompletePlasmaCannon', 'DoublePlasmaCannon ready audio failed'); } return; // Exit early during reload - no other functionality } // Only proceed with normal behavior if loaded and active if (!self.isActive || self.currentAmmo <= 0) { // Start reload cycle self.isReloading = true; self.isActive = false; self.reloadTimer = 0; safePlayAudio('ChargingPlasmaCannon', 'DoublePlasmaCannon charging audio failed'); // Start looping the 2-4 second fragment immediately at 2 seconds self.chargingLoopTimeout = LK.setTimeout(function () { self.chargingLoopInterval = LK.setInterval(function () { if (self.isReloading) { safePlayAudio('ChargingPlasmaCannonLoop', 'DoublePlasmaCannon charging loop audio failed'); } }, 2000); // Loop the 2-second fragment continuously }, 2000); // Start loop immediately at 2 seconds, not after the full sound completes return; } self.lastShot++; // Find target and rotate cannon towards it (only when active) var target = self.findTarget(); if (target) { // Check if target changed - if so, reset alignment if (self.currentTarget !== target) { self.isAligned = false; } self.currentTarget = target; self.noTargetTimer = 0; // Reset timer when target is found // Calculate angle to target var dx = target.x - self.x; var dy = target.y - self.y; var targetAngle = Math.atan2(dy, dx); // FIX: Add 90-degree offset because sprite points UP instead of RIGHT var spriteRotationAngle = targetAngle - Math.PI / 2; // Smooth rotation using tween var currentAngle = cannon.rotation; var angleDiff = spriteRotationAngle - currentAngle; // Normalize angle difference to shortest path while (angleDiff > Math.PI) { angleDiff -= 2 * Math.PI; } while (angleDiff < -Math.PI) { angleDiff += 2 * Math.PI; } // Only tween if significant angle difference if (Math.abs(angleDiff) > 0.1) { // Check if this is the start of rotation (transition from not rotating to rotating) if (!self.isRotating && !self.lastRotationState) { // Play start rotation sound immediately safePlayAudio('RotatingStartPlasmaCannon', 'DoublePlasmaCannon rotation start audio failed'); self.rotatingStartPlayed = true; // Set delay before starting loop sound for better synchronization self.rotationSoundDelay = 25; // Slightly shorter delay for responsiveness // Clear any existing rotation loop if (self.rotatingLoopInterval) { LK.clearInterval(self.rotatingLoopInterval); self.rotatingLoopInterval = null; } } self.isRotating = true; self.isAligned = false; tween.stop(cannon, { rotation: true }); tween(cannon, { rotation: spriteRotationAngle }, { duration: 400, easing: tween.linear, onFinish: function onFinish() { self.isRotating = false; self.isAligned = true; // Stop rotation loop sound when rotation finishes if (self.rotatingLoopInterval) { LK.clearInterval(self.rotatingLoopInterval); self.rotatingLoopInterval = null; } } }); } else { // Already aligned self.isRotating = false; self.isAligned = true; // Stop rotation sounds if cannon stops rotating if (self.rotatingLoopInterval) { LK.clearInterval(self.rotatingLoopInterval); self.rotatingLoopInterval = null; } } // Visual charging feedback based on fire rate progress var chargeProgress = self.lastShot / self.fireRate; if (chargeProgress < 1.0) { // Charging phase - gradually build up blue tint var blueIntensity = Math.floor(chargeProgress * 127); // 0 to 127 // Create proper blue tint by reducing red and green components var redComponent = Math.floor(255 - blueIntensity); var greenComponent = Math.floor(255 - blueIntensity * 0.5); var blueComponent = 255; var chargeTint = redComponent << 16 | greenComponent << 8 | blueComponent; // Stop any existing tint animations tween.stop(cannon, { tint: true }); // Apply charging tint tween(cannon, { tint: chargeTint }, { duration: 100, easing: tween.easeInOut }); } else { // Ready phase - steady blue glow (indicates loaded and ready) tween.stop(cannon, { tint: true }); tween(cannon, { tint: 0x80BFFF }, { duration: 100, easing: tween.easeInOut }); } // Shoot if ready, has ammo, and cannon is properly aligned if (self.lastShot >= self.fireRate && self.currentAmmo > 0 && self.isAligned && !self.isRotating) { // Firing phase - brief bright flash tween.stop(cannon, { tint: true }); tween(cannon, { tint: 0x40A0FF }, { duration: 100, easing: tween.easeOut, onFinish: function onFinish() { // Return to ammo-based blue intensity after firing if (self.currentAmmo > 0) { // Calculate new blue intensity based on remaining ammo after this shot var ammoAfterShot = self.currentAmmo - 1; if (ammoAfterShot > 0) { var ammoProgress = ammoAfterShot / self.maxAmmo; var blueIntensity = Math.floor(ammoProgress * 127 + 60); var redComponent = Math.floor(255 - blueIntensity * 0.7); var greenComponent = Math.floor(255 - blueIntensity * 0.3); var blueComponent = 255; var ammoTint = redComponent << 16 | greenComponent << 8 | blueComponent; tween(cannon, { tint: ammoTint // Dimmer blue based on remaining ammo }, { duration: 200, easing: tween.easeOut }); } else { // Last shot - fade to normal tween(cannon, { tint: 0xFFFFFF }, { duration: 200, easing: tween.easeOut }); } } else { tween(cannon, { tint: 0xFFFFFF }, { duration: 200, easing: tween.easeOut }); } } }); self.shoot(target); self.currentAmmo--; // Consume ammo self.lastShot = 0; } } else { // No target found - increment timer and implement random idle behavior self.currentTarget = null; self.isAligned = false; // Reset alignment when no target self.noTargetTimer++; self.idleMovementTimer++; // Keep blue glow when loaded but no target (ready state) - intensity based on remaining ammo if (self.currentAmmo > 0) { // Calculate blue intensity based on remaining ammo (more ammo = more blue) var ammoProgress = self.currentAmmo / self.maxAmmo; // 0 to 1 var blueIntensity = Math.floor(ammoProgress * 127 + 60); // 60 to 187 (ensures visible blue) var redComponent = Math.floor(255 - blueIntensity * 0.7); var greenComponent = Math.floor(255 - blueIntensity * 0.3); var blueComponent = 255; var ammoTint = redComponent << 16 | greenComponent << 8 | blueComponent; tween.stop(cannon, { tint: true }); tween(cannon, { tint: ammoTint // Blue intensity reflects remaining ammo }, { duration: 300, easing: tween.easeOut }); } else { // Reset tint when no ammo tween.stop(cannon, { tint: true }); tween(cannon, { tint: 0xFFFFFF }, { duration: 300, easing: tween.easeOut }); } // Random idle movement behavior - starts after 3 seconds without target if (self.idleMovementTimer >= 180) { // 3 seconds at 60fps // Check if we need to start a new idle action if (!self.currentIdleAction || self.idleActionTimer >= self.idleActionDuration) { // Randomly choose an idle action every 2-5 seconds var randomActions = ['rotateLeft', 'rotateRight', 'rotateUp', 'rotateDown', 'returnToCenter']; self.currentIdleAction = randomActions[Math.floor(Math.random() * randomActions.length)]; self.idleActionDuration = 120 + Math.floor(Math.random() * 180); // 2-5 seconds self.idleActionTimer = 0; // Execute the chosen idle action var targetAngle = cannon.rotation; var shouldRotate = false; switch (self.currentIdleAction) { case 'rotateLeft': targetAngle = cannon.rotation - Math.PI / 3; // 60 degrees left shouldRotate = true; break; case 'rotateRight': targetAngle = cannon.rotation + Math.PI / 3; // 60 degrees right shouldRotate = true; break; case 'rotateUp': targetAngle = -Math.PI / 2; // Point up shouldRotate = true; break; case 'rotateDown': targetAngle = Math.PI / 2; // Point down shouldRotate = true; break; case 'returnToCenter': targetAngle = -Math.PI / 2; // Return to original position shouldRotate = true; break; } if (shouldRotate) { // Normalize target angle while (targetAngle > Math.PI) { targetAngle -= 2 * Math.PI; } while (targetAngle < -Math.PI) { targetAngle += 2 * Math.PI; } var currentAngle = cannon.rotation; var angleDiff = targetAngle - currentAngle; // Normalize angle difference to shortest path while (angleDiff > Math.PI) { angleDiff -= 2 * Math.PI; } while (angleDiff < -Math.PI) { angleDiff += 2 * Math.PI; } // Only rotate if significant difference if (Math.abs(angleDiff) > 0.1) { // Improved rotation sound logic with better timing if (!self.isRotating && !self.lastRotationState) { // Start rotation sound immediately safePlayAudio('RotatingStartPlasmaCannon', 'DoublePlasmaCannon idle rotation start audio failed'); // Set delay before starting loop sound self.rotationSoundDelay = 30; // 0.5 second delay at 60fps // Clear any existing rotation loop if (self.rotatingLoopInterval) { LK.clearInterval(self.rotatingLoopInterval); self.rotatingLoopInterval = null; } } self.isRotating = true; tween.stop(cannon, { rotation: true }); tween(cannon, { rotation: targetAngle }, { duration: 600 + Math.floor(Math.random() * 400), // 0.6-1.0 seconds for natural variation easing: tween.easeInOut, onFinish: function onFinish() { self.isRotating = false; // Stop rotation loop sound when rotation finishes if (self.rotatingLoopInterval) { LK.clearInterval(self.rotatingLoopInterval); self.rotatingLoopInterval = null; } } }); } } } // Update idle action timer self.idleActionTimer++; } // Handle rotation sound delay and looping for idle movement if (self.isRotating && self.rotationSoundDelay > 0) { self.rotationSoundDelay--; if (self.rotationSoundDelay === 0 && !self.rotatingLoopInterval) { // Start the rotation loop sound after delay self.rotatingLoopInterval = LK.setInterval(function () { if (self.isRotating) { safePlayAudio('RotatingPlasmaCannon', 'DoublePlasmaCannon idle rotation loop audio failed'); } }, 600); // Play every 600ms for a more natural feel } } // Fallback: Return to original position after 8 seconds of no target if (self.noTargetTimer >= 480 && !self.isRotating) { // 8 seconds at 60fps var originalAngle = -Math.PI / 2; // Pointing up (original position) var currentAngle = cannon.rotation; var angleDiff = originalAngle - currentAngle; // Normalize angle difference to shortest path while (angleDiff > Math.PI) { angleDiff -= 2 * Math.PI; } while (angleDiff < -Math.PI) { angleDiff += 2 * Math.PI; } // Only rotate if significant difference if (Math.abs(angleDiff) > 0.1) { if (!self.isRotating && !self.lastRotationState) { safePlayAudio('RotatingStartPlasmaCannon', 'DoublePlasmaCannon return rotation start audio failed'); self.rotationSoundDelay = 30; if (self.rotatingLoopInterval) { LK.clearInterval(self.rotatingLoopInterval); self.rotatingLoopInterval = null; } } self.isRotating = true; self.currentIdleAction = null; // Reset idle action tween.stop(cannon, { rotation: true }); tween(cannon, { rotation: originalAngle }, { duration: 500, easing: tween.easeInOut, onFinish: function onFinish() { self.isRotating = false; if (self.rotatingLoopInterval) { LK.clearInterval(self.rotatingLoopInterval); self.rotatingLoopInterval = null; } } }); } } } // Update rotation state tracking for better sound synchronization self.wasRotating = self.lastRotationState; self.lastRotationState = self.isRotating; // Handle rotation sound delay for better timing if (self.isRotating && self.rotationSoundDelay > 0) { self.rotationSoundDelay--; if (self.rotationSoundDelay === 0 && !self.rotatingLoopInterval) { // Start the rotation loop sound after proper delay self.rotatingLoopInterval = LK.setInterval(function () { if (self.isRotating) { safePlayAudio('RotatingPlasmaCannon', 'DoublePlasmaCannon rotation loop audio failed'); } }, 500); // Consistent timing with target tracking } } }; self.findTarget = function () { // Only target enemies when active (not reloading) if (!self.isActive || self.isReloading || self.currentAmmo <= 0) { return null; } for (var i = 0; i < enemies.length; i++) { var enemy = enemies[i]; var dx = enemy.x - self.x; var dy = enemy.y - self.y; var distance = Math.sqrt(dx * dx + dy * dy); if (distance <= self.range) { return enemy; } } return null; }; self.shoot = function (target) { // Calculate enemy movement prediction for accurate shooting var enemyVelocityX = 0; var enemyVelocityY = 0; // Get enemy movement direction from their path if (target.pathIndex < enemyPath.length - 1) { var nextPoint = enemyPath[target.pathIndex + 1]; var dx = nextPoint.x - target.x; var dy = nextPoint.y - target.y; var distance = Math.sqrt(dx * dx + dy * dy); if (distance > 0) { enemyVelocityX = dx / distance * target.speed; enemyVelocityY = dy / distance * target.speed; } } // Calculate bullet travel time and predict enemy position var bulletSpeed = 8; // Match bullet speed from Bullet class var distanceToEnemy = Math.sqrt((target.x - self.x) * (target.x - self.x) + (target.y - self.y) * (target.y - self.y)); var bulletTravelTime = distanceToEnemy / bulletSpeed; // Predict where enemy will be when bullet arrives var predictedX = target.x + enemyVelocityX * bulletTravelTime; var predictedY = target.y + enemyVelocityY * bulletTravelTime; // Calculate cannon corner positions for dual shooting from sprite corners // Get current cannon rotation angle var cannonAngle = cannon.rotation; // Calculate the corners of the cannon sprite // Cannon sprite is 100x163.06, with anchor at 0.5, 0.3 var spriteWidth = 100; var spriteHeight = 163.06; var anchorX = 0.5; var anchorY = 0.3; // Calculate local corner positions relative to anchor point - shooting from rear corners var leftCornerLocalX = -spriteWidth * anchorX + 20; // Slightly inward from edge var rightCornerLocalX = spriteWidth * (1 - anchorX) - 20; // Slightly inward from edge var cornerLocalY = spriteHeight * (1 - anchorY) - 30; // Near the rear end of cannon (opposite side) // Transform local positions to world coordinates with rotation var cosAngle = Math.cos(cannonAngle); var sinAngle = Math.sin(cannonAngle); // Left corner world position var leftBarrelX = self.x + (leftCornerLocalX * cosAngle - cornerLocalY * sinAngle); var leftBarrelY = self.y + (leftCornerLocalX * sinAngle + cornerLocalY * cosAngle); // Right corner world position var rightBarrelX = self.x + (rightCornerLocalX * cosAngle - cornerLocalY * sinAngle); var rightBarrelY = self.y + (rightCornerLocalX * sinAngle + cornerLocalY * cosAngle); // Fire two bullets - one from each barrel var bullet1 = new Bullet(self.getBulletType(), leftBarrelX, leftBarrelY, predictedX, predictedY, self.damage); var bullet2 = new Bullet(self.getBulletType(), rightBarrelX, rightBarrelY, predictedX, predictedY, self.damage); bullets.push(bullet1); bullets.push(bullet2); game.addChild(bullet1); game.addChild(bullet2); safePlayAudio('BulletPlasmaCannon', 'DoublePlasmaCannon shoot audio failed'); // Add recoil effect - move cannon sprite backward slightly then return var recoilDistance = 8; // Small backward movement var currentX = cannon.x; var currentY = cannon.y; // Calculate recoil direction based on cannon's current rotation // Since sprite points UP by default and we add -PI/2 offset, we need to account for that var actualCannonDirection = cannon.rotation + Math.PI / 2; // Convert back to actual direction var recoilAngle = actualCannonDirection + Math.PI; // Opposite to firing direction var recoilX = currentX + Math.cos(recoilAngle) * recoilDistance; var recoilY = currentY + Math.sin(recoilAngle) * recoilDistance; // Stop any existing position tweens on cannon sprite tween.stop(cannon, { x: true, y: true }); // Quick recoil backward then return to original position tween(cannon, { x: recoilX, y: recoilY }, { duration: 80, easing: tween.easeOut, onFinish: function onFinish() { // Return to original position tween(cannon, { x: currentX, y: currentY }, { duration: 120, easing: tween.easeInOut }); } }); // Add glowing effect to both plasma bullets tween(bullet1.children[0], { tint: 0x80ff80 }, { duration: 100 }); tween(bullet2.children[0], { tint: 0x80ff80 }, { duration: 100 }); }; return self; }); var Enemy = Container.expand(function (type) { var self = Container.call(this); var enemyGraphic = self.attachAsset(type, { anchorX: 0.5, anchorY: 0.5 }); self.enemyType = type; self.pathIndex = 0; self.speed = 1; self.maxHealth = 100; self.health = self.maxHealth; self.goldValue = 10; // Set enemy properties based on type if (type === 'alienGrunt') { self.health = 80; self.maxHealth = 80; self.speed = 1.5; self.goldValue = 8; } else if (type === 'shieldedWalker') { self.health = 120; self.maxHealth = 120; self.speed = 1; self.goldValue = 12; } else if (type === 'agileCommander') { self.health = 60; self.maxHealth = 60; self.speed = 2.5; self.goldValue = 15; } else if (type === 'armoredColossus') { self.health = 200; self.maxHealth = 200; self.speed = 0.8; self.goldValue = 25; } else if (type === 'flyingDrone') { self.health = 70; self.maxHealth = 70; self.speed = 2; self.goldValue = 18; } self.takeDamage = function (damage) { self.health -= damage; if (self.health <= 0) { return true; // Enemy is dead } return false; }; self.update = function () { if (self.pathIndex < enemyPath.length - 1) { var currentTarget = enemyPath[self.pathIndex + 1]; var dx = currentTarget.x - self.x; var dy = currentTarget.y - self.y; var distance = Math.sqrt(dx * dx + dy * dy); if (distance < 5) { self.pathIndex++; } else { self.x += dx / distance * self.speed; self.y += dy / distance * self.speed; } } }; return self; }); var Tower = Container.expand(function (type, gridX, gridY) { var self = Container.call(this); var towerGraphic = self.attachAsset(type, { anchorX: 0.5, anchorY: 0.5 }); self.towerType = type; self.gridX = gridX; self.gridY = gridY; self.range = 150; self.damage = 25; self.fireRate = 60; // frames between shots self.lastShot = 0; self.cost = 75; self.currentTarget = null; // Store current target for continuous tracking self.targetDirection = null; // Store targeting calculations for accurate shooting // Set tower properties based on type if (type === 'tacticalRecruit') { self.range = 360; self.damage = 20; self.fireRate = 45; self.cost = 50; } else if (type === 'armoredElite') { self.range = 360; self.damage = 40; self.fireRate = 90; self.cost = 100; } else if (type === 'rotatingTurret') { self.range = 360; self.damage = 15; self.fireRate = 30; self.cost = 75; } else if (type === 'magneticCannon') { self.range = 360; self.damage = 60; self.fireRate = 120; self.cost = 150; } self.getBulletType = function () { if (self.towerType === 'tacticalRecruit') { return 'basicBullet'; } if (self.towerType === 'armoredElite') { return 'heavyBullet'; } if (self.towerType === 'rotatingTurret') { return 'turretBullet'; } if (self.towerType === 'magneticCannon') { return 'magneticBullet'; } if (self.towerType === 'doublePlasmaCannon') { return 'plasmaBullet'; } return 'basicBullet'; }; self.update = function () { self.lastShot++; // Find and track target EVERY frame (like DoublePlasmaCannon) var target = self.findTarget(); if (target) { self.currentTarget = target; // Calculate direction to target (standardized like DoublePlasmaCannon) var dx = target.x - self.x; var dy = target.y - self.y; var distance = Math.sqrt(dx * dx + dy * dy); var targetAngle = Math.atan2(dy, dx); // Store targeting info for accurate shooting self.targetDirection = { dx: dx, dy: dy, distance: distance, angle: targetAngle }; // Add visual rotation like DoublePlasmaCannon var currentAngle = towerGraphic.rotation; var angleDiff = targetAngle - currentAngle; // Normalize angle difference to shortest path while (angleDiff > Math.PI) { angleDiff -= 2 * Math.PI; } while (angleDiff < -Math.PI) { angleDiff += 2 * Math.PI; } // Only tween if significant angle difference if (Math.abs(angleDiff) > 0.1) { tween.stop(towerGraphic, { rotation: true }); tween(towerGraphic, { rotation: currentAngle + angleDiff }, { duration: 200, easing: tween.easeOut }); } } else { self.currentTarget = null; // Clear invalid target self.targetDirection = null; // Return tower to original position (pointing up) when no target var originalAngle = -Math.PI / 2; // Pointing up (original position) var currentAngle = towerGraphic.rotation; var angleDiff = originalAngle - currentAngle; // Normalize angle difference to shortest path while (angleDiff > Math.PI) { angleDiff -= 2 * Math.PI; } while (angleDiff < -Math.PI) { angleDiff += 2 * Math.PI; } // Only tween if significant angle difference if (Math.abs(angleDiff) > 0.1) { tween.stop(towerGraphic, { rotation: true }); tween(towerGraphic, { rotation: originalAngle }, { duration: 300, easing: tween.easeOut }); } } // Debug logging for tacticalRecruit if (self.towerType === 'tacticalRecruit') { console.log('tacticalRecruit update - target found:', self.currentTarget ? 'yes' : 'no', 'lastShot:', self.lastShot, 'fireRate:', self.fireRate); } // Shoot at current target when ready if (self.currentTarget && self.lastShot >= self.fireRate) { if (self.towerType === 'tacticalRecruit') { console.log('tacticalRecruit shooting at target:', self.currentTarget.enemyType); } self.shoot(self.currentTarget); self.lastShot = 0; } }; self.findTarget = function () { // Check if current target is still valid first (like DoublePlasmaCannon logic) if (self.currentTarget) { var dx = self.currentTarget.x - self.x; var dy = self.currentTarget.y - self.y; var distance = Math.sqrt(dx * dx + dy * dy); if (distance <= self.range && self.currentTarget.health > 0) { return self.currentTarget; // Keep tracking same enemy } } var closestTarget = null; var closestDistance = Infinity; if (self.towerType === 'tacticalRecruit') { console.log('tacticalRecruit findTarget - checking', enemies.length, 'enemies, range:', self.range); } for (var i = 0; i < enemies.length; i++) { var enemy = enemies[i]; var dx = enemy.x - self.x; var dy = enemy.y - self.y; var distance = Math.sqrt(dx * dx + dy * dy); if (self.towerType === 'tacticalRecruit') { console.log('tacticalRecruit checking enemy', i, 'distance:', distance, 'range:', self.range); } if (distance <= self.range && distance < closestDistance) { closestTarget = enemy; closestDistance = distance; if (self.towerType === 'tacticalRecruit') { console.log('tacticalRecruit found closer target! distance:', distance); } } } return closestTarget; }; self.shoot = function (target) { // Validate target and coordinates before shooting if (!target || isNaN(target.x) || isNaN(target.y) || isNaN(self.x) || isNaN(self.y)) { console.error('Tower shoot - invalid coordinates:', { tower: { x: self.x, y: self.y }, target: target ? { x: target.x, y: target.y } : 'null target' }); return; } // Calculate enemy movement prediction for accurate shooting var enemyVelocityX = 0; var enemyVelocityY = 0; // Get enemy movement direction from their path if (target.pathIndex < enemyPath.length - 1) { var nextPoint = enemyPath[target.pathIndex + 1]; var dx = nextPoint.x - target.x; var dy = nextPoint.y - target.y; var distance = Math.sqrt(dx * dx + dy * dy); if (distance > 0) { enemyVelocityX = dx / distance * target.speed; enemyVelocityY = dy / distance * target.speed; } } // Calculate bullet travel time and predict enemy position var bulletSpeed = 8; // Match bullet speed from Bullet class var distanceToEnemy = Math.sqrt((target.x - self.x) * (target.x - self.x) + (target.y - self.y) * (target.y - self.y)); var bulletTravelTime = distanceToEnemy / bulletSpeed; // Predict where enemy will be when bullet arrives var predictedX = target.x + enemyVelocityX * bulletTravelTime; var predictedY = target.y + enemyVelocityY * bulletTravelTime; if (self.towerType === 'tacticalRecruit') { console.log('tacticalRecruit shooting! Tower pos:', self.x, self.y, 'Target pos:', target.x, target.y, 'Predicted pos:', predictedX, predictedY, 'Bullet type:', self.getBulletType(), 'damage:', self.damage); } // Shoot at predicted position instead of current position var bullet = new Bullet(self.getBulletType(), self.x, self.y, predictedX, predictedY, self.damage); bullets.push(bullet); game.addChild(bullet); if (self.towerType === 'tacticalRecruit') { console.log('tacticalRecruit bullet created and added, bullets array length:', bullets.length); } safePlayAudio('shoot', 'Tower shoot audio failed'); }; return self; }); /**** * Initialize Game ****/ var game = new LK.Game({ backgroundColor: 0x1a1a2e }); /**** * Game Code ****/ // Utility functions for stability function isValidNumber(num) { return typeof num === 'number' && !isNaN(num) && num !== Infinity && num !== -Infinity; } function safeAssign(obj, property, value, fallback) { obj[property] = isValidNumber(value) ? value : fallback; } // Game state variables var gold = 200; var baseHealth = 20; var currentWave = 1; var totalWaves = 20; var waveInProgress = false; var waveCompleted = false; var waveBreakTime = 0; var gameEnded = false; var enemiesSpawned = 0; var enemiesToSpawn = []; var spawnTimer = 0; var waveStartTime = 0; // Resource management limits var maxBullets = 50; var maxEnemies = 30; var audioEnabled = true; // Game object arrays var towers = []; var enemies = []; var bullets = []; var towerZones = []; // Safe audio playback function function safePlayAudio(soundId, fallbackMessage) { if (!audioEnabled) { return; } try { var sound = LK.getSound(soundId); if (sound && typeof sound.play === 'function') { sound.play(); } } catch (error) { console.warn(fallbackMessage || 'Audio playback failed:', error); // Disable audio temporarily on multiple failures audioEnabled = false; LK.setTimeout(function () { audioEnabled = true; }, 5000); } } // Enemy path - creating a simple path from left to right with some turns var enemyPath = [{ x: 0, y: 1366 }, // Start off screen left { x: 300, y: 1366 }, { x: 300, y: 1000 }, { x: 800, y: 1000 }, { x: 800, y: 1600 }, { x: 1200, y: 1600 }, { x: 1200, y: 800 }, { x: 1700, y: 800 }, { x: 1700, y: 1366 }, { x: 2100, y: 1366 } // End off screen right (base location) ]; // Draw path for (var i = 0; i < enemyPath.length - 1; i++) { var pathSegment = LK.getAsset('pathTile', { anchorX: 0.5, anchorY: 0.5 }); pathSegment.x = enemyPath[i].x; pathSegment.y = enemyPath[i].y; pathSegment.alpha = 0.3; game.addChild(pathSegment); } // Create base var base = LK.getAsset('base', { anchorX: 0.5, anchorY: 0.5 }); base.x = enemyPath[enemyPath.length - 1].x; base.y = enemyPath[enemyPath.length - 1].y; game.addChild(base); // Create tower placement zones var towerGrid = [{ x: 500, y: 800 }, { x: 500, y: 1200 }, { x: 500, y: 1800 }, { x: 1000, y: 600 }, { x: 1000, y: 1200 }, { x: 1000, y: 1800 }, { x: 1400, y: 600 }, { x: 1400, y: 1000 }, { x: 1400, y: 1400 }]; for (var i = 0; i < towerGrid.length; i++) { // Validate grid coordinates before creating zone var gridX = towerGrid[i].x; var gridY = towerGrid[i].y; if (!isValidNumber(gridX) || !isValidNumber(gridY)) { console.error('Invalid tower grid coordinates at index', i, ':', gridX, gridY); continue; } var zone = LK.getAsset('towerZone', { anchorX: 0.5, anchorY: 0.5 }); safeAssign(zone, 'x', gridX, 500); safeAssign(zone, 'y', gridY, 800); zone.alpha = 0.2; zone.gridIndex = i; zone.occupied = false; towerZones.push(zone); game.addChild(zone); } // UI Elements var goldText = new Text2('Gold: ' + gold, { size: 60, fill: 0xFFD700 }); goldText.anchor.set(0, 0); goldText.x = 120; goldText.y = 120; LK.gui.topLeft.addChild(goldText); var healthText = new Text2('Base Health: ' + baseHealth, { size: 60, fill: 0xFF4500 }); healthText.anchor.set(0.5, 0); healthText.x = 0; healthText.y = 120; LK.gui.top.addChild(healthText); var waveText = new Text2('Wave: ' + currentWave + '/' + totalWaves, { size: 60, fill: 0x00BFFF }); waveText.anchor.set(1, 0); waveText.x = -20; waveText.y = 120; LK.gui.topRight.addChild(waveText); var nextWaveButton = new Text2('Start Wave 1', { size: 70, fill: 0x00FF00 }); nextWaveButton.anchor.set(0.5, 1); nextWaveButton.x = 0; nextWaveButton.y = -100; LK.gui.bottom.addChild(nextWaveButton); // Function to update button text function updateNextWaveButton() { if (gameEnded) { nextWaveButton.setText('Game Over'); nextWaveButton.tint = 0xFF0000; nextWaveButton.alpha = 0.5; } else if (currentWave > totalWaves) { nextWaveButton.setText('Victory!'); nextWaveButton.tint = 0xFFD700; nextWaveButton.alpha = 0.5; } else if (waveInProgress) { nextWaveButton.setText('Wave ' + currentWave + ' in Progress'); nextWaveButton.tint = 0x888888; nextWaveButton.alpha = 0.5; } else if (waveBreakTime > 0) { var seconds = Math.ceil(waveBreakTime / 60); nextWaveButton.setText('Next Wave in ' + seconds + 's'); nextWaveButton.tint = 0xFFFF00; nextWaveButton.alpha = 0.7; } else { nextWaveButton.setText('Start Wave ' + currentWave); nextWaveButton.tint = 0x00FF00; nextWaveButton.alpha = 1.0; } } // Tower selection UI var towerButtons = []; var towerTypes = ['tacticalRecruit', 'armoredElite', 'rotatingTurret', 'magneticCannon', 'CanonDoublePlasmaBase']; var towerCosts = [50, 100, 75, 150, 200]; var selectedTowerType = null; for (var i = 0; i < towerTypes.length; i++) { var button = LK.getAsset(towerTypes[i], { anchorX: 0.5, anchorY: 0.5, scaleX: 0.8, scaleY: 0.8 }); button.x = 200 + i * 120; button.y = 2600; button.towerType = towerTypes[i]; button.cost = towerCosts[i]; button.alpha = 0.7; towerButtons.push(button); game.addChild(button); var costText = new Text2('$' + towerCosts[i], { size: 40, fill: 0xFFFFFF }); costText.anchor.set(0.5, 0); costText.x = button.x; costText.y = button.y + 60; game.addChild(costText); } // Wave configuration function generateWave(waveNumber) { var enemyList = []; var enemyTypes = ['alienGrunt', 'shieldedWalker', 'agileCommander', 'armoredColossus', 'flyingDrone']; // Base enemy count increases with wave number var baseCount = Math.min(3 + Math.floor(waveNumber * 1.5), 25); // Early waves have simpler enemies if (waveNumber <= 3) { for (var i = 0; i < baseCount; i++) { enemyList.push('alienGrunt'); } } else if (waveNumber <= 7) { // Mix of basic enemies for (var i = 0; i < baseCount; i++) { var rand = Math.random(); if (rand < 0.6) { enemyList.push('alienGrunt'); } else if (rand < 0.9) { enemyList.push('shieldedWalker'); } else { enemyList.push('agileCommander'); } } } else if (waveNumber <= 15) { // More variety and difficulty for (var i = 0; i < baseCount; i++) { var rand = Math.random(); if (rand < 0.3) { enemyList.push('alienGrunt'); } else if (rand < 0.5) { enemyList.push('shieldedWalker'); } else if (rand < 0.7) { enemyList.push('agileCommander'); } else if (rand < 0.9) { enemyList.push('armoredColossus'); } else { enemyList.push('flyingDrone'); } } } else { // Final waves - all enemy types for (var i = 0; i < baseCount; i++) { var typeIndex = Math.floor(Math.random() * enemyTypes.length); enemyList.push(enemyTypes[typeIndex]); } } return enemyList; } function startNextWave() { if (waveInProgress || gameEnded || waveBreakTime > 0) { return; } console.log("Starting wave " + currentWave); enemiesToSpawn = generateWave(currentWave); waveInProgress = true; waveCompleted = false; enemiesSpawned = 0; spawnTimer = 1; waveStartTime = LK.ticks; // Spawn first enemy immediately spawnEnemy(); // Update wave text waveText.setText('Wave: ' + currentWave + '/' + totalWaves); // Immediate visual feedback updateNextWaveButton(); } function spawnEnemy() { if (enemiesSpawned < enemiesToSpawn.length && !gameEnded) { var enemyType = enemiesToSpawn[enemiesSpawned]; var enemy = new Enemy(enemyType); enemy.x = enemyPath[0].x; enemy.y = enemyPath[0].y; enemies.push(enemy); game.addChild(enemy); enemiesSpawned++; } } function checkWaveCompletion() { // Wave is complete when all enemies have been spawned and destroyed if (enemiesSpawned >= enemiesToSpawn.length && enemies.length === 0 && waveInProgress) { waveInProgress = false; waveCompleted = true; waveBreakTime = 300; // 5 seconds at 60fps // Wave completion bonus gold += 25; goldText.setText('Gold: ' + gold); currentWave++; if (currentWave > totalWaves) { gameEnded = true; LK.showYouWin(); return; } } } // Event handlers game.down = function (x, y, obj) { // Check tower button clicks for (var i = 0; i < towerButtons.length; i++) { var button = towerButtons[i]; var dx = x - button.x; var dy = y - button.y; if (Math.abs(dx) < 50 && Math.abs(dy) < 50) { if (gold >= button.cost) { selectedTowerType = button.towerType; // Highlight selected tower for (var j = 0; j < towerButtons.length; j++) { towerButtons[j].alpha = j === i ? 1.0 : 0.7; } return; } } } // Check tower zone clicks for (var i = 0; i < towerZones.length; i++) { var zone = towerZones[i]; var dx = x - zone.x; var dy = y - zone.y; if (Math.abs(dx) < 50 && Math.abs(dy) < 50 && !zone.occupied && selectedTowerType) { var towerCost = 50; if (selectedTowerType === 'armoredElite') { towerCost = 100; } else if (selectedTowerType === 'rotatingTurret') { towerCost = 75; } else if (selectedTowerType === 'magneticCannon') { towerCost = 150; } else if (selectedTowerType === 'CanonDoublePlasmaBase') { towerCost = 200; } if (gold >= towerCost) { // Validate zone coordinates before tower creation if (!isValidNumber(zone.x) || !isValidNumber(zone.y)) { console.error('Invalid zone coordinates for tower placement:', zone.x, zone.y); return; } var tower; if (selectedTowerType === 'CanonDoublePlasmaBase') { tower = new DoublePlasmaCannon(i % 3, Math.floor(i / 3)); } else { tower = new Tower(selectedTowerType, i % 3, Math.floor(i / 3)); } // Validate tower was created successfully if (!tower) { console.error('Failed to create tower of type:', selectedTowerType); return; } safeAssign(tower, 'x', zone.x, 500); safeAssign(tower, 'y', zone.y, 800); // Final validation of tower position if (!isValidNumber(tower.x) || !isValidNumber(tower.y)) { console.error('Tower position became NaN after assignment:', tower.x, tower.y); return; } towers.push(tower); game.addChild(tower); zone.occupied = true; gold -= towerCost; goldText.setText('Gold: ' + gold); safePlayAudio('placeTower', 'Tower placement audio failed'); selectedTowerType = null; // Reset tower button highlights for (var j = 0; j < towerButtons.length; j++) { towerButtons[j].alpha = 0.7; } } return; } } // Check next wave button click // Button is positioned at bottom center of screen var buttonDx = x - 1024; // Center of screen width (2048/2) var buttonDy = y - 2500; // Near bottom of screen if (Math.abs(buttonDx) < 200 && Math.abs(buttonDy) < 60 && !waveInProgress && waveBreakTime === 0 && currentWave <= totalWaves && !gameEnded) { console.log("Next wave button clicked!"); startNextWave(); } }; // Game update loop game.update = function () { if (gameEnded) { return; } // Handle wave break timer if (waveBreakTime > 0) { waveBreakTime--; if (waveBreakTime === 0) { waveCompleted = false; } } // Spawn enemies during active wave with limits if (waveInProgress && !gameEnded && enemies.length < maxEnemies) { spawnTimer++; // Spawn enemies every 48 frames (0.8 seconds at 60fps) after the first one if (spawnTimer >= 48) { spawnEnemy(); spawnTimer = 0; } } // Update towers with rate limiting for (var i = 0; i < towers.length; i++) { try { towers[i].update(); } catch (error) { console.error('Tower update failed for tower', i, ':', error); } } // Update enemies for (var i = enemies.length - 1; i >= 0; i--) { var enemy = enemies[i]; enemy.update(); // Check if enemy reached base if (enemy.pathIndex >= enemyPath.length - 1) { baseHealth--; healthText.setText('Base Health: ' + baseHealth); enemy.destroy(); enemies.splice(i, 1); if (baseHealth <= 0) { gameEnded = true; LK.showGameOver(); return; } } } // Update bullets with resource limits for (var i = bullets.length - 1; i >= 0; i--) { var bullet = bullets[i]; // Validate bullet exists and has valid position if (!bullet || !isValidNumber(bullet.x) || !isValidNumber(bullet.y)) { if (bullet) { bullet.destroy(); } bullets.splice(i, 1); continue; } try { bullet.update(); } catch (error) { console.error('Bullet update failed:', error); bullet.destroy(); bullets.splice(i, 1); continue; } // Check bullet collision with enemies var hit = false; for (var j = 0; j < enemies.length; j++) { var enemy = enemies[j]; if (!enemy || !isValidNumber(enemy.x) || !isValidNumber(enemy.y)) { continue; } var dx = bullet.x - enemy.x; var dy = bullet.y - enemy.y; var distance = Math.sqrt(dx * dx + dy * dy); if (isValidNumber(distance) && distance < 30) { if (enemy.takeDamage(bullet.damage)) { // Enemy died gold += enemy.goldValue; goldText.setText('Gold: ' + gold); safePlayAudio('enemyDeath', 'Enemy death audio failed'); enemy.destroy(); enemies.splice(j, 1); } bullet.destroy(); bullets.splice(i, 1); hit = true; break; } } // Remove bullets that go off screen if (!hit && (bullet.x < -50 || bullet.x > 2098 || bullet.y < -50 || bullet.y > 2782)) { bullet.destroy(); bullets.splice(i, 1); } } // Limit total bullets for performance if (bullets.length > maxBullets) { var excessBullets = bullets.length - maxBullets; for (var k = 0; k < excessBullets; k++) { if (bullets[k]) { bullets[k].destroy(); bullets.splice(k, 1); } } } // Check wave completion checkWaveCompletion(); // Update UI updateNextWaveButton(); };
/****
* Plugins
****/
var tween = LK.import("@upit/tween.v1");
/****
* Classes
****/
var Bullet = Container.expand(function (type, startX, startY, targetX, targetY, damage) {
var self = Container.call(this);
var bulletGraphic = self.attachAsset(type, {
anchorX: 0.5,
anchorY: 0.5
});
// Validate input coordinates
if (isNaN(startX) || isNaN(startY) || isNaN(targetX) || isNaN(targetY)) {
console.error('Bullet constructor received NaN coordinates:', {
startX: startX,
startY: startY,
targetX: targetX,
targetY: targetY
});
// Use fallback coordinates to prevent NaN propagation
startX = startX || 0;
startY = startY || 0;
targetX = targetX || 0;
targetY = targetY || 0;
}
self.x = startX;
self.y = startY;
self.damage = damage;
self.speed = 8;
self.targetX = targetX;
self.targetY = targetY;
var dx = targetX - startX;
var dy = targetY - startY;
var distance = Math.sqrt(dx * dx + dy * dy);
// Calculate bullet trajectory angle for proper sprite rotation
var trajectoryAngle = Math.atan2(dy, dx);
// Set bullet graphic rotation to match trajectory direction
bulletGraphic.rotation = trajectoryAngle;
// Prevent division by zero and NaN calculations
if (distance === 0 || isNaN(distance)) {
console.warn('Bullet distance is zero or NaN, using default velocity');
self.velocityX = 1; // Default horizontal movement
self.velocityY = 0;
} else {
self.velocityX = dx / distance * self.speed;
self.velocityY = dy / distance * self.speed;
}
// Final validation to ensure velocities are not NaN
if (isNaN(self.velocityX) || isNaN(self.velocityY)) {
console.error('Bullet velocities are NaN, using fallback');
self.velocityX = 1;
self.velocityY = 0;
}
self.update = function () {
self.x += self.velocityX;
self.y += self.velocityY;
};
return self;
});
var DoublePlasmaCannon = Container.expand(function (gridX, gridY) {
var self = Container.call(this);
// Create static base - adjust anchor to position base higher for better cannon alignment
// Base is 200x150, adjust anchor Y to position base higher than center
var base = self.attachAsset('CanonDoublePlasmaBase', {
anchorX: 0.5,
anchorY: 0.3 // Adjusted to position base higher than center for better cannon alignment with 200x150 sprite
});
// Create rotating cannon part - position correctly on top of base
// Base is 200x150, cannon is 100x163.06, adjust anchor to position cannon higher in sprite
// Adjusting anchor to 0.3 to better center the cannon sprite (100x163.06)
var cannon = self.attachAsset('CanonDoublePlasma', {
anchorX: 0.5,
anchorY: 0.3 // Adjusted anchor point to better center during rotation for 100x163.06 sprite
});
self.towerType = 'doublePlasmaCannon';
self.gridX = gridX;
self.gridY = gridY;
self.range = 360;
self.damage = 75; // Increased damage for plasma rounds
self.fireRate = 30; // Faster fire rate between shots when loaded
self.lastShot = 0;
self.cost = 200;
self.currentTarget = null;
self.noTargetTimer = 0; // Timer to track how long without target
// Heavy weapon system properties
self.isReloading = true; // Start in reload phase
self.reloadTime = 1200; // 20 seconds at 60fps
self.reloadTimer = 0;
self.maxAmmo = 10;
self.currentAmmo = 0;
self.isActive = false; // Can only target when loaded and ready
self.isRotating = false; // Track if cannon is currently rotating
self.isAligned = false; // Track if cannon is aligned with target
self.wasRotating = false; // Track previous rotation state for sound triggers
self.rotatingStartPlayed = false; // Track if start sound has been played
self.rotatingLoopInterval = null; // Store rotation loop sound interval
// Random movement properties for idle behavior
self.idleMovementTimer = 0; // Timer for random movements
self.currentIdleAction = null; // Current idle action being performed
self.idleActionDuration = 0; // How long current action should last
self.idleActionTimer = 0; // Timer for current action
self.lastRotationState = false; // More precise rotation state tracking
self.rotationSoundDelay = 0; // Delay counter for better sound timing
self.getBulletType = function () {
return 'plasmaBullet';
};
self.update = function () {
// Heavy weapon reload system
if (self.isReloading) {
self.reloadTimer++;
// No targeting or rotation during reload
self.currentTarget = null;
self.noTargetTimer = 0;
// Visual feedback during reload - gradual blue charging effect
var reloadProgress = self.reloadTimer / self.reloadTime; // 0 to 1
var blueIntensity = Math.floor(reloadProgress * 127); // 0 to 127
// Create gradual blue tint - more blue as charging progresses
var redComponent = Math.floor(255 - blueIntensity * 0.7);
var greenComponent = Math.floor(255 - blueIntensity * 0.3);
var blueComponent = 255;
var chargeTint = redComponent << 16 | greenComponent << 8 | blueComponent;
tween.stop(cannon, {
tint: true
});
tween(cannon, {
tint: chargeTint // Gradual blue charging effect
}, {
duration: 100,
easing: tween.easeInOut
});
// Check if reload is complete
if (self.reloadTimer >= self.reloadTime) {
self.isReloading = false;
self.isActive = true;
self.currentAmmo = self.maxAmmo;
self.reloadTimer = 0;
// Stop charging sound loop
if (self.chargingLoopInterval) {
LK.clearInterval(self.chargingLoopInterval);
self.chargingLoopInterval = null;
}
if (self.chargingLoopTimeout) {
LK.clearTimeout(self.chargingLoopTimeout);
self.chargingLoopTimeout = null;
}
safePlayAudio('ChargingCompletePlasmaCannon', 'DoublePlasmaCannon ready audio failed');
}
return; // Exit early during reload - no other functionality
}
// Only proceed with normal behavior if loaded and active
if (!self.isActive || self.currentAmmo <= 0) {
// Start reload cycle
self.isReloading = true;
self.isActive = false;
self.reloadTimer = 0;
safePlayAudio('ChargingPlasmaCannon', 'DoublePlasmaCannon charging audio failed');
// Start looping the 2-4 second fragment immediately at 2 seconds
self.chargingLoopTimeout = LK.setTimeout(function () {
self.chargingLoopInterval = LK.setInterval(function () {
if (self.isReloading) {
safePlayAudio('ChargingPlasmaCannonLoop', 'DoublePlasmaCannon charging loop audio failed');
}
}, 2000); // Loop the 2-second fragment continuously
}, 2000); // Start loop immediately at 2 seconds, not after the full sound completes
return;
}
self.lastShot++;
// Find target and rotate cannon towards it (only when active)
var target = self.findTarget();
if (target) {
// Check if target changed - if so, reset alignment
if (self.currentTarget !== target) {
self.isAligned = false;
}
self.currentTarget = target;
self.noTargetTimer = 0; // Reset timer when target is found
// Calculate angle to target
var dx = target.x - self.x;
var dy = target.y - self.y;
var targetAngle = Math.atan2(dy, dx);
// FIX: Add 90-degree offset because sprite points UP instead of RIGHT
var spriteRotationAngle = targetAngle - Math.PI / 2;
// Smooth rotation using tween
var currentAngle = cannon.rotation;
var angleDiff = spriteRotationAngle - currentAngle;
// Normalize angle difference to shortest path
while (angleDiff > Math.PI) {
angleDiff -= 2 * Math.PI;
}
while (angleDiff < -Math.PI) {
angleDiff += 2 * Math.PI;
}
// Only tween if significant angle difference
if (Math.abs(angleDiff) > 0.1) {
// Check if this is the start of rotation (transition from not rotating to rotating)
if (!self.isRotating && !self.lastRotationState) {
// Play start rotation sound immediately
safePlayAudio('RotatingStartPlasmaCannon', 'DoublePlasmaCannon rotation start audio failed');
self.rotatingStartPlayed = true;
// Set delay before starting loop sound for better synchronization
self.rotationSoundDelay = 25; // Slightly shorter delay for responsiveness
// Clear any existing rotation loop
if (self.rotatingLoopInterval) {
LK.clearInterval(self.rotatingLoopInterval);
self.rotatingLoopInterval = null;
}
}
self.isRotating = true;
self.isAligned = false;
tween.stop(cannon, {
rotation: true
});
tween(cannon, {
rotation: spriteRotationAngle
}, {
duration: 400,
easing: tween.linear,
onFinish: function onFinish() {
self.isRotating = false;
self.isAligned = true;
// Stop rotation loop sound when rotation finishes
if (self.rotatingLoopInterval) {
LK.clearInterval(self.rotatingLoopInterval);
self.rotatingLoopInterval = null;
}
}
});
} else {
// Already aligned
self.isRotating = false;
self.isAligned = true;
// Stop rotation sounds if cannon stops rotating
if (self.rotatingLoopInterval) {
LK.clearInterval(self.rotatingLoopInterval);
self.rotatingLoopInterval = null;
}
}
// Visual charging feedback based on fire rate progress
var chargeProgress = self.lastShot / self.fireRate;
if (chargeProgress < 1.0) {
// Charging phase - gradually build up blue tint
var blueIntensity = Math.floor(chargeProgress * 127); // 0 to 127
// Create proper blue tint by reducing red and green components
var redComponent = Math.floor(255 - blueIntensity);
var greenComponent = Math.floor(255 - blueIntensity * 0.5);
var blueComponent = 255;
var chargeTint = redComponent << 16 | greenComponent << 8 | blueComponent;
// Stop any existing tint animations
tween.stop(cannon, {
tint: true
});
// Apply charging tint
tween(cannon, {
tint: chargeTint
}, {
duration: 100,
easing: tween.easeInOut
});
} else {
// Ready phase - steady blue glow (indicates loaded and ready)
tween.stop(cannon, {
tint: true
});
tween(cannon, {
tint: 0x80BFFF
}, {
duration: 100,
easing: tween.easeInOut
});
}
// Shoot if ready, has ammo, and cannon is properly aligned
if (self.lastShot >= self.fireRate && self.currentAmmo > 0 && self.isAligned && !self.isRotating) {
// Firing phase - brief bright flash
tween.stop(cannon, {
tint: true
});
tween(cannon, {
tint: 0x40A0FF
}, {
duration: 100,
easing: tween.easeOut,
onFinish: function onFinish() {
// Return to ammo-based blue intensity after firing
if (self.currentAmmo > 0) {
// Calculate new blue intensity based on remaining ammo after this shot
var ammoAfterShot = self.currentAmmo - 1;
if (ammoAfterShot > 0) {
var ammoProgress = ammoAfterShot / self.maxAmmo;
var blueIntensity = Math.floor(ammoProgress * 127 + 60);
var redComponent = Math.floor(255 - blueIntensity * 0.7);
var greenComponent = Math.floor(255 - blueIntensity * 0.3);
var blueComponent = 255;
var ammoTint = redComponent << 16 | greenComponent << 8 | blueComponent;
tween(cannon, {
tint: ammoTint // Dimmer blue based on remaining ammo
}, {
duration: 200,
easing: tween.easeOut
});
} else {
// Last shot - fade to normal
tween(cannon, {
tint: 0xFFFFFF
}, {
duration: 200,
easing: tween.easeOut
});
}
} else {
tween(cannon, {
tint: 0xFFFFFF
}, {
duration: 200,
easing: tween.easeOut
});
}
}
});
self.shoot(target);
self.currentAmmo--; // Consume ammo
self.lastShot = 0;
}
} else {
// No target found - increment timer and implement random idle behavior
self.currentTarget = null;
self.isAligned = false; // Reset alignment when no target
self.noTargetTimer++;
self.idleMovementTimer++;
// Keep blue glow when loaded but no target (ready state) - intensity based on remaining ammo
if (self.currentAmmo > 0) {
// Calculate blue intensity based on remaining ammo (more ammo = more blue)
var ammoProgress = self.currentAmmo / self.maxAmmo; // 0 to 1
var blueIntensity = Math.floor(ammoProgress * 127 + 60); // 60 to 187 (ensures visible blue)
var redComponent = Math.floor(255 - blueIntensity * 0.7);
var greenComponent = Math.floor(255 - blueIntensity * 0.3);
var blueComponent = 255;
var ammoTint = redComponent << 16 | greenComponent << 8 | blueComponent;
tween.stop(cannon, {
tint: true
});
tween(cannon, {
tint: ammoTint // Blue intensity reflects remaining ammo
}, {
duration: 300,
easing: tween.easeOut
});
} else {
// Reset tint when no ammo
tween.stop(cannon, {
tint: true
});
tween(cannon, {
tint: 0xFFFFFF
}, {
duration: 300,
easing: tween.easeOut
});
}
// Random idle movement behavior - starts after 3 seconds without target
if (self.idleMovementTimer >= 180) {
// 3 seconds at 60fps
// Check if we need to start a new idle action
if (!self.currentIdleAction || self.idleActionTimer >= self.idleActionDuration) {
// Randomly choose an idle action every 2-5 seconds
var randomActions = ['rotateLeft', 'rotateRight', 'rotateUp', 'rotateDown', 'returnToCenter'];
self.currentIdleAction = randomActions[Math.floor(Math.random() * randomActions.length)];
self.idleActionDuration = 120 + Math.floor(Math.random() * 180); // 2-5 seconds
self.idleActionTimer = 0;
// Execute the chosen idle action
var targetAngle = cannon.rotation;
var shouldRotate = false;
switch (self.currentIdleAction) {
case 'rotateLeft':
targetAngle = cannon.rotation - Math.PI / 3; // 60 degrees left
shouldRotate = true;
break;
case 'rotateRight':
targetAngle = cannon.rotation + Math.PI / 3; // 60 degrees right
shouldRotate = true;
break;
case 'rotateUp':
targetAngle = -Math.PI / 2; // Point up
shouldRotate = true;
break;
case 'rotateDown':
targetAngle = Math.PI / 2; // Point down
shouldRotate = true;
break;
case 'returnToCenter':
targetAngle = -Math.PI / 2; // Return to original position
shouldRotate = true;
break;
}
if (shouldRotate) {
// Normalize target angle
while (targetAngle > Math.PI) {
targetAngle -= 2 * Math.PI;
}
while (targetAngle < -Math.PI) {
targetAngle += 2 * Math.PI;
}
var currentAngle = cannon.rotation;
var angleDiff = targetAngle - currentAngle;
// Normalize angle difference to shortest path
while (angleDiff > Math.PI) {
angleDiff -= 2 * Math.PI;
}
while (angleDiff < -Math.PI) {
angleDiff += 2 * Math.PI;
}
// Only rotate if significant difference
if (Math.abs(angleDiff) > 0.1) {
// Improved rotation sound logic with better timing
if (!self.isRotating && !self.lastRotationState) {
// Start rotation sound immediately
safePlayAudio('RotatingStartPlasmaCannon', 'DoublePlasmaCannon idle rotation start audio failed');
// Set delay before starting loop sound
self.rotationSoundDelay = 30; // 0.5 second delay at 60fps
// Clear any existing rotation loop
if (self.rotatingLoopInterval) {
LK.clearInterval(self.rotatingLoopInterval);
self.rotatingLoopInterval = null;
}
}
self.isRotating = true;
tween.stop(cannon, {
rotation: true
});
tween(cannon, {
rotation: targetAngle
}, {
duration: 600 + Math.floor(Math.random() * 400),
// 0.6-1.0 seconds for natural variation
easing: tween.easeInOut,
onFinish: function onFinish() {
self.isRotating = false;
// Stop rotation loop sound when rotation finishes
if (self.rotatingLoopInterval) {
LK.clearInterval(self.rotatingLoopInterval);
self.rotatingLoopInterval = null;
}
}
});
}
}
}
// Update idle action timer
self.idleActionTimer++;
}
// Handle rotation sound delay and looping for idle movement
if (self.isRotating && self.rotationSoundDelay > 0) {
self.rotationSoundDelay--;
if (self.rotationSoundDelay === 0 && !self.rotatingLoopInterval) {
// Start the rotation loop sound after delay
self.rotatingLoopInterval = LK.setInterval(function () {
if (self.isRotating) {
safePlayAudio('RotatingPlasmaCannon', 'DoublePlasmaCannon idle rotation loop audio failed');
}
}, 600); // Play every 600ms for a more natural feel
}
}
// Fallback: Return to original position after 8 seconds of no target
if (self.noTargetTimer >= 480 && !self.isRotating) {
// 8 seconds at 60fps
var originalAngle = -Math.PI / 2; // Pointing up (original position)
var currentAngle = cannon.rotation;
var angleDiff = originalAngle - currentAngle;
// Normalize angle difference to shortest path
while (angleDiff > Math.PI) {
angleDiff -= 2 * Math.PI;
}
while (angleDiff < -Math.PI) {
angleDiff += 2 * Math.PI;
}
// Only rotate if significant difference
if (Math.abs(angleDiff) > 0.1) {
if (!self.isRotating && !self.lastRotationState) {
safePlayAudio('RotatingStartPlasmaCannon', 'DoublePlasmaCannon return rotation start audio failed');
self.rotationSoundDelay = 30;
if (self.rotatingLoopInterval) {
LK.clearInterval(self.rotatingLoopInterval);
self.rotatingLoopInterval = null;
}
}
self.isRotating = true;
self.currentIdleAction = null; // Reset idle action
tween.stop(cannon, {
rotation: true
});
tween(cannon, {
rotation: originalAngle
}, {
duration: 500,
easing: tween.easeInOut,
onFinish: function onFinish() {
self.isRotating = false;
if (self.rotatingLoopInterval) {
LK.clearInterval(self.rotatingLoopInterval);
self.rotatingLoopInterval = null;
}
}
});
}
}
}
// Update rotation state tracking for better sound synchronization
self.wasRotating = self.lastRotationState;
self.lastRotationState = self.isRotating;
// Handle rotation sound delay for better timing
if (self.isRotating && self.rotationSoundDelay > 0) {
self.rotationSoundDelay--;
if (self.rotationSoundDelay === 0 && !self.rotatingLoopInterval) {
// Start the rotation loop sound after proper delay
self.rotatingLoopInterval = LK.setInterval(function () {
if (self.isRotating) {
safePlayAudio('RotatingPlasmaCannon', 'DoublePlasmaCannon rotation loop audio failed');
}
}, 500); // Consistent timing with target tracking
}
}
};
self.findTarget = function () {
// Only target enemies when active (not reloading)
if (!self.isActive || self.isReloading || self.currentAmmo <= 0) {
return null;
}
for (var i = 0; i < enemies.length; i++) {
var enemy = enemies[i];
var dx = enemy.x - self.x;
var dy = enemy.y - self.y;
var distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= self.range) {
return enemy;
}
}
return null;
};
self.shoot = function (target) {
// Calculate enemy movement prediction for accurate shooting
var enemyVelocityX = 0;
var enemyVelocityY = 0;
// Get enemy movement direction from their path
if (target.pathIndex < enemyPath.length - 1) {
var nextPoint = enemyPath[target.pathIndex + 1];
var dx = nextPoint.x - target.x;
var dy = nextPoint.y - target.y;
var distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
enemyVelocityX = dx / distance * target.speed;
enemyVelocityY = dy / distance * target.speed;
}
}
// Calculate bullet travel time and predict enemy position
var bulletSpeed = 8; // Match bullet speed from Bullet class
var distanceToEnemy = Math.sqrt((target.x - self.x) * (target.x - self.x) + (target.y - self.y) * (target.y - self.y));
var bulletTravelTime = distanceToEnemy / bulletSpeed;
// Predict where enemy will be when bullet arrives
var predictedX = target.x + enemyVelocityX * bulletTravelTime;
var predictedY = target.y + enemyVelocityY * bulletTravelTime;
// Calculate cannon corner positions for dual shooting from sprite corners
// Get current cannon rotation angle
var cannonAngle = cannon.rotation;
// Calculate the corners of the cannon sprite
// Cannon sprite is 100x163.06, with anchor at 0.5, 0.3
var spriteWidth = 100;
var spriteHeight = 163.06;
var anchorX = 0.5;
var anchorY = 0.3;
// Calculate local corner positions relative to anchor point - shooting from rear corners
var leftCornerLocalX = -spriteWidth * anchorX + 20; // Slightly inward from edge
var rightCornerLocalX = spriteWidth * (1 - anchorX) - 20; // Slightly inward from edge
var cornerLocalY = spriteHeight * (1 - anchorY) - 30; // Near the rear end of cannon (opposite side)
// Transform local positions to world coordinates with rotation
var cosAngle = Math.cos(cannonAngle);
var sinAngle = Math.sin(cannonAngle);
// Left corner world position
var leftBarrelX = self.x + (leftCornerLocalX * cosAngle - cornerLocalY * sinAngle);
var leftBarrelY = self.y + (leftCornerLocalX * sinAngle + cornerLocalY * cosAngle);
// Right corner world position
var rightBarrelX = self.x + (rightCornerLocalX * cosAngle - cornerLocalY * sinAngle);
var rightBarrelY = self.y + (rightCornerLocalX * sinAngle + cornerLocalY * cosAngle);
// Fire two bullets - one from each barrel
var bullet1 = new Bullet(self.getBulletType(), leftBarrelX, leftBarrelY, predictedX, predictedY, self.damage);
var bullet2 = new Bullet(self.getBulletType(), rightBarrelX, rightBarrelY, predictedX, predictedY, self.damage);
bullets.push(bullet1);
bullets.push(bullet2);
game.addChild(bullet1);
game.addChild(bullet2);
safePlayAudio('BulletPlasmaCannon', 'DoublePlasmaCannon shoot audio failed');
// Add recoil effect - move cannon sprite backward slightly then return
var recoilDistance = 8; // Small backward movement
var currentX = cannon.x;
var currentY = cannon.y;
// Calculate recoil direction based on cannon's current rotation
// Since sprite points UP by default and we add -PI/2 offset, we need to account for that
var actualCannonDirection = cannon.rotation + Math.PI / 2; // Convert back to actual direction
var recoilAngle = actualCannonDirection + Math.PI; // Opposite to firing direction
var recoilX = currentX + Math.cos(recoilAngle) * recoilDistance;
var recoilY = currentY + Math.sin(recoilAngle) * recoilDistance;
// Stop any existing position tweens on cannon sprite
tween.stop(cannon, {
x: true,
y: true
});
// Quick recoil backward then return to original position
tween(cannon, {
x: recoilX,
y: recoilY
}, {
duration: 80,
easing: tween.easeOut,
onFinish: function onFinish() {
// Return to original position
tween(cannon, {
x: currentX,
y: currentY
}, {
duration: 120,
easing: tween.easeInOut
});
}
});
// Add glowing effect to both plasma bullets
tween(bullet1.children[0], {
tint: 0x80ff80
}, {
duration: 100
});
tween(bullet2.children[0], {
tint: 0x80ff80
}, {
duration: 100
});
};
return self;
});
var Enemy = Container.expand(function (type) {
var self = Container.call(this);
var enemyGraphic = self.attachAsset(type, {
anchorX: 0.5,
anchorY: 0.5
});
self.enemyType = type;
self.pathIndex = 0;
self.speed = 1;
self.maxHealth = 100;
self.health = self.maxHealth;
self.goldValue = 10;
// Set enemy properties based on type
if (type === 'alienGrunt') {
self.health = 80;
self.maxHealth = 80;
self.speed = 1.5;
self.goldValue = 8;
} else if (type === 'shieldedWalker') {
self.health = 120;
self.maxHealth = 120;
self.speed = 1;
self.goldValue = 12;
} else if (type === 'agileCommander') {
self.health = 60;
self.maxHealth = 60;
self.speed = 2.5;
self.goldValue = 15;
} else if (type === 'armoredColossus') {
self.health = 200;
self.maxHealth = 200;
self.speed = 0.8;
self.goldValue = 25;
} else if (type === 'flyingDrone') {
self.health = 70;
self.maxHealth = 70;
self.speed = 2;
self.goldValue = 18;
}
self.takeDamage = function (damage) {
self.health -= damage;
if (self.health <= 0) {
return true; // Enemy is dead
}
return false;
};
self.update = function () {
if (self.pathIndex < enemyPath.length - 1) {
var currentTarget = enemyPath[self.pathIndex + 1];
var dx = currentTarget.x - self.x;
var dy = currentTarget.y - self.y;
var distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 5) {
self.pathIndex++;
} else {
self.x += dx / distance * self.speed;
self.y += dy / distance * self.speed;
}
}
};
return self;
});
var Tower = Container.expand(function (type, gridX, gridY) {
var self = Container.call(this);
var towerGraphic = self.attachAsset(type, {
anchorX: 0.5,
anchorY: 0.5
});
self.towerType = type;
self.gridX = gridX;
self.gridY = gridY;
self.range = 150;
self.damage = 25;
self.fireRate = 60; // frames between shots
self.lastShot = 0;
self.cost = 75;
self.currentTarget = null; // Store current target for continuous tracking
self.targetDirection = null; // Store targeting calculations for accurate shooting
// Set tower properties based on type
if (type === 'tacticalRecruit') {
self.range = 360;
self.damage = 20;
self.fireRate = 45;
self.cost = 50;
} else if (type === 'armoredElite') {
self.range = 360;
self.damage = 40;
self.fireRate = 90;
self.cost = 100;
} else if (type === 'rotatingTurret') {
self.range = 360;
self.damage = 15;
self.fireRate = 30;
self.cost = 75;
} else if (type === 'magneticCannon') {
self.range = 360;
self.damage = 60;
self.fireRate = 120;
self.cost = 150;
}
self.getBulletType = function () {
if (self.towerType === 'tacticalRecruit') {
return 'basicBullet';
}
if (self.towerType === 'armoredElite') {
return 'heavyBullet';
}
if (self.towerType === 'rotatingTurret') {
return 'turretBullet';
}
if (self.towerType === 'magneticCannon') {
return 'magneticBullet';
}
if (self.towerType === 'doublePlasmaCannon') {
return 'plasmaBullet';
}
return 'basicBullet';
};
self.update = function () {
self.lastShot++;
// Find and track target EVERY frame (like DoublePlasmaCannon)
var target = self.findTarget();
if (target) {
self.currentTarget = target;
// Calculate direction to target (standardized like DoublePlasmaCannon)
var dx = target.x - self.x;
var dy = target.y - self.y;
var distance = Math.sqrt(dx * dx + dy * dy);
var targetAngle = Math.atan2(dy, dx);
// Store targeting info for accurate shooting
self.targetDirection = {
dx: dx,
dy: dy,
distance: distance,
angle: targetAngle
};
// Add visual rotation like DoublePlasmaCannon
var currentAngle = towerGraphic.rotation;
var angleDiff = targetAngle - currentAngle;
// Normalize angle difference to shortest path
while (angleDiff > Math.PI) {
angleDiff -= 2 * Math.PI;
}
while (angleDiff < -Math.PI) {
angleDiff += 2 * Math.PI;
}
// Only tween if significant angle difference
if (Math.abs(angleDiff) > 0.1) {
tween.stop(towerGraphic, {
rotation: true
});
tween(towerGraphic, {
rotation: currentAngle + angleDiff
}, {
duration: 200,
easing: tween.easeOut
});
}
} else {
self.currentTarget = null; // Clear invalid target
self.targetDirection = null;
// Return tower to original position (pointing up) when no target
var originalAngle = -Math.PI / 2; // Pointing up (original position)
var currentAngle = towerGraphic.rotation;
var angleDiff = originalAngle - currentAngle;
// Normalize angle difference to shortest path
while (angleDiff > Math.PI) {
angleDiff -= 2 * Math.PI;
}
while (angleDiff < -Math.PI) {
angleDiff += 2 * Math.PI;
}
// Only tween if significant angle difference
if (Math.abs(angleDiff) > 0.1) {
tween.stop(towerGraphic, {
rotation: true
});
tween(towerGraphic, {
rotation: originalAngle
}, {
duration: 300,
easing: tween.easeOut
});
}
}
// Debug logging for tacticalRecruit
if (self.towerType === 'tacticalRecruit') {
console.log('tacticalRecruit update - target found:', self.currentTarget ? 'yes' : 'no', 'lastShot:', self.lastShot, 'fireRate:', self.fireRate);
}
// Shoot at current target when ready
if (self.currentTarget && self.lastShot >= self.fireRate) {
if (self.towerType === 'tacticalRecruit') {
console.log('tacticalRecruit shooting at target:', self.currentTarget.enemyType);
}
self.shoot(self.currentTarget);
self.lastShot = 0;
}
};
self.findTarget = function () {
// Check if current target is still valid first (like DoublePlasmaCannon logic)
if (self.currentTarget) {
var dx = self.currentTarget.x - self.x;
var dy = self.currentTarget.y - self.y;
var distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= self.range && self.currentTarget.health > 0) {
return self.currentTarget; // Keep tracking same enemy
}
}
var closestTarget = null;
var closestDistance = Infinity;
if (self.towerType === 'tacticalRecruit') {
console.log('tacticalRecruit findTarget - checking', enemies.length, 'enemies, range:', self.range);
}
for (var i = 0; i < enemies.length; i++) {
var enemy = enemies[i];
var dx = enemy.x - self.x;
var dy = enemy.y - self.y;
var distance = Math.sqrt(dx * dx + dy * dy);
if (self.towerType === 'tacticalRecruit') {
console.log('tacticalRecruit checking enemy', i, 'distance:', distance, 'range:', self.range);
}
if (distance <= self.range && distance < closestDistance) {
closestTarget = enemy;
closestDistance = distance;
if (self.towerType === 'tacticalRecruit') {
console.log('tacticalRecruit found closer target! distance:', distance);
}
}
}
return closestTarget;
};
self.shoot = function (target) {
// Validate target and coordinates before shooting
if (!target || isNaN(target.x) || isNaN(target.y) || isNaN(self.x) || isNaN(self.y)) {
console.error('Tower shoot - invalid coordinates:', {
tower: {
x: self.x,
y: self.y
},
target: target ? {
x: target.x,
y: target.y
} : 'null target'
});
return;
}
// Calculate enemy movement prediction for accurate shooting
var enemyVelocityX = 0;
var enemyVelocityY = 0;
// Get enemy movement direction from their path
if (target.pathIndex < enemyPath.length - 1) {
var nextPoint = enemyPath[target.pathIndex + 1];
var dx = nextPoint.x - target.x;
var dy = nextPoint.y - target.y;
var distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
enemyVelocityX = dx / distance * target.speed;
enemyVelocityY = dy / distance * target.speed;
}
}
// Calculate bullet travel time and predict enemy position
var bulletSpeed = 8; // Match bullet speed from Bullet class
var distanceToEnemy = Math.sqrt((target.x - self.x) * (target.x - self.x) + (target.y - self.y) * (target.y - self.y));
var bulletTravelTime = distanceToEnemy / bulletSpeed;
// Predict where enemy will be when bullet arrives
var predictedX = target.x + enemyVelocityX * bulletTravelTime;
var predictedY = target.y + enemyVelocityY * bulletTravelTime;
if (self.towerType === 'tacticalRecruit') {
console.log('tacticalRecruit shooting! Tower pos:', self.x, self.y, 'Target pos:', target.x, target.y, 'Predicted pos:', predictedX, predictedY, 'Bullet type:', self.getBulletType(), 'damage:', self.damage);
}
// Shoot at predicted position instead of current position
var bullet = new Bullet(self.getBulletType(), self.x, self.y, predictedX, predictedY, self.damage);
bullets.push(bullet);
game.addChild(bullet);
if (self.towerType === 'tacticalRecruit') {
console.log('tacticalRecruit bullet created and added, bullets array length:', bullets.length);
}
safePlayAudio('shoot', 'Tower shoot audio failed');
};
return self;
});
/****
* Initialize Game
****/
var game = new LK.Game({
backgroundColor: 0x1a1a2e
});
/****
* Game Code
****/
// Utility functions for stability
function isValidNumber(num) {
return typeof num === 'number' && !isNaN(num) && num !== Infinity && num !== -Infinity;
}
function safeAssign(obj, property, value, fallback) {
obj[property] = isValidNumber(value) ? value : fallback;
}
// Game state variables
var gold = 200;
var baseHealth = 20;
var currentWave = 1;
var totalWaves = 20;
var waveInProgress = false;
var waveCompleted = false;
var waveBreakTime = 0;
var gameEnded = false;
var enemiesSpawned = 0;
var enemiesToSpawn = [];
var spawnTimer = 0;
var waveStartTime = 0;
// Resource management limits
var maxBullets = 50;
var maxEnemies = 30;
var audioEnabled = true;
// Game object arrays
var towers = [];
var enemies = [];
var bullets = [];
var towerZones = [];
// Safe audio playback function
function safePlayAudio(soundId, fallbackMessage) {
if (!audioEnabled) {
return;
}
try {
var sound = LK.getSound(soundId);
if (sound && typeof sound.play === 'function') {
sound.play();
}
} catch (error) {
console.warn(fallbackMessage || 'Audio playback failed:', error);
// Disable audio temporarily on multiple failures
audioEnabled = false;
LK.setTimeout(function () {
audioEnabled = true;
}, 5000);
}
}
// Enemy path - creating a simple path from left to right with some turns
var enemyPath = [{
x: 0,
y: 1366
},
// Start off screen left
{
x: 300,
y: 1366
}, {
x: 300,
y: 1000
}, {
x: 800,
y: 1000
}, {
x: 800,
y: 1600
}, {
x: 1200,
y: 1600
}, {
x: 1200,
y: 800
}, {
x: 1700,
y: 800
}, {
x: 1700,
y: 1366
}, {
x: 2100,
y: 1366
} // End off screen right (base location)
];
// Draw path
for (var i = 0; i < enemyPath.length - 1; i++) {
var pathSegment = LK.getAsset('pathTile', {
anchorX: 0.5,
anchorY: 0.5
});
pathSegment.x = enemyPath[i].x;
pathSegment.y = enemyPath[i].y;
pathSegment.alpha = 0.3;
game.addChild(pathSegment);
}
// Create base
var base = LK.getAsset('base', {
anchorX: 0.5,
anchorY: 0.5
});
base.x = enemyPath[enemyPath.length - 1].x;
base.y = enemyPath[enemyPath.length - 1].y;
game.addChild(base);
// Create tower placement zones
var towerGrid = [{
x: 500,
y: 800
}, {
x: 500,
y: 1200
}, {
x: 500,
y: 1800
}, {
x: 1000,
y: 600
}, {
x: 1000,
y: 1200
}, {
x: 1000,
y: 1800
}, {
x: 1400,
y: 600
}, {
x: 1400,
y: 1000
}, {
x: 1400,
y: 1400
}];
for (var i = 0; i < towerGrid.length; i++) {
// Validate grid coordinates before creating zone
var gridX = towerGrid[i].x;
var gridY = towerGrid[i].y;
if (!isValidNumber(gridX) || !isValidNumber(gridY)) {
console.error('Invalid tower grid coordinates at index', i, ':', gridX, gridY);
continue;
}
var zone = LK.getAsset('towerZone', {
anchorX: 0.5,
anchorY: 0.5
});
safeAssign(zone, 'x', gridX, 500);
safeAssign(zone, 'y', gridY, 800);
zone.alpha = 0.2;
zone.gridIndex = i;
zone.occupied = false;
towerZones.push(zone);
game.addChild(zone);
}
// UI Elements
var goldText = new Text2('Gold: ' + gold, {
size: 60,
fill: 0xFFD700
});
goldText.anchor.set(0, 0);
goldText.x = 120;
goldText.y = 120;
LK.gui.topLeft.addChild(goldText);
var healthText = new Text2('Base Health: ' + baseHealth, {
size: 60,
fill: 0xFF4500
});
healthText.anchor.set(0.5, 0);
healthText.x = 0;
healthText.y = 120;
LK.gui.top.addChild(healthText);
var waveText = new Text2('Wave: ' + currentWave + '/' + totalWaves, {
size: 60,
fill: 0x00BFFF
});
waveText.anchor.set(1, 0);
waveText.x = -20;
waveText.y = 120;
LK.gui.topRight.addChild(waveText);
var nextWaveButton = new Text2('Start Wave 1', {
size: 70,
fill: 0x00FF00
});
nextWaveButton.anchor.set(0.5, 1);
nextWaveButton.x = 0;
nextWaveButton.y = -100;
LK.gui.bottom.addChild(nextWaveButton);
// Function to update button text
function updateNextWaveButton() {
if (gameEnded) {
nextWaveButton.setText('Game Over');
nextWaveButton.tint = 0xFF0000;
nextWaveButton.alpha = 0.5;
} else if (currentWave > totalWaves) {
nextWaveButton.setText('Victory!');
nextWaveButton.tint = 0xFFD700;
nextWaveButton.alpha = 0.5;
} else if (waveInProgress) {
nextWaveButton.setText('Wave ' + currentWave + ' in Progress');
nextWaveButton.tint = 0x888888;
nextWaveButton.alpha = 0.5;
} else if (waveBreakTime > 0) {
var seconds = Math.ceil(waveBreakTime / 60);
nextWaveButton.setText('Next Wave in ' + seconds + 's');
nextWaveButton.tint = 0xFFFF00;
nextWaveButton.alpha = 0.7;
} else {
nextWaveButton.setText('Start Wave ' + currentWave);
nextWaveButton.tint = 0x00FF00;
nextWaveButton.alpha = 1.0;
}
}
// Tower selection UI
var towerButtons = [];
var towerTypes = ['tacticalRecruit', 'armoredElite', 'rotatingTurret', 'magneticCannon', 'CanonDoublePlasmaBase'];
var towerCosts = [50, 100, 75, 150, 200];
var selectedTowerType = null;
for (var i = 0; i < towerTypes.length; i++) {
var button = LK.getAsset(towerTypes[i], {
anchorX: 0.5,
anchorY: 0.5,
scaleX: 0.8,
scaleY: 0.8
});
button.x = 200 + i * 120;
button.y = 2600;
button.towerType = towerTypes[i];
button.cost = towerCosts[i];
button.alpha = 0.7;
towerButtons.push(button);
game.addChild(button);
var costText = new Text2('$' + towerCosts[i], {
size: 40,
fill: 0xFFFFFF
});
costText.anchor.set(0.5, 0);
costText.x = button.x;
costText.y = button.y + 60;
game.addChild(costText);
}
// Wave configuration
function generateWave(waveNumber) {
var enemyList = [];
var enemyTypes = ['alienGrunt', 'shieldedWalker', 'agileCommander', 'armoredColossus', 'flyingDrone'];
// Base enemy count increases with wave number
var baseCount = Math.min(3 + Math.floor(waveNumber * 1.5), 25);
// Early waves have simpler enemies
if (waveNumber <= 3) {
for (var i = 0; i < baseCount; i++) {
enemyList.push('alienGrunt');
}
} else if (waveNumber <= 7) {
// Mix of basic enemies
for (var i = 0; i < baseCount; i++) {
var rand = Math.random();
if (rand < 0.6) {
enemyList.push('alienGrunt');
} else if (rand < 0.9) {
enemyList.push('shieldedWalker');
} else {
enemyList.push('agileCommander');
}
}
} else if (waveNumber <= 15) {
// More variety and difficulty
for (var i = 0; i < baseCount; i++) {
var rand = Math.random();
if (rand < 0.3) {
enemyList.push('alienGrunt');
} else if (rand < 0.5) {
enemyList.push('shieldedWalker');
} else if (rand < 0.7) {
enemyList.push('agileCommander');
} else if (rand < 0.9) {
enemyList.push('armoredColossus');
} else {
enemyList.push('flyingDrone');
}
}
} else {
// Final waves - all enemy types
for (var i = 0; i < baseCount; i++) {
var typeIndex = Math.floor(Math.random() * enemyTypes.length);
enemyList.push(enemyTypes[typeIndex]);
}
}
return enemyList;
}
function startNextWave() {
if (waveInProgress || gameEnded || waveBreakTime > 0) {
return;
}
console.log("Starting wave " + currentWave);
enemiesToSpawn = generateWave(currentWave);
waveInProgress = true;
waveCompleted = false;
enemiesSpawned = 0;
spawnTimer = 1;
waveStartTime = LK.ticks;
// Spawn first enemy immediately
spawnEnemy();
// Update wave text
waveText.setText('Wave: ' + currentWave + '/' + totalWaves);
// Immediate visual feedback
updateNextWaveButton();
}
function spawnEnemy() {
if (enemiesSpawned < enemiesToSpawn.length && !gameEnded) {
var enemyType = enemiesToSpawn[enemiesSpawned];
var enemy = new Enemy(enemyType);
enemy.x = enemyPath[0].x;
enemy.y = enemyPath[0].y;
enemies.push(enemy);
game.addChild(enemy);
enemiesSpawned++;
}
}
function checkWaveCompletion() {
// Wave is complete when all enemies have been spawned and destroyed
if (enemiesSpawned >= enemiesToSpawn.length && enemies.length === 0 && waveInProgress) {
waveInProgress = false;
waveCompleted = true;
waveBreakTime = 300; // 5 seconds at 60fps
// Wave completion bonus
gold += 25;
goldText.setText('Gold: ' + gold);
currentWave++;
if (currentWave > totalWaves) {
gameEnded = true;
LK.showYouWin();
return;
}
}
}
// Event handlers
game.down = function (x, y, obj) {
// Check tower button clicks
for (var i = 0; i < towerButtons.length; i++) {
var button = towerButtons[i];
var dx = x - button.x;
var dy = y - button.y;
if (Math.abs(dx) < 50 && Math.abs(dy) < 50) {
if (gold >= button.cost) {
selectedTowerType = button.towerType;
// Highlight selected tower
for (var j = 0; j < towerButtons.length; j++) {
towerButtons[j].alpha = j === i ? 1.0 : 0.7;
}
return;
}
}
}
// Check tower zone clicks
for (var i = 0; i < towerZones.length; i++) {
var zone = towerZones[i];
var dx = x - zone.x;
var dy = y - zone.y;
if (Math.abs(dx) < 50 && Math.abs(dy) < 50 && !zone.occupied && selectedTowerType) {
var towerCost = 50;
if (selectedTowerType === 'armoredElite') {
towerCost = 100;
} else if (selectedTowerType === 'rotatingTurret') {
towerCost = 75;
} else if (selectedTowerType === 'magneticCannon') {
towerCost = 150;
} else if (selectedTowerType === 'CanonDoublePlasmaBase') {
towerCost = 200;
}
if (gold >= towerCost) {
// Validate zone coordinates before tower creation
if (!isValidNumber(zone.x) || !isValidNumber(zone.y)) {
console.error('Invalid zone coordinates for tower placement:', zone.x, zone.y);
return;
}
var tower;
if (selectedTowerType === 'CanonDoublePlasmaBase') {
tower = new DoublePlasmaCannon(i % 3, Math.floor(i / 3));
} else {
tower = new Tower(selectedTowerType, i % 3, Math.floor(i / 3));
}
// Validate tower was created successfully
if (!tower) {
console.error('Failed to create tower of type:', selectedTowerType);
return;
}
safeAssign(tower, 'x', zone.x, 500);
safeAssign(tower, 'y', zone.y, 800);
// Final validation of tower position
if (!isValidNumber(tower.x) || !isValidNumber(tower.y)) {
console.error('Tower position became NaN after assignment:', tower.x, tower.y);
return;
}
towers.push(tower);
game.addChild(tower);
zone.occupied = true;
gold -= towerCost;
goldText.setText('Gold: ' + gold);
safePlayAudio('placeTower', 'Tower placement audio failed');
selectedTowerType = null;
// Reset tower button highlights
for (var j = 0; j < towerButtons.length; j++) {
towerButtons[j].alpha = 0.7;
}
}
return;
}
}
// Check next wave button click
// Button is positioned at bottom center of screen
var buttonDx = x - 1024; // Center of screen width (2048/2)
var buttonDy = y - 2500; // Near bottom of screen
if (Math.abs(buttonDx) < 200 && Math.abs(buttonDy) < 60 && !waveInProgress && waveBreakTime === 0 && currentWave <= totalWaves && !gameEnded) {
console.log("Next wave button clicked!");
startNextWave();
}
};
// Game update loop
game.update = function () {
if (gameEnded) {
return;
}
// Handle wave break timer
if (waveBreakTime > 0) {
waveBreakTime--;
if (waveBreakTime === 0) {
waveCompleted = false;
}
}
// Spawn enemies during active wave with limits
if (waveInProgress && !gameEnded && enemies.length < maxEnemies) {
spawnTimer++;
// Spawn enemies every 48 frames (0.8 seconds at 60fps) after the first one
if (spawnTimer >= 48) {
spawnEnemy();
spawnTimer = 0;
}
}
// Update towers with rate limiting
for (var i = 0; i < towers.length; i++) {
try {
towers[i].update();
} catch (error) {
console.error('Tower update failed for tower', i, ':', error);
}
}
// Update enemies
for (var i = enemies.length - 1; i >= 0; i--) {
var enemy = enemies[i];
enemy.update();
// Check if enemy reached base
if (enemy.pathIndex >= enemyPath.length - 1) {
baseHealth--;
healthText.setText('Base Health: ' + baseHealth);
enemy.destroy();
enemies.splice(i, 1);
if (baseHealth <= 0) {
gameEnded = true;
LK.showGameOver();
return;
}
}
}
// Update bullets with resource limits
for (var i = bullets.length - 1; i >= 0; i--) {
var bullet = bullets[i];
// Validate bullet exists and has valid position
if (!bullet || !isValidNumber(bullet.x) || !isValidNumber(bullet.y)) {
if (bullet) {
bullet.destroy();
}
bullets.splice(i, 1);
continue;
}
try {
bullet.update();
} catch (error) {
console.error('Bullet update failed:', error);
bullet.destroy();
bullets.splice(i, 1);
continue;
}
// Check bullet collision with enemies
var hit = false;
for (var j = 0; j < enemies.length; j++) {
var enemy = enemies[j];
if (!enemy || !isValidNumber(enemy.x) || !isValidNumber(enemy.y)) {
continue;
}
var dx = bullet.x - enemy.x;
var dy = bullet.y - enemy.y;
var distance = Math.sqrt(dx * dx + dy * dy);
if (isValidNumber(distance) && distance < 30) {
if (enemy.takeDamage(bullet.damage)) {
// Enemy died
gold += enemy.goldValue;
goldText.setText('Gold: ' + gold);
safePlayAudio('enemyDeath', 'Enemy death audio failed');
enemy.destroy();
enemies.splice(j, 1);
}
bullet.destroy();
bullets.splice(i, 1);
hit = true;
break;
}
}
// Remove bullets that go off screen
if (!hit && (bullet.x < -50 || bullet.x > 2098 || bullet.y < -50 || bullet.y > 2782)) {
bullet.destroy();
bullets.splice(i, 1);
}
}
// Limit total bullets for performance
if (bullets.length > maxBullets) {
var excessBullets = bullets.length - maxBullets;
for (var k = 0; k < excessBullets; k++) {
if (bullets[k]) {
bullets[k].destroy();
bullets.splice(k, 1);
}
}
}
// Check wave completion
checkWaveCompletion();
// Update UI
updateNextWaveButton();
};