diff --git a/js/Game.js b/js/Game.js index befe657..7456a2e 100644 --- a/js/Game.js +++ b/js/Game.js @@ -360,6 +360,9 @@ class Game { this.systems.weather.reset(); this.screenEffects.reset(); + // Spawn asteroids across the map + this.systems.spawner.spawnAsteroids(50); + // Hide menu, show game this.systems.ui.showScreen('game'); @@ -389,8 +392,8 @@ class Game { const maxHealth = shipData.baseStats.maxHealth + metaHealth; this.player.addComponent('position', Components.Position( - this.canvas.width / 2, - this.canvas.height / 2 + WORLD_WIDTH / 2, + WORLD_HEIGHT / 2 )); this.player.addComponent('velocity', Components.Velocity(0, 0)); diff --git a/js/constants.js b/js/constants.js index c567f80..20b200c 100644 --- a/js/constants.js +++ b/js/constants.js @@ -3,6 +3,10 @@ * Centralized constants to avoid redeclaration errors */ +// World size (playable area - 2x canvas size) +const WORLD_WIDTH = 2560; +const WORLD_HEIGHT = 1440; + // Enemy size threshold for boss detection const BOSS_SIZE_THRESHOLD = 35; diff --git a/js/core/ECS.js b/js/core/ECS.js index 4702209..8e0ed19 100644 --- a/js/core/ECS.js +++ b/js/core/ECS.js @@ -321,5 +321,10 @@ const Components = { patterns, phaseTime: 0, nextPhaseHealth: 0.5 + }), + Asteroid: (sizeTier) => ({ + sizeTier, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 2 }) }; diff --git a/js/systems/CollisionSystem.js b/js/systems/CollisionSystem.js index 090aa30..cfa50e4 100644 --- a/js/systems/CollisionSystem.js +++ b/js/systems/CollisionSystem.js @@ -29,6 +29,9 @@ class CollisionSystem { // Check projectile-enemy collisions this.checkProjectileEnemyCollisions(); + // Check projectile-asteroid collisions + this.checkProjectileAsteroidCollisions(); + // Check player-enemy collisions this.checkPlayerEnemyCollisions(); @@ -97,6 +100,53 @@ class CollisionSystem { } } + checkProjectileAsteroidCollisions() { + const projectiles = this.world.getEntitiesByType('projectile'); + const asteroids = this.world.getEntitiesByType('asteroid'); + + for (const projectile of projectiles) { + const projPos = projectile.getComponent('position'); + const projCol = projectile.getComponent('collision'); + const projComp = projectile.getComponent('projectile'); + + if (!projPos || !projCol || !projComp) continue; + + // Check if projectile is from player + const ownerEntity = this.world.getEntity(projComp.owner); + if (!ownerEntity || ownerEntity.type !== 'player') continue; + + for (const asteroid of asteroids) { + const asteroidPos = asteroid.getComponent('position'); + const asteroidCol = asteroid.getComponent('collision'); + const asteroidHealth = asteroid.getComponent('health'); + + if (!asteroidPos || !asteroidCol || !asteroidHealth) continue; + + if (MathUtils.circleCollision( + projPos.x, projPos.y, projCol.radius, + asteroidPos.x, asteroidPos.y, asteroidCol.radius + )) { + // Deal damage to asteroid + this.damageAsteroid(asteroid, projComp.damage); + + // Don't remove orbital projectiles + if (projComp.orbital) { + continue; + } + + // Remove projectile if not piercing + if (projComp.piercing <= 0) { + this.world.removeEntity(projectile.id); + } else { + projComp.piercing--; + } + + break; + } + } + } + } + checkPlayerEnemyCollisions() { const players = this.world.getEntitiesByType('player'); const enemies = this.world.getEntitiesByType('enemy'); @@ -312,6 +362,51 @@ class CollisionSystem { } } + damageAsteroid(asteroid, damage) { + const health = asteroid.getComponent('health'); + const pos = asteroid.getComponent('position'); + const renderable = asteroid.getComponent('renderable'); + + if (!health) return; + + health.current -= damage; + + // Play hit sound + if (this.audioManager && this.audioManager.initialized) { + this.audioManager.playSFX('hit', MathUtils.randomFloat(1.0, 1.3)); + } + + if (health.current <= 0) { + this.destroyAsteroid(asteroid); + } + } + + destroyAsteroid(asteroid) { + const pos = asteroid.getComponent('position'); + const renderable = asteroid.getComponent('renderable'); + + if (pos) { + // Create particle explosion + if (this.particleSystem) { + const color = renderable ? renderable.color : '#8B7355'; + this.particleSystem.createExplosion(pos.x, pos.y, renderable ? renderable.size : 12, color, 15); + } + + // Play destruction sound + if (this.audioManager && this.audioManager.initialized) { + this.audioManager.playSFX('explosion', MathUtils.randomFloat(0.9, 1.1)); + } + + // Small chance to drop XP + if (Math.random() < 0.3) { + this.spawnPickup(pos.x, pos.y, 'xp', 2); + } + } + + // Remove asteroid (asteroids don't count as kills) + this.world.removeEntity(asteroid.id); + } + killEnemy(enemy) { const enemyComp = enemy.getComponent('enemy'); const pos = enemy.getComponent('position'); diff --git a/js/systems/MovementSystem.js b/js/systems/MovementSystem.js index a779397..e1666a8 100644 --- a/js/systems/MovementSystem.js +++ b/js/systems/MovementSystem.js @@ -28,6 +28,15 @@ class MovementSystem { this.updatePlayerMovement(player, deltaTime); } + // Update asteroid rotation + const asteroids = this.world.getEntitiesByType('asteroid'); + for (const asteroid of asteroids) { + const asteroidComp = asteroid.getComponent('asteroid'); + if (asteroidComp) { + asteroidComp.rotation += asteroidComp.rotationSpeed * deltaTime; + } + } + // Update orbital and laser projectiles const projectiles = this.world.getEntitiesByType('projectile'); for (const projectile of projectiles) { @@ -133,12 +142,12 @@ class MovementSystem { if (Math.abs(vel.vy) < velocityThreshold) vel.vy = 0; } - // Keep player in bounds + // Keep player in world bounds const collision = player.getComponent('collision'); const radius = collision ? collision.radius : 15; - pos.x = MathUtils.clamp(pos.x, radius, this.canvas.width - radius); - pos.y = MathUtils.clamp(pos.y, radius, this.canvas.height - radius); + pos.x = MathUtils.clamp(pos.x, radius, WORLD_WIDTH - radius); + pos.y = MathUtils.clamp(pos.y, radius, WORLD_HEIGHT - radius); } updateEntityPosition(entity, deltaTime) { @@ -150,10 +159,10 @@ class MovementSystem { pos.x += vel.vx * deltaTime; pos.y += vel.vy * deltaTime; - // Remove entities that are off-screen (with buffer) - const buffer = 100; - if (pos.x < -buffer || pos.x > this.canvas.width + buffer || - pos.y < -buffer || pos.y > this.canvas.height + buffer) { + // Remove entities that are far off-world (with buffer) + const buffer = 200; + if (pos.x < -buffer || pos.x > WORLD_WIDTH + buffer || + pos.y < -buffer || pos.y > WORLD_HEIGHT + buffer) { // Don't remove player or pickups if (entity.type !== 'player' && entity.type !== 'pickup') { diff --git a/js/systems/RenderSystem.js b/js/systems/RenderSystem.js index bc2c732..f7a248c 100644 --- a/js/systems/RenderSystem.js +++ b/js/systems/RenderSystem.js @@ -11,6 +11,13 @@ class RenderSystem { this.world = world; this.gameState = gameState; + // Camera system for larger world + this.cameraX = 0; + this.cameraY = 0; + + // Zoom level (0.5 = 2x zoom out to see more) + this.zoomLevel = 0.5; + // Screen effects reference (set from Game.js) this.screenEffects = null; @@ -41,8 +48,8 @@ class RenderSystem { layers.forEach(layer => { for (let i = 0; i < layer.count; i++) { this.stars.push({ - x: Math.random() * this.canvas.width, - y: Math.random() * this.canvas.height, + x: Math.random() * WORLD_WIDTH, + y: Math.random() * WORLD_HEIGHT, speed: layer.speed, size: layer.size, alpha: layer.alpha, @@ -53,6 +60,30 @@ class RenderSystem { }); } + /** + * Update camera position to follow player + */ + updateCamera() { + const players = this.world.getEntitiesByType('player'); + if (players.length > 0) { + const player = players[0]; + const pos = player.getComponent('position'); + if (pos) { + // Calculate visible area based on zoom level + const visibleWidth = this.canvas.width / this.zoomLevel; + const visibleHeight = this.canvas.height / this.zoomLevel; + + // Center camera on player + this.cameraX = pos.x - visibleWidth / 2; + this.cameraY = pos.y - visibleHeight / 2; + + // Clamp camera to world bounds + this.cameraX = MathUtils.clamp(this.cameraX, 0, WORLD_WIDTH - visibleWidth); + this.cameraY = MathUtils.clamp(this.cameraY, 0, WORLD_HEIGHT - visibleHeight); + } + } + } + /** * Main render loop * @param {number} deltaTime - Time since last frame in seconds @@ -61,6 +92,9 @@ class RenderSystem { this.lastFrameTime = deltaTime; this.fps = deltaTime > 0 ? 1 / deltaTime : 60; + // Update camera position based on player + this.updateCamera(); + // Clear canvas this.ctx.fillStyle = '#000'; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); @@ -70,7 +104,7 @@ class RenderSystem { this.gameState.isState(GameStates.LEVEL_UP) || this.gameState.isState(GameStates.PAUSED)) { - // Save context for screen shake + // Save context for camera, zoom, and screen shake this.ctx.save(); // Apply screen shake if available @@ -78,10 +112,17 @@ class RenderSystem { this.screenEffects.applyShake(this.ctx); } + // Apply zoom scale (0.5 = 2x zoom out) + // IMPORTANT: Scale must be applied before translate to maintain correct camera positioning + this.ctx.scale(this.zoomLevel, this.zoomLevel); + + // Apply camera translation (adjusted for zoom) + this.ctx.translate(-this.cameraX, -this.cameraY); + this.renderStarfield(deltaTime); this.renderEntities(); - // Restore context after shake + // Restore context after camera, zoom, and shake this.ctx.restore(); this.renderBossHealthBar(); @@ -101,11 +142,11 @@ class RenderSystem { this.ctx.save(); this.stars.forEach(star => { - // Parallax movement + // Parallax movement (vertical scrolling) star.y += star.speed * 60 * deltaTime; - if (star.y > this.canvas.height) { + if (star.y > WORLD_HEIGHT) { star.y = 0; - star.x = Math.random() * this.canvas.width; + star.x = Math.random() * WORLD_WIDTH; } // Twinkling effect @@ -124,8 +165,9 @@ class RenderSystem { * Render all entities in the game world */ renderEntities() { - // Render order: particles -> pickups -> projectiles -> enemies -> weather -> player + // Render order: particles -> asteroids -> pickups -> projectiles -> enemies -> weather -> player this.renderParticles(); + this.renderAsteroids(); this.renderPickups(); this.renderProjectiles(); this.renderEnemies(); @@ -162,6 +204,64 @@ class RenderSystem { }); } + /** + * Render asteroids + */ + renderAsteroids() { + const asteroids = this.world.getEntitiesByType('asteroid'); + + asteroids.forEach(asteroid => { + const pos = asteroid.getComponent('position'); + const render = asteroid.getComponent('renderable'); + const asteroidComp = asteroid.getComponent('asteroid'); + const health = asteroid.getComponent('health'); + + if (!pos || !render || !asteroidComp) return; + + this.ctx.save(); + this.ctx.translate(pos.x, pos.y); + this.ctx.rotate(asteroidComp.rotation); + + // Draw asteroid as rocky brown circle with darker patches + const gradient = this.ctx.createRadialGradient(0, 0, 0, 0, 0, render.size); + gradient.addColorStop(0, '#8B7355'); + gradient.addColorStop(0.5, '#6B5345'); + gradient.addColorStop(1, '#4B3325'); + + this.ctx.fillStyle = gradient; + this.ctx.shadowBlur = 5; + this.ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; + + this.ctx.beginPath(); + this.ctx.arc(0, 0, render.size, 0, Math.PI * 2); + this.ctx.fill(); + + // Add some crater-like details + this.ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; + for (let i = 0; i < 3; i++) { + const angle = (i / 3) * Math.PI * 2; + const dist = render.size * 0.4; + const craterSize = render.size * 0.15; + this.ctx.beginPath(); + this.ctx.arc( + Math.cos(angle) * dist, + Math.sin(angle) * dist, + craterSize, + 0, + Math.PI * 2 + ); + this.ctx.fill(); + } + + this.ctx.restore(); + + // Health bar for larger asteroids + if (health && render.size > 15) { + this.drawHealthBar(pos.x, pos.y - render.size - 8, health.current, health.max, false); + } + }); + } + /** * Render pickups (XP, health, etc.) */ diff --git a/js/systems/SpawnerSystem.js b/js/systems/SpawnerSystem.js index f9aa033..8e1bf0c 100644 --- a/js/systems/SpawnerSystem.js +++ b/js/systems/SpawnerSystem.js @@ -575,7 +575,7 @@ class SpawnerSystem { } /** - * Calculate spawn position on screen edge + * Calculate spawn position on screen edge relative to player * @param {number} playerX - Player X position * @param {number} playerY - Player Y position * @returns {{x: number, y: number}} Spawn position @@ -587,46 +587,35 @@ class SpawnerSystem { const screenWidth = this.canvas.width; const screenHeight = this.canvas.height; + let spawnX, spawnY; + switch (edge) { - case 0: // Top - return { - x: MathUtils.clamp( - playerX + (Math.random() - 0.5) * screenWidth, - margin, - screenWidth - margin - ), - y: -margin - }; - case 1: // Right - return { - x: screenWidth + margin, - y: MathUtils.clamp( - playerY + (Math.random() - 0.5) * screenHeight, - margin, - screenHeight - margin - ) - }; - case 2: // Bottom - return { - x: MathUtils.clamp( - playerX + (Math.random() - 0.5) * screenWidth, - margin, - screenWidth - margin - ), - y: screenHeight + margin - }; - case 3: // Left - return { - x: -margin, - y: MathUtils.clamp( - playerY + (Math.random() - 0.5) * screenHeight, - margin, - screenHeight - margin - ) - }; + case 0: // Top - spawn above player's view + spawnX = playerX + (Math.random() - 0.5) * screenWidth; + spawnY = playerY - screenHeight / 2 - margin; + break; + case 1: // Right - spawn to the right of player's view + spawnX = playerX + screenWidth / 2 + margin; + spawnY = playerY + (Math.random() - 0.5) * screenHeight; + break; + case 2: // Bottom - spawn below player's view + spawnX = playerX + (Math.random() - 0.5) * screenWidth; + spawnY = playerY + screenHeight / 2 + margin; + break; + case 3: // Left - spawn to the left of player's view + spawnX = playerX - screenWidth / 2 - margin; + spawnY = playerY + (Math.random() - 0.5) * screenHeight; + break; default: - return { x: playerX, y: playerY }; + spawnX = playerX; + spawnY = playerY; } + + // Clamp to world bounds + spawnX = MathUtils.clamp(spawnX, margin, WORLD_WIDTH - margin); + spawnY = MathUtils.clamp(spawnY, margin, WORLD_HEIGHT - margin); + + return { x: spawnX, y: spawnY }; } /** @@ -644,4 +633,50 @@ class SpawnerSystem { }; this.difficultyMultiplier = 1.0; } + + /** + * Spawn asteroids across the map + * @param {number} count - Number of asteroids to spawn + */ + spawnAsteroids(count = 50) { + logger.info('SpawnerSystem', `Spawning ${count} asteroids across the map`); + + for (let i = 0; i < count; i++) { + // Random position in world + const x = Math.random() * (WORLD_WIDTH - 200) + 100; + const y = Math.random() * (WORLD_HEIGHT - 200) + 100; + + // All small asteroids for now + const sizeTier = 'small'; + const size = 12; + const health = 30; + + this.createAsteroid(x, y, size, health, sizeTier); + } + } + + /** + * Create asteroid entity + * @param {number} x - X position + * @param {number} y - Y position + * @param {number} size - Size/radius + * @param {number} health - Health + * @param {string} sizeTier - Size tier ('small', 'medium', 'large') + * @returns {Entity} Created asteroid + */ + createAsteroid(x, y, size, health, sizeTier) { + const asteroid = this.world.createEntity('asteroid'); + + asteroid.addComponent('position', Components.Position(x, y)); + asteroid.addComponent('health', Components.Health(health, health)); + asteroid.addComponent('collision', Components.Collision(size)); + asteroid.addComponent('renderable', Components.Renderable( + '#8B7355', // Brown/grey color + size, + 'circle' + )); + asteroid.addComponent('asteroid', Components.Asteroid(sizeTier)); + + return asteroid; + } }