diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..0d3ea6b --- /dev/null +++ b/js/audio.js @@ -0,0 +1,188 @@ +// Audio System +const Audio = { + ctx: null, + enabled: true, + volume: 0.3, + + // Initialize audio context + init() { + // Create on first user interaction + document.addEventListener('click', () => this.ensureContext(), { once: true }); + document.addEventListener('keydown', () => this.ensureContext(), { once: true }); + }, + + ensureContext() { + if (!this.ctx) { + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + } + }, + + // Play a tone + playTone(frequency, duration, type = 'square', volume = null) { + if (!this.enabled || !this.ctx) return; + + try { + const oscillator = this.ctx.createOscillator(); + const gainNode = this.ctx.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(this.ctx.destination); + + oscillator.frequency.value = frequency; + oscillator.type = type; + + const vol = (volume || this.volume) * this.volume; + gainNode.gain.setValueAtTime(vol, this.ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration); + + oscillator.start(this.ctx.currentTime); + oscillator.stop(this.ctx.currentTime + duration); + } catch (e) { + // Ignore audio errors + } + }, + + // Play noise burst (for explosions, etc) + playNoise(duration, volume = null) { + if (!this.enabled || !this.ctx) return; + + try { + const bufferSize = this.ctx.sampleRate * duration; + const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1; + } + + const noise = this.ctx.createBufferSource(); + const gainNode = this.ctx.createGain(); + const filter = this.ctx.createBiquadFilter(); + + noise.buffer = buffer; + filter.type = 'lowpass'; + filter.frequency.value = 1000; + + noise.connect(filter); + filter.connect(gainNode); + gainNode.connect(this.ctx.destination); + + const vol = (volume || this.volume) * this.volume; + gainNode.gain.setValueAtTime(vol, this.ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration); + + noise.start(this.ctx.currentTime); + } catch (e) { + // Ignore audio errors + } + }, + + // Sound effects + playPlace() { + this.playTone(200, 0.1, 'square', 0.2); + setTimeout(() => this.playTone(300, 0.08, 'square', 0.15), 50); + }, + + playDelete() { + this.playTone(150, 0.15, 'sawtooth', 0.2); + setTimeout(() => this.playTone(100, 0.1, 'sawtooth', 0.15), 80); + }, + + playMine() { + this.playTone(100 + Math.random() * 50, 0.08, 'triangle', 0.15); + }, + + playMineComplete() { + this.playTone(400, 0.1, 'sine', 0.2); + setTimeout(() => this.playTone(500, 0.08, 'sine', 0.15), 60); + }, + + playShoot(towerType) { + switch(towerType) { + case 'gun_turret': + this.playNoise(0.05, 0.3); + this.playTone(150, 0.05, 'square', 0.2); + break; + case 'flame_turret': + this.playNoise(0.15, 0.25); + this.playTone(80, 0.1, 'sawtooth', 0.15); + break; + case 'laser_turret': + this.playTone(800, 0.15, 'sine', 0.25); + this.playTone(1200, 0.1, 'sine', 0.15); + break; + case 'tesla_turret': + this.playTone(200, 0.05, 'sawtooth', 0.2); + this.playTone(400, 0.1, 'sawtooth', 0.15); + this.playTone(300, 0.08, 'sawtooth', 0.1); + break; + case 'cannon_turret': + this.playNoise(0.2, 0.4); + this.playTone(60, 0.2, 'triangle', 0.3); + break; + default: + this.playTone(200, 0.05, 'square', 0.2); + } + }, + + playExplosion() { + this.playNoise(0.3, 0.4); + this.playTone(50, 0.3, 'triangle', 0.3); + setTimeout(() => this.playTone(40, 0.2, 'triangle', 0.2), 100); + }, + + playEnemyHit() { + this.playTone(150, 0.05, 'square', 0.1); + }, + + playEnemyDeath() { + this.playTone(200, 0.1, 'sawtooth', 0.2); + setTimeout(() => this.playTone(100, 0.15, 'sawtooth', 0.15), 50); + }, + + playWaveStart() { + const notes = [300, 400, 300, 500]; + notes.forEach((freq, i) => { + setTimeout(() => this.playTone(freq, 0.2, 'square', 0.25), i * 150); + }); + }, + + playWaveComplete() { + const notes = [400, 500, 600, 800]; + notes.forEach((freq, i) => { + setTimeout(() => this.playTone(freq, 0.15, 'sine', 0.25), i * 100); + }); + }, + + playBuildingDamage() { + this.playTone(100, 0.1, 'sawtooth', 0.2); + }, + + playBuildingDestroyed() { + this.playNoise(0.3, 0.35); + this.playTone(80, 0.3, 'sawtooth', 0.25); + }, + + playUIClick() { + this.playTone(400, 0.05, 'sine', 0.1); + }, + + playError() { + this.playTone(150, 0.15, 'square', 0.2); + setTimeout(() => this.playTone(100, 0.15, 'square', 0.15), 100); + }, + + playDevMode() { + this.playTone(600, 0.1, 'sine', 0.2); + setTimeout(() => this.playTone(800, 0.1, 'sine', 0.2), 100); + }, + + // Toggle sound + toggle() { + this.enabled = !this.enabled; + if (this.enabled) { + this.playUIClick(); + } + return this.enabled; + } +}; diff --git a/js/buildings.js b/js/buildings.js new file mode 100644 index 0000000..5e1f2fd --- /dev/null +++ b/js/buildings.js @@ -0,0 +1,163 @@ +// Building Management +const Buildings = { + // All placed buildings + list: [], + + // Initialize + init() { + this.list = []; + }, + + // Get building at tile position + getAt(x, y) { + return this.list.find(b => { + const size = Utils.getBuildingSize(b.type); + return x >= b.x && x < b.x + size.w && y >= b.y && y < b.y + size.h; + }); + }, + + // Check if building can be placed + canPlace(type, x, y) { + const size = Utils.getBuildingSize(type); + + // Check bounds and collisions + for (let dy = 0; dy < size.h; dy++) { + for (let dx = 0; dx < size.w; dx++) { + const tx = x + dx; + const ty = y + dy; + if (!Utils.inBounds(tx, ty)) return false; + if (this.getAt(tx, ty)) return false; + } + } + + // Miners must be on resources + if (type === 'miner') { + let hasResource = false; + for (let dy = 0; dy < size.h; dy++) { + for (let dx = 0; dx < size.w; dx++) { + if (Terrain.hasResource(x + dx, y + dy)) { + hasResource = true; + } + } + } + if (!hasResource) return false; + } + + return true; + }, + + // Place a building + place(type, x, y, rotation) { + const cost = CONFIG.COSTS[type]; + if (!Resources.canAfford(cost)) { + Audio.playError(); + return false; + } + if (!this.canPlace(type, x, y)) { + Audio.playError(); + return false; + } + + const building = { + type, + x, + y, + rotation: rotation || 0, + inventory: {}, + output: {}, + progress: 0, + health: 100, + maxHealth: 100, + recipe: type === 'assembler' ? 'gear' : null + }; + + this.list.push(building); + Resources.payCost(cost); + Audio.playPlace(); + return true; + }, + + // Remove a building + remove(x, y) { + const building = this.getAt(x, y); + if (!building) return false; + + const idx = this.list.indexOf(building); + if (idx !== -1) { + this.list.splice(idx, 1); + Resources.refundHalf(CONFIG.COSTS[building.type]); + Audio.playDelete(); + return true; + } + return false; + }, + + // Get all buildings by type + getByType(type) { + return this.list.filter(b => b.type === type); + }, + + // Add item to building inventory + addToInventory(building, item, amount = 1) { + building.inventory[item] = (building.inventory[item] || 0) + amount; + }, + + // Remove item from building inventory + removeFromInventory(building, item, amount = 1) { + if ((building.inventory[item] || 0) < amount) return false; + building.inventory[item] -= amount; + if (building.inventory[item] <= 0) delete building.inventory[item]; + return true; + }, + + // Add item to building output + addToOutput(building, item, amount = 1) { + building.output[item] = (building.output[item] || 0) + amount; + }, + + // Remove item from building output + removeFromOutput(building, item, amount = 1) { + if ((building.output[item] || 0) < amount) return false; + building.output[item] -= amount; + if (building.output[item] <= 0) delete building.output[item]; + return true; + }, + + // Get inventory count + getInventoryCount(building, item) { + return building.inventory[item] || 0; + }, + + // Get output count + getOutputCount(building, item) { + return building.output[item] || 0; + }, + + // Get total output items + getTotalOutput(building) { + return Object.values(building.output || {}).reduce((a, b) => a + b, 0); + }, + + // Get total inventory items + getTotalInventory(building) { + return Object.values(building.inventory || {}).reduce((a, b) => a + b, 0); + }, + + // Get all buildings data for saving + getData() { + return this.list.map(b => ({ + ...b, + inventory: { ...b.inventory }, + output: { ...b.output } + })); + }, + + // Load buildings data + setData(data) { + this.list = data.map(b => ({ + ...b, + inventory: { ...b.inventory }, + output: { ...b.output } + })); + } +}; diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..17f5964 --- /dev/null +++ b/js/config.js @@ -0,0 +1,117 @@ +// Game Configuration Constants +const CONFIG = { + // Dev mode - toggle with backtick key (`) + DEV_MODE: false, + + TILE_SIZE: 40, + MAP_WIDTH: 100, + MAP_HEIGHT: 100, + + // Directions: 0=right, 1=down, 2=left, 3=up + DIR: [ + { x: 1, y: 0 }, + { x: 0, y: 1 }, + { x: -1, y: 0 }, + { x: 0, y: -1 } + ], + + // Building sizes + BUILDING_SIZES: { + 'miner': { w: 2, h: 2 }, + 'furnace': { w: 2, h: 2 }, + 'assembler': { w: 3, h: 3 }, + 'belt': { w: 1, h: 1 }, + 'inserter': { w: 1, h: 1 }, + 'chest': { w: 1, h: 1 } + }, + + // Building costs + COSTS: { + 'miner': { 'iron-plate': 10 }, + 'belt': { 'iron-plate': 1 }, + 'inserter': { 'iron-plate': 2, 'gear': 1 }, + 'furnace': { 'iron': 10 }, + 'assembler': { 'iron-plate': 5, 'gear': 3 }, + 'chest': { 'iron-plate': 5 } + }, + + // Production speeds (items per second at 1x speed) + SPEEDS: { + 'miner': 1.0, + 'furnace': 0.5, + 'assembler': 0.3, + 'inserter': 2.0, + 'belt': 3.0 + }, + + // Manual mining + MANUAL_MINE_RATE: 0.5, // seconds per ore + MANUAL_MINE_AMOUNT: 1, + + // Recipes + RECIPES: { + 'gear': { + inputs: { 'iron-plate': 2 }, + output: 'gear', + outputCount: 1 + }, + 'circuit': { + inputs: { 'iron-plate': 1, 'copper-plate': 3 }, + output: 'circuit', + outputCount: 1 + } + }, + + // Smelting recipes + SMELTING: { + 'iron': { output: 'iron-plate', fuel: 'coal' }, + 'copper': { output: 'copper-plate', fuel: 'coal' } + }, + + // Resource types and their colors + RESOURCE_COLORS: { + 'iron': { light: '#9aaabb', dark: '#6a7a8a' }, + 'copper': { light: '#e8b878', dark: '#b87830' }, + 'coal': { light: '#3a3a3a', dark: '#1a1a1a' } + }, + + // Building styles for rendering + BUILDING_STYLES: { + 'miner': { light: '#ffbb44', dark: '#cc7700', accent: '#ffdd88' }, + 'belt': { light: '#777', dark: '#444', accent: '#999' }, + 'inserter': { light: '#ffdd55', dark: '#bb9900', accent: '#ffee88' }, + 'furnace': { light: '#ee5533', dark: '#992211', accent: '#ff8866' }, + 'assembler': { light: '#55aaee', dark: '#226699', accent: '#88ccff' }, + 'chest': { light: '#aa7744', dark: '#664422', accent: '#cc9966' } + }, + + // Starting resources + STARTING_RESOURCES: { + 'iron': 0, + 'copper': 0, + 'coal': 0, + 'iron-plate': 20, + 'copper-plate': 10, + 'gear': 5, + 'circuit': 0, + 'xp': 0 + }, + + // Resource display order + RESOURCE_ORDER: ['iron', 'copper', 'coal', 'iron-plate', 'copper-plate', 'gear', 'circuit', 'xp'], + + // Resource display names + RESOURCE_NAMES: { + 'iron': 'Iron Ore', + 'copper': 'Copper Ore', + 'coal': 'Coal', + 'iron-plate': 'Iron Plates', + 'copper-plate': 'Copper Plates', + 'gear': 'Gears', + 'circuit': 'Circuits', + 'xp': 'XP' + }, + + // Tower types for toolbar + TOWER_ORDER: ['gun_turret', 'flame_turret', 'laser_turret', 'tesla_turret', 'cannon_turret'] +}; diff --git a/js/enemies.js b/js/enemies.js new file mode 100644 index 0000000..4b0264e --- /dev/null +++ b/js/enemies.js @@ -0,0 +1,351 @@ +// Enemy System +const Enemies = { + list: [], + wave: 0, + waveTimer: 0, + timeBetweenWaves: 120, // seconds between waves + waveActive: false, + enemiesRemaining: 0, + spawnQueue: [], + spawnTimer: 0, + + // Enemy types + TYPES: { + 'biter': { + health: 50, + maxHealth: 50, + speed: 30, // pixels per second + damage: 10, + attackSpeed: 1, // attacks per second + range: 40, // attack range in pixels + color: '#44aa44', + colorDark: '#227722', + size: 0.6, // tile fraction + xp: 10 + }, + 'spitter': { + health: 30, + maxHealth: 30, + speed: 25, + damage: 15, + attackSpeed: 0.5, + range: 120, + color: '#aa44aa', + colorDark: '#772277', + size: 0.5, + xp: 15 + }, + 'big_biter': { + health: 200, + maxHealth: 200, + speed: 20, + damage: 30, + attackSpeed: 0.8, + range: 50, + color: '#228822', + colorDark: '#115511', + size: 0.9, + xp: 50 + }, + 'tank_biter': { + health: 500, + maxHealth: 500, + speed: 15, + damage: 50, + attackSpeed: 0.5, + range: 60, + color: '#116611', + colorDark: '#0a440a', + size: 1.2, + xp: 100 + } + }, + + // Initialize + init() { + this.list = []; + this.wave = 0; + this.waveTimer = this.timeBetweenWaves * 0.5; // First wave comes sooner + this.waveActive = false; + this.enemiesRemaining = 0; + this.spawnQueue = []; + this.spawnTimer = 0; + }, + + // Update enemies + update(dt) { + // Wave timer + if (!this.waveActive) { + this.waveTimer -= dt; + if (this.waveTimer <= 0) { + this.startWave(); + } + } + + // Spawn queued enemies + if (this.spawnQueue.length > 0) { + this.spawnTimer -= dt; + if (this.spawnTimer <= 0) { + this.spawnTimer = 0.5; // Spawn every 0.5 seconds + const spawn = this.spawnQueue.shift(); + this.spawnEnemy(spawn.type, spawn.x, spawn.y); + } + } + + // Update each enemy + for (let i = this.list.length - 1; i >= 0; i--) { + const enemy = this.list[i]; + this.updateEnemy(enemy, dt); + + // Remove dead enemies + if (enemy.health <= 0) { + this.list.splice(i, 1); + this.enemiesRemaining--; + Resources.add('xp', enemy.xp || 10); + Audio.playEnemyDeath(); + } + } + + // Check if wave is complete + if (this.waveActive && this.enemiesRemaining <= 0 && this.spawnQueue.length === 0) { + this.waveActive = false; + this.waveTimer = this.timeBetweenWaves; + Audio.playWaveComplete(); + } + }, + + // Update single enemy + updateEnemy(enemy, dt) { + const type = this.TYPES[enemy.type]; + + // Find target (nearest building or base center) + const target = this.findTarget(enemy); + + if (target) { + const dx = target.x - enemy.x; + const dy = target.y - enemy.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + // Move toward target if not in range + if (dist > type.range) { + const moveX = (dx / dist) * type.speed * dt; + const moveY = (dy / dist) * type.speed * dt; + enemy.x += moveX; + enemy.y += moveY; + enemy.angle = Math.atan2(dy, dx); + } else { + // Attack + enemy.attackCooldown = (enemy.attackCooldown || 0) - dt; + if (enemy.attackCooldown <= 0) { + enemy.attackCooldown = 1 / type.attackSpeed; + this.attack(enemy, target); + } + } + } + + // Animation + enemy.animTime = (enemy.animTime || 0) + dt * 5; + }, + + // Find nearest target for enemy + findTarget(enemy) { + let nearest = null; + let nearestDist = Infinity; + + // Check buildings + Buildings.list.forEach(b => { + const size = Utils.getBuildingSize(b.type); + const bx = (b.x + size.w / 2) * CONFIG.TILE_SIZE; + const by = (b.y + size.h / 2) * CONFIG.TILE_SIZE; + const dist = Math.sqrt((bx - enemy.x) ** 2 + (by - enemy.y) ** 2); + + if (dist < nearestDist) { + nearestDist = dist; + nearest = { x: bx, y: by, building: b }; + } + }); + + // Check towers + Towers.list.forEach(t => { + const tx = (t.x + 0.5) * CONFIG.TILE_SIZE; + const ty = (t.y + 0.5) * CONFIG.TILE_SIZE; + const dist = Math.sqrt((tx - enemy.x) ** 2 + (ty - enemy.y) ** 2); + + if (dist < nearestDist) { + nearestDist = dist; + nearest = { x: tx, y: ty, tower: t }; + } + }); + + // If no targets, go to map center + if (!nearest) { + nearest = { + x: CONFIG.MAP_WIDTH * CONFIG.TILE_SIZE / 2, + y: CONFIG.MAP_HEIGHT * CONFIG.TILE_SIZE / 2 + }; + } + + return nearest; + }, + + // Enemy attacks target + attack(enemy, target) { + const type = this.TYPES[enemy.type]; + + if (target.building) { + target.building.health = (target.building.health || 100) - type.damage; + Audio.playBuildingDamage(); + if (target.building.health <= 0) { + Buildings.remove(target.building.x, target.building.y); + Audio.playBuildingDestroyed(); + } + } else if (target.tower) { + target.tower.health -= type.damage; + Audio.playBuildingDamage(); + if (target.tower.health <= 0) { + Towers.remove(target.tower.x, target.tower.y); + Audio.playBuildingDestroyed(); + } + } + + // Visual feedback + enemy.attacking = true; + setTimeout(() => enemy.attacking = false, 100); + }, + + // Start a new wave + startWave() { + this.wave++; + this.waveActive = true; + Audio.playWaveStart(); + + // Calculate wave composition + const baseCount = 5 + this.wave * 3; + const spawnPoints = this.getSpawnPoints(); + + // Distribute enemies across spawn points + spawnPoints.forEach(point => { + let count = Math.ceil(baseCount / spawnPoints.length); + + for (let i = 0; i < count; i++) { + let type = 'biter'; + + // Higher waves get tougher enemies + if (this.wave >= 3 && Math.random() < 0.2) type = 'spitter'; + if (this.wave >= 5 && Math.random() < 0.15) type = 'big_biter'; + if (this.wave >= 8 && Math.random() < 0.1) type = 'tank_biter'; + + // Add some randomness to spawn position + const offsetX = (Math.random() - 0.5) * 200; + const offsetY = (Math.random() - 0.5) * 200; + + this.spawnQueue.push({ + type, + x: point.x + offsetX, + y: point.y + offsetY + }); + this.enemiesRemaining++; + } + }); + }, + + // Get spawn points at map edges + getSpawnPoints() { + const points = []; + const margin = 2 * CONFIG.TILE_SIZE; + const mapW = CONFIG.MAP_WIDTH * CONFIG.TILE_SIZE; + const mapH = CONFIG.MAP_HEIGHT * CONFIG.TILE_SIZE; + + // Pick 1-4 spawn points based on wave number + const numPoints = Math.min(4, 1 + Math.floor(this.wave / 3)); + + const edges = [ + { x: margin, y: mapH / 2 }, // Left + { x: mapW - margin, y: mapH / 2 }, // Right + { x: mapW / 2, y: margin }, // Top + { x: mapW / 2, y: mapH - margin } // Bottom + ]; + + // Shuffle and pick + for (let i = edges.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [edges[i], edges[j]] = [edges[j], edges[i]]; + } + + return edges.slice(0, numPoints); + }, + + // Spawn a single enemy + spawnEnemy(type, x, y) { + const enemyType = this.TYPES[type]; + this.list.push({ + type, + x, + y, + health: enemyType.maxHealth, + maxHealth: enemyType.maxHealth, + angle: 0, + animTime: 0, + attackCooldown: 0, + xp: enemyType.xp + }); + }, + + // Damage an enemy + damage(enemy, amount) { + enemy.health -= amount; + enemy.damageFlash = 0.1; + Audio.playEnemyHit(); + }, + + // Get enemies in radius + getInRadius(x, y, radius) { + return this.list.filter(e => { + const dist = Math.sqrt((e.x - x) ** 2 + (e.y - y) ** 2); + return dist <= radius; + }); + }, + + // Get nearest enemy to position + getNearest(x, y, maxRange = Infinity) { + let nearest = null; + let nearestDist = maxRange; + + this.list.forEach(e => { + const dist = Math.sqrt((e.x - x) ** 2 + (e.y - y) ** 2); + if (dist < nearestDist) { + nearestDist = dist; + nearest = e; + } + }); + + return nearest; + }, + + // Get time until next wave + getTimeUntilWave() { + return this.waveActive ? 0 : Math.max(0, this.waveTimer); + }, + + // Get data for saving + getData() { + return { + list: this.list.map(e => ({ ...e })), + wave: this.wave, + waveTimer: this.waveTimer, + waveActive: this.waveActive, + enemiesRemaining: this.enemiesRemaining, + spawnQueue: [...this.spawnQueue] + }; + }, + + // Load data + setData(data) { + this.list = data.list?.map(e => ({ ...e })) || []; + this.wave = data.wave || 0; + this.waveTimer = data.waveTimer || this.timeBetweenWaves; + this.waveActive = data.waveActive || false; + this.enemiesRemaining = data.enemiesRemaining || 0; + this.spawnQueue = data.spawnQueue || []; + } +}; diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..9475287 --- /dev/null +++ b/js/game.js @@ -0,0 +1,70 @@ +// Main Game Controller +const game = { + ui: UI, + lastTime: 0, + minimapTimer: 0, + + // Initialize game + init() { + // Initialize all systems + Audio.init(); + Resources.init(); + Terrain.init(); + Buildings.init(); + Towers.init(); + Enemies.init(); + Simulation.init(); + Renderer.init(); + Input.init(); + UI.init(); + + // Center camera on starting area + Renderer.centerCamera(30, 30); + + // Start game loop + this.gameLoop(0); + }, + + // Start new game + newGame() { + Resources.init(); + Terrain.init(); + Buildings.init(); + Towers.init(); + Enemies.init(); + Simulation.init(); + Renderer.centerCamera(30, 30); + }, + + // Main game loop + gameLoop(time) { + const dt = Math.min((time - game.lastTime) / 1000, 0.1); + game.lastTime = time; + + // Update simulation + Simulation.update(dt); + Towers.update(dt); + Enemies.update(dt); + + // Update UI + UI.updateResources(); + UI.updateToolButtons(Input.currentTool); + UI.updateWaveIndicator(); + + // Update minimap less frequently + game.minimapTimer += dt; + if (game.minimapTimer > 0.2) { + game.minimapTimer = 0; + UI.updateMinimap(); + } + + // Render + Renderer.render(Input.currentTool, Input.rotation, Input.mouseWorld); + + // Next frame + requestAnimationFrame((t) => game.gameLoop(t)); + } +}; + +// Start game when page loads +document.addEventListener('DOMContentLoaded', () => game.init());